diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5b7fabfbf..ce0158bc4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,7 +7,7 @@ - [ ] This comment contains a description of changes (with reason) - [ ] Referenced issue is linked - [ ] If you've fixed a bug or added code that should be tested, add tests! -- [ ] Documentation in `docs` is updated. If you've created a new file, add it to the API documentation pages. +- [ ] Documentation in `docs` is updated. Public modules under documented packages are picked up automatically by the recursive API autosummary. diff --git a/.github/workflows/build_package.yml b/.github/workflows/build_package.yml index 41e341569..3a12077b5 100644 --- a/.github/workflows/build_package.yml +++ b/.github/workflows/build_package.yml @@ -1,6 +1,16 @@ name: Build DrEvalPy Package -on: [push, pull_request] +on: + # Same-repo PR branches were building twice (push + pull_request = 18 jobs). + # Keep the pull_request build and limit push builds to the branches that are + # not covered by a PR: the default branches and release branches, which + # feed python-publish.yml. + push: + branches: + - main + - master + - "release/*" + pull_request: jobs: build: @@ -12,25 +22,15 @@ jobs: python: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 name: Check out source-code repository - - name: Setup Python - uses: actions/setup-python@v6 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: python-version: ${{ matrix.python }} - - name: Install Poetry - run: | - pipx install poetry - pipx inject poetry poetry-plugin-export - poetry --version - - name: Build package - run: poetry build --ansi - - - name: Install required twine packaging dependencies - run: pip install setuptools wheel twine + run: uv build - - name: Check twine package - run: twine check dist/* + - name: Check package with twine + run: uvx twine check dist/* diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8b848f3d8..d34e2a815 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -10,9 +10,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out the repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@v6.0.0 + uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d # v6.0.0 with: skip-delete: true diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index c8e4eb349..100b71d14 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -1,22 +1,17 @@ name: Create and publish a Docker image -# Configures this workflow to run every time a release is published on: release: types: [published] -# Defines two custom environment variables for the workflow. -# These are used for the Container registry domain, and a name for the Docker image that this workflow builds. env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} -# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. jobs: build-and-push-image: runs-on: ubuntu-latest - # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. permissions: contents: read packages: write @@ -24,21 +19,17 @@ jobs: id-token: write steps: - # Necessary for buildx - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4 - # Set up BuildKit Docker container builder to be able to build - # multi-platform images and export cache - # https://github.com/docker/setup-buildx-action - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Log in to the Container registry - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -46,16 +37,13 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. - # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. - # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . push: true diff --git a/.github/workflows/publish_docs.yml b/.github/workflows/publish_docs.yml index 405fd2c89..a0fa0dd08 100644 --- a/.github/workflows/publish_docs.yml +++ b/.github/workflows/publish_docs.yml @@ -1,36 +1,32 @@ name: Build Documentation -on: [push] +# PR-side docs builds are covered by the `docs` job in run_tests.yml. +# This workflow exists to deploy, so it only runs on the branches that deploy. +on: + push: + branches: + - main + - master jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 name: Check out source-code repository - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install pip - run: | - python -m pip install --upgrade pip + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - - name: Install doc dependencies - run: | - pip install -r docs/requirements.txt + - name: Install dependencies + run: uv sync --group docs - name: Build docs - run: | - cd docs - make html + run: uv run sphinx-build -W -b html docs docs/_build/html - name: Deploy - if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main'}} - uses: peaceiris/actions-gh-pages@v4 + if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./docs/_build/html diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 98d85e023..d4d7fad82 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,11 +1,3 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - name: Upload Python Package on: @@ -20,19 +12,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.x" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - name: Build package - run: python -m build + run: uv build + - name: Publish package - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release_drafter.yml b/.github/workflows/release_drafter.yml index 4e519cbf7..a9b782e12 100644 --- a/.github/workflows/release_drafter.yml +++ b/.github/workflows/release_drafter.yml @@ -1,12 +1,14 @@ name: Release Drafter + on: push: branches: - development + jobs: update_release_draft: runs-on: ubuntu-latest steps: - - uses: release-drafter/release-drafter@v7 + - uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0 env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/run_network_tests.yml b/.github/workflows/run_network_tests.yml new file mode 100644 index 000000000..cc7d6700d --- /dev/null +++ b/.github/workflows/run_network_tests.yml @@ -0,0 +1,59 @@ +name: Network Tests + +# The `network`-marked tests download pretrained weights and remote annotations +# from the drevalpy artifacts bucket, so they are deselected everywhere else: +# the pre-commit hook runs the fast tier and run_tests.yml runs +# `-m "not network"`. Without this workflow they would never run at all. +on: + schedule: + # Mondays 05:00 UTC. Weekly is enough: these tests guard artifact + # availability and loader wiring, neither of which changes per commit. + - cron: "0 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: network-tests + cancel-in-progress: false + +jobs: + network-tests: + name: Network tests + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + # The bucket refuses anonymous reads (403), so without credentials every + # one of these tests fails for a reason that has nothing to do with the + # code. Detect that up front and skip instead, so a fork or a repo with no + # secrets configured does not carry a permanently red weekly badge. + - name: Check for artifact credentials + id: creds + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + run: | + if [ -n "$AWS_ACCESS_KEY_ID" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Network tests skipped::AWS_ACCESS_KEY_ID is not configured, so the artifacts bucket is unreachable. Add the AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY secrets to enable the network tests." + fi + + - name: Run network tests + if: steps.creds.outputs.available == 'true' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ vars.AWS_DEFAULT_REGION }} + # Optional mirror. Empty is fine: get_artifacts_uri() strips blanks and + # falls back to the bundled default bucket. + DREVALPY_ARTIFACTS_URI: ${{ vars.DREVALPY_ARTIFACTS_URI }} + # The explicit `-m network` replaces the `addopts` marker expression + # rather than adding to it, so this selects exactly the 8 network tests. + # No coverage: run_tests.yml owns the coverage gate. + run: uv run pytest -m network diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 32d149f52..a9b924ede 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -9,125 +9,88 @@ on: - "*" jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Ruff check + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + - name: Check lockfile is up to date + run: uv lock --check + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Run ty + run: uv run ty check + tests: - name: ${{ matrix.session }} ${{ matrix.python-version }} / ${{ matrix.os }} + name: Tests (Python ${{ matrix.python-version }} / ${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: - fail-fast: true + # Keep the Linux 3.11/3.12/3.13 signal alive when the Windows job fails. + fail-fast: false matrix: + python-version: ["3.11", "3.12", "3.13"] + os: [ubuntu-latest] include: - - { python-version: "3.13", os: ubuntu-latest, session: "pre-commit" } - - { python-version: "3.13", os: ubuntu-latest, session: "mypy" } - - { python-version: "3.13", os: ubuntu-latest, session: "tests" } - - { python-version: "3.13", os: windows-latest, session: "typeguard" } - - { python-version: "3.13", os: ubuntu-latest, session: "xdoctest" } - - { python-version: "3.13", os: ubuntu-latest, session: "docs-build" } - - env: - NOXSESSION: ${{ matrix.session }} - + - python-version: "3.13" + os: windows-latest steps: - - name: Check out the repository - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: python-version: ${{ matrix.python-version }} - - name: Install Poetry - run: | - pipx install poetry - pipx inject poetry poetry-plugin-export - poetry --version - - - name: Install nox nox-poetry rich - run: | - pipx install nox - pipx inject nox nox-poetry - pipx inject nox rich - nox --version - - - name: Compute pre-commit cache key - if: matrix.session == 'pre-commit' - id: pre-commit-cache - shell: python - run: | - import hashlib - import sys - - python = "py{}.{}".format(*sys.version_info[:2]) - payload = sys.version.encode() + sys.executable.encode() - digest = hashlib.sha256(payload).hexdigest() - result = "${{ runner.os }}-{}-{}-pre-commit".format(python, digest[:8]) - - print("::set-output name=result::{}".format(result)) - - - name: Restore pre-commit cache - uses: actions/cache@v5.0.5 - if: matrix.session == 'pre-commit' - with: - path: ~/.cache/pre-commit - key: ${{ steps.pre-commit-cache.outputs.result }}-${{ hashFiles('.pre-commit-config.yaml') }} - restore-keys: | - ${{ steps.pre-commit-cache.outputs.result }}- - - - name: Run Nox - run: | - nox --force-color --python=${{ matrix.python-version }} - - - name: Upload coverage data - if: always() && matrix.session == 'tests' && matrix.os == 'ubuntu-latest' - uses: actions/upload-artifact@v7 - with: - name: coverage-data - path: ".coverage.*" - include-hidden-files: "true" - - - name: Upload documentation - if: matrix.session == 'docs-build' - uses: actions/upload-artifact@v7 + - name: Run tests + # The explicit `-m` is load-bearing: `addopts` in pyproject.toml defaults + # to the fast tier (`-m "not slow and not network"`), and a command-line + # `-m` *replaces* it rather than adding to it. Without this the job would + # silently run only the fast tier and the coverage gate would fail. + # `not network` (rather than no marker at all) deselects the tests that + # download pretrained weights from the artifacts bucket, which is not + # readable without credentials; run_network_tests.yml covers those. + run: uv run pytest -m "not network" --cov --cov-report=xml --cov-report=json + + - name: Coverage floor per module + # The only place coverage is enforced: the pre-commit hook runs the fast + # tier without `--cov`, so this job is the gate for both the aggregate + # `fail_under` and the per-module floors. + run: uv run python tools/coverage_gate.py + + - name: Module size ceiling + # Runs here as well as in the prek hook so a PR cannot land the + # regression by skipping hooks. Needs no test run - it only parses the + # working tree - so it costs a couple of seconds. + run: uv run python tools/size_gate.py + + - name: Upload coverage + if: always() && matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@6d497bbcd2616c4cbf2f07268d34081bf421ba2c # v7.0.0 with: - name: docs - path: docs/_build + token: ${{ secrets.CODECOV_TOKEN }} - coverage: + docs: + name: Build Documentation runs-on: ubuntu-latest - needs: tests steps: - - name: Check out the repository - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - name: Set up Python 3.13 - uses: actions/setup-python@v6 - with: - python-version: 3.13 - - - name: Install Poetry - run: | - pipx install poetry - pipx inject poetry poetry-plugin-export - poetry --version - - - name: Install nox nox-poetry rich - run: | - pipx install nox - pipx inject nox nox-poetry - pipx inject nox rich - nox --version - - - name: Download coverage data - uses: actions/download-artifact@v8 - with: - name: coverage-data - - - name: Combine coverage data and display human readable report - run: nox --force-color --session=coverage + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - - name: Create coverage report - run: nox --force-color --session=coverage -- xml -i - - - name: Upload coverage report - uses: codecov/codecov-action@v7.0.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} + - name: Build docs + run: uv run --group docs sphinx-build -W docs docs/_build diff --git a/.gitignore b/.gitignore index e7c276c4f..b5eb57846 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ # Data -data/ +/data/ # Results directory is created when running the demo notebook -results/ +/results/ # Wandb directory is created when running the benchmark with wandb wandb/ @@ -61,6 +61,7 @@ htmlcov/ .cache nosetests.xml coverage.xml +coverage.json *.cover *.py,cover .hypothesis/ @@ -86,6 +87,14 @@ instance/ # Sphinx documentation docs/_build/ +docs/python/api/_autosummary/ +docs/cli/_generated_reference.rst +docs/concepts/_generated_cell_line_featurizers.rst +docs/concepts/_generated_drug_featurizers.rst +docs/concepts/_generated_model_zoo.rst +docs/concepts/_generated_predictors.rst +docs/python/_generated_examples.rst + # PyBuilder .pybuilder/ @@ -156,8 +165,14 @@ venv.bak/ # mypy .mypy_cache/ +.complexipy_cache/ .dmypy.json dmypy.json +.repowise/ +.vscode/ +# Repowise generates .cursor/mcp.json and .cursor/rules/repowise.mdc. Ignored +# because the MCP entry embeds an absolute path to this checkout. +.cursor/ # Pyre type checker .pyre/ @@ -177,3 +192,11 @@ cython_debug/ /data/GDSC/ .Rproj.user .idea/ +test.ipynb +# Scratch directory at the repository root only. Anchored with a leading slash +# because the unanchored pattern also swallowed the shipped drevalpy/testing +# package, which then silently vanished from the wheel. +/testing/ + +# macOS Finder metadata +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fdb212c95..508f891c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,65 +1,57 @@ repos: - - repo: local + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.8 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 hooks: - - id: black - name: black - entry: black - language: system - types: [python] - require_serial: true - id: check-added-large-files - name: Check for added large files - entry: check-added-large-files - exclude: ^dreval_demo\.ipynb$ - language: system + exclude: ^uv\.lock$ - id: check-toml - name: Check Toml - entry: check-toml - language: system - types: [toml] - id: check-yaml - name: Check Yaml - entry: check-yaml - language: system - types: [yaml] - id: end-of-file-fixer - name: Fix End of Files - entry: end-of-file-fixer - language: system - types: [text] - stages: [pre-commit, pre-push, manual] exclude: docs/ - - id: flake8 - name: flake8 - entry: flake8 - language: system - types: [python] - require_serial: true - args: - - --ignore=D212,W503,C901,N803,N806,S615,S403,S301 - - id: pyupgrade - name: pyupgrade - description: Automatically upgrade syntax for newer versions. - entry: pyupgrade - language: system - types: [python] - args: [--py39-plus, --keep-runtime-typing] + - id: trailing-whitespace - repo: https://github.com/pre-commit/mirrors-prettier rev: v2.5.1 hooks: - id: prettier - - repo: https://github.com/pycqa/isort - rev: 6.0.1 + - repo: https://github.com/rohaquinlop/complexipy-pre-commit + rev: v6.2.0 hooks: - - id: isort - name: isort (python) - - id: isort - name: isort (cython) - types: [cython] - - id: isort - name: isort (pyi) - types: [pyi] - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + - id: complexipy + types_or: [python, pyi] + files: ^drevalpy/ + - repo: local hooks: - - id: trailing-whitespace + - id: size-gate + name: module size ceiling + # Cheap enough to run on every commit: it parses the working tree and + # counts AST statements, needing neither a test run nor coverage. The + # ceiling and its exemptions live in [tool.drevalpy.size_gate] in + # pyproject.toml. Also enforced in CI, so `--no-verify` cannot land a + # regression. + entry: uv run python tools/size_gate.py + language: system + pass_filenames: false + files: ^(drevalpy/.*\.py|pyproject\.toml|tools/size_gate\.py)$ + - id: pytest + name: pytest (fast tier) + # No arguments on purpose: `addopts` in pyproject.toml defaults to + # `-m "not slow and not network"`, which is the fast tier. Coverage and + # the per-module coverage gate run in CI on the full suite + # (`.github/workflows/run_tests.yml`), not here: the floors are only + # meaningful on the full suite, and measuring coverage roughly doubles + # the run on top of that. + # + # Tier definitions, timings and the commands to run each tier live in + # the "Tests" and "Commands" sections of AGENTS.md - the single source + # for those numbers. Do not restate them here. + entry: uv run pytest + language: system + pass_filenames: false + always_run: true + stages: [pre-commit] diff --git a/.readthedocs.yml b/.readthedocs.yml index 5234fdc2a..96a2d8f8d 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,32 +1,22 @@ # .readthedocs.yaml # Read the Docs configuration file -# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +# See https://docs.readthedocs.com/platform/stable/config-file/v2.html -# Required version: 2 -# Set the OS, Python version and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.11" - # You can also specify other tool versions: - # nodejs: "19" - # rust: "1.64" - # golang: "1.19" + python: "3.13" -# Build documentation in the "docs/" directory with Sphinx sphinx: configuration: docs/conf.py -# Optionally build your docs in additional formats such as PDF and ePub formats: all -# Optional but recommended, declare the Python requirements required -# to build your documentation -# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: - - requirements: docs/requirements.txt - - method: pip - path: . + - method: uv + command: sync + groups: + - docs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..f05cc95b4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,325 @@ +# Agent notes + +## Commands + +```bash +uv run prek run --all-files # all hooks: ruff, whitespace/format, complexipy, fast tests +uv run prek install # run hooks on commit +uv run pytest # fast tier (addopts default: -m "not slow and not network") +uv run pytest -m slow # extended tier +uv run pytest -m "not network" # fast + extended, i.e. what CI runs +uv run --group docs sphinx-build -W docs docs/_build +``` + +Coverage is enforced in CI, not by the hooks. Reproduce it yourself before pushing +a change that adds or moves shipped code: + +```bash +uv run pytest -m "not network" --cov --cov-report=json --cov-report=term-missing +uv run python tools/coverage_gate.py +uv run python tools/size_gate.py +``` + +`tools/size_gate.py` caps AST statements per module against +`[tool.drevalpy.size_gate].max_module_statements`, which is what keeps the mixin +splits in `models/mixins/` and `components/featurizers/` from growing back. It runs +as a prek hook too, and needs no test run. Its exemptions follow the coverage +gate's ethos: a recorded ceiling is a _measured_ count, lowered as a module shrinks +and never raised to let a regression through. + +Repowise needs **both** coverage reports, which is the opposite of what a single +`.coverage` ingest suggests - measured against repowise 0.42.0: + +```bash +uv run pytest -m "not network" --cov --cov-context=test # writes .coverage + coverage.json +repowise coverage add .coverage # per-test map ONLY +uv run coverage lcov -o coverage.lcov +repowise coverage add coverage.lcov && rm coverage.lcov # per-file line coverage +repowise coverage status # expect both sections +``` + +`repowise coverage add .coverage` reports only `Built the test-to-code map: N test->file record(s)` and leaves `repowise coverage status`'s line-coverage figure +untouched at whatever a previous lcov ingest left - it does **not** carry per-file +coverage, despite `--help` presenting `.coverage` as the richer input. So the lcov +round-trip is not a fallback; it is required for the coverage-derived health +markers. The two ingests are independent and both persist in `.repowise/`. + +`--cov-context=test` is what records the per-test map (`coverage run --contexts=test`, the command repowise's `--help` prints, is not a valid +coverage.py option - measured against 7.15.2). The map is what +`repowise impacted-tests` and the `missing_tests` / `tests_to_run` fields of +`repowise risk` read; both are verified working here. + +`repowise coverage add` does **not** read coverage.py's JSON report (`--format` +takes `lcov|cobertura|clover|repowise-json`, and `repowise-json` is Repowise's +own schema). + +Without an ingest Repowise guesses from filenames, and its `has_test_file` probe +does not recognise `test_init.py`, so every re-export barrel is reported as an +untested hotspot. One artifact survives **both** ingest paths: lcov emits no `DA:` +lines for a zero-statement file, and `.coverage` does not fix it, so the eleven +one-line `__init__.py` files under `components/featurizers/{cell_line,drug}`, +`components/featurizers/shared` and the seven literature model packages read 0% +rather than 100% and each takes a full `-2.00` `coverage_gradient` penalty, 22 +points in total. Ignore those; the other `__init__.py` files carry genuine partial +coverage and are real debt. + +### Per-PR risk check + +```bash +repowise risk --target --changed-file [--changed-file ...] +``` + +Note the singular, repeatable `--changed-file`. The response leads with a +directive naming `will_break`, `missing_cochanges`, `missing_tests` and +`tests_to_run`; the last two are populated only once the test-to-code map above is +ingested. `repowise impacted-tests ` answers the narrower "which tests +cover these changed lines" - it takes a **git revspec**, not a path, and a +single-commit argument diffs that commit against its parent. + +## Tests + +- Markers: a test is `slow` if it costs >= 0.2s; `network` means it downloads + remote artifacts and cannot run on a machine without internet access. + A command-line `-m` **replaces** the one in `addopts` rather than adding to it. +- Apply `slow` with `pytestmark` at file or class level, not per test, and drop + the marker again when an optimisation brings the test back under 0.2s. +- Do **not** add `pytest-xdist`; it measured slower than serial. `--dist loadfile`/`loadscope` and random ordering are also unsafe: the component + registries are process-global and the `BUILTIN_*_NAMES` sets in + `drevalpy/registry/_builtins.py` are lazy singletons that cache on first access. +- Any test that calls `load_extensions` or a `register_*` decorator must evict on + teardown via `restore_component_registries()` / `isolated_component_registries()` + from `tests/registry/_helpers.py`, plus `clear_external_zoo()` when an external + zoo is loaded. `register_builtin_components()` is **not** a teardown - it only adds. + +## Test layout + +`tests/` mirrors `drevalpy/`, one test file per module, enforced by +`tests/test_module_mirror_policy.py`. Rules in order of precedence: + +1. **Public module.** `drevalpy/a/b/c.py` -> `tests/a/b/test_c.py`. +2. **Package surface.** A package's `__init__.py` re-exports go in `test_init.py` + in the mirrored directory. The guard does not check `__init__.py` at all, so + write one only where the surface is worth pinning. +3. **Private module.** `_foo.py` needs a mirror of its own. The guard accepts + either `test__foo.py` or the underscore-stripped `test_foo.py`, and the + stripped form is the house style - see `tests/models/config/` and + `tests/registry/`. An all-private package therefore mirrors as a directory of + stripped names: `_block_specs.py` -> `test_block_specs.py`. What the file + _exercises_ may still be the public entry point that exposes the module; what + it may never be is a stub written to silence the guard. + +- Merge several small test files covering one source module into the single + mirrored file; split one test file spanning several source modules along module + lines. +- No `__init__.py` in test directories - `--import-mode=importlib` with + `pythonpath = ["."]` makes them namespace packages. Add one only for a genuine + module package with content of its own, such as `tests/synthetic/`. +- Never create stub test files or directories holding only an `__init__.py` to + satisfy the guard. +- Files that legitimately mirror no single module: `tests/docs/`, the + cross-package policy guards at the `tests/` root, and + `tests/test_import_cost_policy.py`. `EXEMPT_MODULES` in the mirror policy is a + last resort - one entry today, carrying a comment for why the module is not + library code, and `test_exempt_modules_still_exist` fails once it outlives its + module. +- Shared test code is a `_`-prefixed module beside the tests it serves - + `tests/_barrel_surface.py`, `tests/_trusted_subprocess.py`, + `tests/registry/_helpers.py` - imported as `from tests._x import y`. The + underscore keeps it out of collection, and the mirror policy walks `drevalpy/` + only, so no mirror is demanded for it. Reach for the existing ones before + writing a fixture out again: + - `tests/_import_shims.py::block_imports(monkeypatch, *prefixes)` - fails an + optional third-party import so the guidance message it raises can be pinned. + Patches `builtins.__import__`, because the dependency is imported _inside_ + the method under test, by which point a `sys.modules` edit is too late. + - `tests/models/synthetic_fixtures.py::synthetic_mudataset` - the one builder + behind every synthetic `Dataset`: two cell lines, two drugs, and whichever + cell-line views the caller asks for via `extra_views`. Add a view to + `_VIEW_SPECS` rather than assembling AnnData in a test file. + - `tests/components/predictors/literature/_helpers.py::two_by_two_batch` - the + twelve-keyword `ModelInputBatch.from_response` every literature predictor + test needs; a caller passes only the feature blocks it consumes. + - `tests/components/featurizers/cell_line/_helpers.py::assert_uses_precomputed_variant` + - the `fetch`-hit assertion shared by every dense cell-line featurizer. + - `tests/models/config/_stubs.py` - throwaway featurizer/predictor + registrations whose only real content is their contract. Callers must be + under `isolated_component_registries`. +- A `test_init.py` pins its package surface by subclassing + `ReExportSurface` / `DeclaredSurface` / `SingletonFacadeSurface` from + `tests/_barrel_surface.py` and recording `origins` (`name -> defining module`) + in the file itself. Keep that table hand-written: deriving it from `__all__` + makes `test_all_matches_the_recorded_surface` unfalsifiable, and record each + origin against a module the barrel does _not_ import the name from. + +## Featurizers + +- `DenseViewFeaturizer`, `register_for_sides` and `FeaturizerStorageMixin` are + **public**: re-exported from `drevalpy/plugin/__init__.py` and pinned by + `tests/plugin/test_init.py`, so renaming them is a breaking change even though + two of them live in `_`-prefixed modules. +- A side-agnostic featurizer is written **once**, in + `drevalpy/components/featurizers/shared/`, and bound to both entity sides by + `register_for_sides` from `featurizers/_side_binding.py`, which derives, + registers and namespace-injects one subclass per side. Never hand-write a + second per-side copy - that duplication is exactly what `shared/` removed. +- `side` is a `ClassVar` stamped onto the class by the **registry** + (`drevalpy/registry/featurizer/_base.py`), not by the base class, and + `list_stored_variants` is a `classmethod` reading `cls.side`. That is why each + side needs its own generated subclass instead of one class registered twice. +- Registration is by **directory scan**: + `drevalpy/registry/_builtins.py::_discover_modules` imports every `*.py` in a + component directory except `base`, `__init__` and names starting with `_`. + So a shared-but-unregistered base module must be `_`-prefixed to stay out of + the scan, and `shared/` has its own `_shared_featurizer_modules()` wired into + `register_native_components()`. +- Single-view dense featurizers subclass `DenseViewFeaturizer` + (`featurizers/_dense_view.py`) and override only their distinct transform step. + `cell_line/base.py` and `drug/base.py` host the per-side + `DenseView{CellLine,Drug}Featurizer` bases. +- `Featurizer` in `base.py` keeps only the abstract hooks and the public wrappers + that guard them; the split-out concerns live in mixins beside it: + `FeaturizerStorageMixin` in `featurizers/storage.py`, `NanToleranceMixin` in + `featurizers/_nan_tolerance.py` (`_detect_valid`, + `_warn_if_above_threshold`, `_expand_blocks_with_nan`, `nan_threshold`) and + `FeaturizerDeclarationsMixin` in `featurizers/_declarations.py` (the + `__init_subclass__` contract normalization, `resolve_input_views`, + `output_block_specs_for_config`, and the `contract` / `precompute` / + `requires_view` / `entity_id_only` / `input_views` / `source_views` ClassVars). +- The public `fit` / `transform` / `transform_blocks` wrappers stay in `base.py` + beside the abstract hooks they wrap. Moving them out with the NaN policy would + split the documented subclass contract across two files and measured as a + _rise_ in `base.py`'s LCOM4. `HPOStrategy` stays too - the plugin barrel + re-exports it from `base.py`. +- `DenseViewFeaturizer._restore_dense_state` in `featurizers/_dense_view.py` is + the shared `set_state` path: it restores `view` / `output_dim` / `fitted`, the + three fields `__init__` owns, leaving each subclass only its own fitted object. + Used by `normalized_proteomics.py`, `scaled_gene_expression.py`, `pca.py` and + `landmark.py`. `pharmaformer_gene_expression.py` is deliberately excluded: its + `self._is_fitted = bool(state.get("fitted"))` resets to `False` on an absent + key, where the shared path leaves the flag alone, so folding it in would change + behaviour. + +## DRPModel mixins + +`models/drp_model.py` holds config and identity only - `model_config`, +`_from_resolved_config`, `_apply_model_config`, the five properties, +`log_hyperparameters`. Behaviour hangs off mixins in `models/mixins/`, so add new +behaviour to the mixin that owns that concern rather than back into the base: + +- `_training.py` - `DRPTrainingMixin`, owning `train` / `predict`. It drives + `_stack` and `_empty_training`, which `drp_model.py` only declares in + `_init_runtime_fields`; the one other writer is `_persistence.py`, resetting + `_empty_training` on load. +- `_train_args.py` - `resolve_train_args`, returning a frozen `TrainCallArgs`. It + touches no instance state, which is why it was its own LCOM4 island and is a + function rather than a mixin method. `TrainCallArgs.is_dataset_form` / + `.is_feature_source_form` name the input form once; they replaced a bare + 6-tuple that forced `train` to re-derive it with `isinstance` checks. +- `_feature_matrix.py` - `DRPFeatureMatrixMixin`, owning + `get_concatenated_features` / `get_feature_matrices` - the hand-rolled-model + path that the component stack replaced. + +## Hyperparameter keys + +The key grammar - slot constants, prefix builders, key parsers - has one home in +`drevalpy/models/_hp_key_grammar.py`, which must stay a dependency-free leaf: it +imports nothing from `drevalpy` at module scope, and that is what keeps +`models/config` and `models/tuning` decoupled. + +`TunableComponentMixin` in `components/contracts/hyperparameter_space.py` carries +`get_hyperparameter_space` / `get_default_hyperparameters` / `get_state` / +`set_state` for **both** component kinds; `Featurizer` and `Predictor` each mix it +in. It lives beside `validate_hyperparameter_space`, which it calls and which both +component packages already imported - any home inside `featurizers/` or +`predictors/` would have inverted a dependency between the two siblings. Override +`get_hyperparameter_space` to declare what is tunable and `get_state` / `set_state` +together when there is fitted state to round-trip; `get_default_hyperparameters` is +not an override point. `Predictor.is_fitted` stays on `Predictor` - it is +predictor-only. + +## Import cost + +`tests/test_import_cost_policy.py` asserts that none of +`FORBIDDEN_STARTUP_IMPORTS` (torch, sklearn, pandas, matplotlib, ...) reach +`sys.modules` on `import drevalpy`. When it fails, move the module-scope import +into the method that needs it, using `if TYPE_CHECKING:` for annotation-only +uses. Never delete an entry from the list to make the failure go away. + +Two cases a function-local import cannot fix: + +- A **base class** from a forbidden library has to exist when the `class` + statement executes. Drop the base where it earns nothing, or move the class + into its own private module and re-export it lazily, as + `components/featurizers/cell_line/_proteomics_transformer.py` does. +- A module-scope **side effect** that must run before the library is imported + anywhere stays eager - `xgboost_pred.py` calls + `_set_xgboost_thread_defaults()` at module scope to keep OpenMP from crashing. + +The same file guards `DEFERRED_TRAINING_SYMBOLS` and `LAZY_RE_EXPORTS`: a moved +symbol must keep resolving through the module-level `__getattr__`, and a name +that does not exist must still raise `AttributeError`. + +## Coverage gate + +Three floors, all in the `tests` job of `.github/workflows/run_tests.yml`: +`[tool.coverage.report].fail_under` for the aggregate, `tools/coverage_gate.py` per +module against `[tool.drevalpy.coverage_gate].min_file_coverage`, and +`tools/size_gate.py` per module against +`[tool.drevalpy.size_gate].max_module_statements`. Keep +`[tool.coverage.run].source = ["drevalpy"]` - it holds never-imported modules in +the denominator at 0%. + +An entry in either `exemptions` table is debt, not a policy decision, and carries a +comment saying why the module cannot reach the floor. + +- Work from the "exemptions that can be lowered or deleted" list each gate prints. +- Delete an entry once the module reaches `min_file_coverage`; if it improves but + not that far, lower the recorded floor to the newly measured value. +- Never raise a floor to make a regression pass, and never lower `fail_under` to + make a change fit. Raise `min_file_coverage` when the table empties. +- The same rules run the size gate in the other direction: lower a recorded + ceiling as a module shrinks, never raise it to admit a regression. + +## Repowise false positives + +Checked directly, all artifacts of how the code is wired rather than debt: + +- Both "Break Cycle" plans from `repowise health --refactoring-targets`. In + `models` the only back edge is the `TYPE_CHECKING`-guarded import at + `drevalpy/models/mixins/_persistence_io.py:21-22`, which is already the + correct pattern; in `visualization/plots` none of `heatmap.py`, `violin.py`, + `cross_study_table.py` imports `plots/__init__.py` - it is barrel attribution. +- `repowise dead-code`'s `unused_export` hits for `LassoPredictor`, + `SVRPredictor`, `GradientBoostingPredictor` and `KNNPredictor` (reported at + 100% confidence) and the `unreachable_file` hit for + `components/featurizers/cell_line/gene_lists/_make_gene_lists.py`. No static + importer exists because `registry/_builtins.py::_discover_modules` registers by + directory scan. `docs/conf.py` is a Sphinx entry point, not dead either. +- `coverage_gradient` on `sparsego/*`, `dipk/predictor.py`, `data/datasets/*` and + `models/mixins/_hyperparameters.py` - the same debt already recorded with + reasons in `[tool.drevalpy.coverage_gate.exemptions]`, counted a second time. + Work it through the gate's exemption list, not the health score. + +`hotspot_health` is a local review aid, not a number to gate on. One tree measures +three ways: **10.0** on the `fetch-depth: 1` checkout `actions/checkout` does by +default (every commit-history marker silently vanishes), **6.13** on a full clone +with no coverage ingested, **6.25** in a working tree with coverage ingested. 68% of +total finding impact comes from markers derived from commit history +(`co_change_scatter`, `hidden_coupling`, `churn_risk`, `prior_defect`, +`change_entropy`, `function_hotspot`, `code_age_volatility`, `knowledge_loss`), so +the score also drifts as unrelated commits land and cannot be moved by editing the +working tree. `tools/size_gate.py` is the CI ratchet instead. Use +`repowise health --trend` to review, and read a drop as a prompt to look rather +than as a failure. + +## Path handling + +Use `UPath` from `universal_pathlib` instead of `pathlib.Path` throughout, so +remote filesystems (S3, GCS) work transparently: + +```python +from upath import UPath +``` + +Typer does not support `UPath` in CLI parameter annotations - take `str` and +convert inside the function body. diff --git a/Dockerfile b/Dockerfile index 0c0c6bd21..6d4ce8dc5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,48 +1,45 @@ -# I followed this article's recommendations -# https://medium.com/@albertazzir/blazing-fast-python-docker-builds-with-poetry-a78a66f5aed0 - -# The builder image, used to build the virtual environment FROM python:3.13-bookworm AS builder +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ -RUN pip install poetry==2.4.1 - -# POETRY_CACHE_DIR: When removing the cache folder, make sure this is done in the same RUN command. If it’s done in a -# separate RUN command, the cache will still be part of the previous Docker layer (the one containing poetry install ) -# effectively rendering your optimization useless. - -ENV POETRY_NO_INTERACTION=1 \ - POETRY_CACHE_DIR=/tmp/poetry_cache +ARG TORCH_BACKEND=cpu -WORKDIR /root - -COPY pyproject.toml poetry.lock ./ - -# First, we install only the dependencies. This way, we can cache this layer and avoid re-installing dependencies -# every time we change our application code. -# Because poetry will complain if a README.md is not found, we create a dummy one. +WORKDIR /opt/build +ENV UV_PROJECT_ENVIRONMENT=/opt/venv +COPY pyproject.toml uv.lock ./ RUN touch README.md +RUN uv sync --frozen --no-dev --no-install-project --extra ${TORCH_BACKEND} -RUN poetry install --without development --no-root && rm -rf $POETRY_CACHE_DIR +COPY README.md ./ +COPY drevalpy ./drevalpy +RUN uv sync --frozen --no-dev --no-editable --extra ${TORCH_BACKEND} -# The runtime image, used to run the code FROM python:3.13-slim-bookworm AS runtime +# procps: Nextflow needs `ps`. +# libgomp1: LightGBM's wheel links the system libgomp.so.1 by bare soname, unlike +# the xgboost and scikit-learn wheels, which vendor an auditwheel-renamed copy with +# an RPATH. Until torch's imports were deferred out of module scope, this was +# satisfied by accident: `import drevalpy` imported torch, which ships an unrenamed +# libgomp.so.1 and loads it RTLD_GLOBAL, so LightGBM's dlopen found it already in +# the link map. A lightgbm-only run never imports torch and fails without this. +RUN apt-get update \ + && apt-get install -y --no-install-recommends procps unzip libgomp1 \ + && rm -rf /var/lib/apt/lists/* + LABEL image.author.name="Judith Bernett" LABEL image.author.email="judith.bernett@tum.de" -# Copy installed dependencies from the builder image -COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages -COPY --from=builder /usr/local/bin /usr/local/bin - -# Copy all relevant code - -COPY drevalpy ./drevalpy -COPY README.md ./ -COPY pyproject.toml ./ -COPY poetry.lock ./ +COPY --link --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" -# Install drevalpy -RUN pip install . +# Import each native stack in a *separate* process. A single combined import would +# pass on an image that is missing libgomp1, because importing torch first loads an +# unrenamed libgomp.so.1 RTLD_GLOBAL and resolves LightGBM's dependency for it - +# which is exactly how the missing system library stayed hidden until a +# lightgbm-only pipeline run, where torch is never imported. +RUN for module in lightgbm xgboost sklearn torch rdkit; do \ + python -c "import ${module}" || exit 1; \ + done \ + && python -c "import drevalpy" -# Nextflow needs the command ps to be available -RUN apt-get update && apt-get install -y procps unzip && rm -rf /var/lib/apt/lists/* +CMD ["/bin/bash"] diff --git a/README.md b/README.md index 4248bf39e..165344466 100644 --- a/README.md +++ b/README.md @@ -50,16 +50,6 @@ Use DrEval to build drug response models that have an impact This project is a collaboration of the Technical University of Munich (TUM, Germany) and the Freie Universität Berlin (FU, Germany). -## Demo - -Check out our demo notebook in Colab: -[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/daisybio/drevalpy/blob/development/dreval_colab_demo.ipynb) - -Expected runtime on a normal machine: - -- Standalone demo: 38 minutes -- Nextflow demo: 5 minutes - ## Installation Using pip: @@ -68,11 +58,11 @@ Using pip: pip install drevalpy ``` -Optional Ray Tune support (for parallel hyperparameter tuning): - -```bash -pip install drevalpy[multiprocessing] -``` +Ray Tune (`ray[tune]`) and Optuna are included in the default install. HPO uses +Ray to run trials and Optuna only as the search sampler — there is no +Optuna-only fallback. On Windows, HPO works with Python 3.10–3.12; with +Python 3.13+, Ray has no wheel so hyperparameter tuning is unavailable (use +Python 3.12, WSL, or Docker for HPO, or disable tuning for defaults-only runs). On a regular machine, the installation should take about a minute. @@ -87,175 +77,194 @@ From source: ```bash git clone https://github.com/daisybio/drevalpy.git cd drevalpy -pip install poetry -pip install poetry-plugin-export -poetry install +uv sync ``` -Check your installation by running in your console: +Check your installation: ```bash drevalpy --help ``` +Full install options (Conda, Docker, Windows HPO): [Installation](https://drevalpy.readthedocs.io/en/latest/getting_started/installation.html). + ## Quickstart -To run models from the catalog, you can run: +DrEvalPy exposes the same evaluation workflow through the **CLI** and the **Python API**. Pick one track below; both write results into an output directory (default `results/`) that holds one subdirectory per model and one `.npz` file per cross-validation fold. -```bash -drevalpy --run_id my_first_run --models NaiveTissueMeanPredictor NaiveDrugMeanPredictor --dataset_name TOYv1 --test_mode LCO -``` +### CLI (smallest runnable example) -This will download a small toy drug response dataset, train our baseline models which just predict the drug or tissue means or the mean drug and cell line effects. -It will evaluate in "LCO" which is the leave-cell-line-out splitting strategy using 7 fold cross validation. -The results will be stored in +After installation, run naive baselines on the GDSC1 screen with leave-cell-line-out (LCO) splits: ```bash -results/my_first_run/TOYv1/LCO +drevalpy run NaiveTissueMeanPredictor NaiveDrugMeanPredictor NaiveMeanEffectsPredictor \ + --dataset GDSC1 \ + --split-mode LCO \ + --no-hpo ``` -You can visualize them using +Models are positional arguments. This downloads GDSC1 into the system cache directory (override with the `DREVALPY_CACHE_DIR` environment variable), trains the listed models, and evaluates with the default five-fold CV. Outputs go to `results/`; use `--output-dir` to change that. Hyperparameter tuning is on by default — `--no-hpo` above keeps this first run fast. + +Build the HTML report: ```bash -drevalpy-report --run_id my_first_run --dataset_name TOYv1 +drevalpy report results/ --output-dir report ``` -This will create an index.html file in the results directory which you can open in your web browser. +Open `report/multiqc_report.html` in your browser. + +More CLI options: [CLI quickstart](https://drevalpy.readthedocs.io/en/latest/cli/quickstart.html). + +### Python API -You can also run a drug response experiment using Python: +Load the dataset, resolve zoo presets with `construct_model` (returns a **class**), and pass those classes to `run`: ```python -from drevalpy.experiment import drug_response_experiment -from drevalpy.models import MODEL_FACTORY -from drevalpy.datasets import AVAILABLE_DATASETS - -from drevalpy.experiment import drug_response_experiment - -naive_mean = MODEL_FACTORY["NaivePredictor"] # a naive model that just predicts the training mean -enet = MODEL_FACTORY["ElasticNet"] # An Elastic Net based on drug fingerprints and gene expression of 1000 landmark genes -simple_nn = MODEL_FACTORY["SimpleNeuralNetwork"] # A neural network based on drug fingerprints and gene expression of 1000 landmark genes - -toyv1 = AVAILABLE_DATASETS["TOYv1"](path_data="data") - -drug_response_experiment( - models=[enet, simple_nn], - baselines=[naive_mean], # Ablation studies and robustness tests are not run for baselines. - response_data=toyv1, - n_cv_splits=2, # the number of cross validation splits. Should be higher in practice :) - test_mode="LCO", # LCO means Leave-Cell-Line out. This means that the test and validation splits only contain unseed cell lines. - run_id="my_first_run", - path_data="data", # where the downloaded drug response and feature data is stored - path_out="results", # results are stored here :) - hyperparameter_tuning=False) # if True (default), hyperparameters of the models and baselines are tuned. +from drevalpy.data import load +from drevalpy.models import construct_model +from drevalpy import run + +dataset = load("GDSC1") + +ElasticNet = construct_model("ElasticNet") + +result = run( + models=[ElasticNet], + dataset=dataset, + split_mode="LCO", + hyperparameter_tuning=False, +) ``` -This will run the Random Forest and Simple Neural Network models on the CTRPv2 dataset, using the Naive Mean Effects Predictor as a baseline. The results will be stored in `results/my_second_run/CTRPv2/LCO`. -To obtain evaluation metrics, you can use: +With `hyperparameter_tuning=True` (the default), Ray Tune and Optuna search each model’s structured hyperparameter space. Set `hyperparameter_tuning=False` for a fast defaults-only run. + +`run` returns an `ExperimentResult` holding the predictions and metrics of every fold. Save it and render the same style of HTML report from Python: ```python -from drevalpy.visualization.create_report import create_report +from drevalpy.visualization.report import create_report -create_report( - run_id="my_first_run", - dataset=toyv1.dataset_name, - path_data= "data", - result_path="results", -) +result.save("results/") + +create_report(result, "report/") ``` -We recommend the use of our Nextflow pipeline for computational demanding runs and for improved reproducibility. -No knowledge of Nextflow is required to run it. The nextflow pipeline is available here: [nf-core-drugresponseeval](https://github.com/JudithBernett/nf-core-drugresponseeval). +Concepts (datasets, splits, metrics): [documentation index](https://drevalpy.readthedocs.io/en/latest/index.html). Python walkthrough: [Python quickstart](https://drevalpy.readthedocs.io/en/latest/python/quickstart.html). + +### Large or highly reproducible runs + +For demanding workloads, prefer the Nextflow pipeline [nf-core/drugresponseeval](https://nf-co.re/drugresponseeval/dev/) ([GitHub](https://github.com/nf-core/drugresponseeval)). No Nextflow experience is required for the standard profile. ## Example Report [Browse our benchmark results here.](https://dilis-lab.github.io/drevalpy-report/) -You can reproduce the whole analysis by running the following commands: + +The published benchmark was produced with the Nextflow pipeline +[nf-core/drugresponseeval](https://nf-co.re/drugresponseeval/dev/), which has its own +[parameter schema](https://nf-co.re/drugresponseeval/dev/parameters/) and pins the `drevalpy` +version it runs. The keys below are _pipeline_ parameters, not flags of the `drevalpy` CLI shown +above. Write each parameter set to a YAML file and hand it to Nextflow with `-params-file`: ```bash -# Main run -nextflow run nf-core/drugresponseeval \ - -profile docker \ - --run_id main_results \ - --dataset_name CTRPv2 \ - --cross_study_datasets CTRPv1,CCLE,GDSC1,GDSC2 \ - --models DIPK,MultiViewRandomForest \ - --baselines SimpleNeuralNetwork,RandomForest,MultiViewNeuralNetwork,NaiveMeanEffectsPredictor,GradientBoosting,SRMF,ElasticNet,NaiveTissueMeanPredictor,NaivePredictor,SuperFELTR,NaiveCellLineMeanPredictor,NaiveDrugMeanPredictor \ - --test_mode LPO,LCO,LTO,LDO \ - --randomization_mode SVRC,SVRD \ - --randomization_type permutation \ - --measure LN_IC50 - -# EC50 run -nextflow run nf-core/drugresponseeval \ - -profile docker \ - --run_id ec50_run \ - --dataset_name CTRPv2 \ - --cross_study_datasets CTRPv1,CCLE,GDSC1,GDSC2,PDX_Bruna,BeatAML2 \ - --models RandomForest \ - --baselines NaiveMeanEffectsPredictor \ - --test_mode LCO \ - --measure pEC50 - -# AUC run -nextflow run nf-core/drugresponseeval \ - -profile docker \ - --run_id auc_run \ - --dataset_name CTRPv2 \ - --cross_study_datasets CTRPv1,CCLE,GDSC1,GDSC2,PDX_Bruna,BeatAML2 \ - --models RandomForest \ - --baselines NaiveMeanEffectsPredictor \ - --test_mode LCO \ - --measure AUC - -# Invariant ablation run -# Run this on CPU -nextflow run nf-core/drugresponseeval \ - -profile docker \ - --run_id invariant-rf \ - --dataset_name CTRPv2 \ - --models MultiViewRandomForest \ - --baselines NaiveMeanEffectsPredictor \ - --test_mode LPO,LCO,LDO \ - --randomization_mode SVRC,SVRD \ - --randomization_type invariant \ - --measure LN_IC50 - -# modify the profile to run this on GPU, if possible -nextflow run nf-core/drugresponseeval \ - -profile docker \ - --run_id invariant-dipk \ - --dataset_name CTRPv2 \ - --models DIPK \ - --baselines NaiveMeanEffectsPredictor \ - --test_mode LPO,LCO,LDO \ - --randomization_mode SVRC,SVRD \ - --randomization_type invariant \ - --measure LN_IC50 - -## Inference on BeatAMl2, PDX_Bruna -# run this on CPU -nextflow run nf-core/drugresponseeval \ - -profile docker \ - --run_id infer_pdx_beat \ - --dataset_name CTRPv2 \ - --cross_study_datasets PDX_Bruna,BeatAML2 \ - --models RandomForest,SimpleNeuralNetwork,GradientBoosting,SRMF,ElasticNet,NaivePredictor,NaiveDrugMeanPredictor,NaiveCellLineMeanPredictor \ - --baselines NaiveMeanEffectsPredictor \ - --test_mode LPO,LCO,LDO \ - --measure LN_IC50 - -# modify profile to run this on GPU, if possible -nextflow run nf-core/drugresponseeval \ - -profile docker \ - --run_id dipk_pdx_beat \ - --dataset_name CTRPv2 \ - --cross_study_datasets PDX_Bruna,BeatAML2 \ - --models DIPK \ - --baselines NaiveMeanEffectsPredictor \ - --test_mode LPO,LCO,LDO \ - --measure LN_IC50 +for params in params/*.yaml; do + nextflow run nf-core/drugresponseeval -profile docker -params-file "$params" +done +``` + +Main run: + +```yaml +# params/main_results.yaml +run_id: main_results +dataset_name: CTRPv2 +cross_study_datasets: CTRPv1,GDSC1,GDSC2 +models: DIPK,MultiViewRandomForest +baselines: SimpleNeuralNetwork,RandomForest,MultiViewNeuralNetwork,NaiveMeanEffectsPredictor,GradientBoosting,SRMF,ElasticNet,NaiveTissueMeanPredictor,NaivePredictor,SuperFELTR,NaiveCellLineMeanPredictor,NaiveDrugMeanPredictor +test_mode: LPO,LCO,LTO,LDO +randomization_mode: SVRC,SVRD +randomization_type: permutation +measure: LN_IC50 ``` +EC50 and AUC runs: + +```yaml +# params/ec50_run.yaml +run_id: ec50_run +dataset_name: CTRPv2 +cross_study_datasets: CTRPv1,GDSC1,GDSC2,PDX_Bruna,BeatAML2 +models: RandomForest +baselines: NaiveMeanEffectsPredictor +test_mode: LCO +measure: pEC50 +``` + +```yaml +# params/auc_run.yaml +run_id: auc_run +dataset_name: CTRPv2 +cross_study_datasets: CTRPv1,GDSC1,GDSC2,PDX_Bruna,BeatAML2 +models: RandomForest +baselines: NaiveMeanEffectsPredictor +test_mode: LCO +measure: AUC +``` + +Invariant ablation runs — run the first on CPU, and adjust the profile to use a GPU for the +second one if you can: + +```yaml +# params/invariant-rf.yaml +run_id: invariant-rf +dataset_name: CTRPv2 +models: MultiViewRandomForest +baselines: NaiveMeanEffectsPredictor +test_mode: LPO,LCO,LDO +randomization_mode: SVRC,SVRD +randomization_type: invariant +measure: LN_IC50 +``` + +```yaml +# params/invariant-dipk.yaml +run_id: invariant-dipk +dataset_name: CTRPv2 +models: DIPK +baselines: NaiveMeanEffectsPredictor +test_mode: LPO,LCO,LDO +randomization_mode: SVRC,SVRD +randomization_type: invariant +measure: LN_IC50 +``` + +Inference on BeatAML2 and PDX_Bruna — again CPU for the first, GPU for the second where +available: + +```yaml +# params/infer_pdx_beat.yaml +run_id: infer_pdx_beat +dataset_name: CTRPv2 +cross_study_datasets: PDX_Bruna,BeatAML2 +models: RandomForest,SimpleNeuralNetwork,GradientBoosting,SRMF,ElasticNet,NaivePredictor,NaiveDrugMeanPredictor,NaiveCellLineMeanPredictor +baselines: NaiveMeanEffectsPredictor +test_mode: LPO,LCO,LDO +measure: LN_IC50 +``` + +```yaml +# params/dipk_pdx_beat.yaml +run_id: dipk_pdx_beat +dataset_name: CTRPv2 +cross_study_datasets: PDX_Bruna,BeatAML2 +models: DIPK +baselines: NaiveMeanEffectsPredictor +test_mode: LPO,LCO,LDO +measure: LN_IC50 +``` + +## Development + +Pre-commit runs [complexipy](https://github.com/rohaquinlop/complexipy) on the `drevalpy/` package with a maximum cognitive complexity of **15** (`[tool.complexipy]` in `pyproject.toml`). Refactors should stay at or below that limit; do not add `# complexipy: ignore` comments or exclude product paths from the hook. + ## Contact Main developers: diff --git a/README.rst b/README.rst deleted file mode 100644 index 1eb4d08c0..000000000 --- a/README.rst +++ /dev/null @@ -1,81 +0,0 @@ -DrEvalPy: Python Cancer Cell Line Drug Response Prediction Suite -================================================================ - -|PyPI| |Python Version| |License| |Read the Docs| |Build| |Tests| |Codecov| |pre-commit| |Black| |Zenodo| - -.. |PyPI| image:: https://img.shields.io/pypi/v/drevalpy.svg - :target: https://pypi.org/project/drevalpy/ - :alt: PyPI -.. |Python Version| image:: https://img.shields.io/pypi/pyversions/drevalpy - :target: https://pypi.org/project/drevalpy - :alt: Python Version -.. |License| image:: https://img.shields.io/github/license/daisybio/drevalpy - :target: https://opensource.org/licenses/GPL3 - :alt: License -.. |Read the Docs| image:: https://img.shields.io/readthedocs/drevalpy/latest.svg?label=Read%20the%20Docs - :target: https://drevalpy.readthedocs.io/ - :alt: Read the documentation at https://drevalpy.readthedocs.io/ -.. |Build| image:: https://github.com/daisybio/drevalpy/actions/workflows/build_package.yml/badge.svg - :target: https://github.com/daisybio/drevalpy/actions?workflow=Package - :alt: Build Package Status -.. |Tests| image:: https://github.com/daisybio/drevalpy/actions/workflows/run_tests.yml/badge.svg - :target: https://github.com/daisybio/drevalpy/actions?workflow=Tests - :alt: Run Tests Status -.. |Codecov| image:: https://codecov.io/gh/daisybio/drevalpy/branch/main/graph/badge.svg - :target: https://codecov.io/gh/daisybio/drevalpy - :alt: Codecov -.. |pre-commit| image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white - :target: https://github.com/pre-commit/pre-commit - :alt: pre-commit -.. |Black| image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black - :alt: Black -.. |Zenodo| image:: https://zenodo.org/badge/727156477.svg - :target: https://doi.org/10.5281/zenodo.18302237 - :alt: DOI - -.. image:: _static/img/overview.png - :align: center - :width: 80% - :alt: Overview of the DrEval framework. Via input options, implemented state-of-the-art models can be compared against baselines of varying complexity. We address obstacles to progress in the field at each point in our pipeline: Our framework is available on PyPI and nf-core and we follow FAIReR standards for optimal reproducibility. DrEval is easily extendable as demonstrated here with an implementation of a proteomics-based random forest. Custom viability data can be preprocessed with CurveCurator, leading to more consistent data and metrics. DrEval supports five widely used datasets with application-aware train/test splits that enable detecting weak generalization. Models are free to use provided cell line- and drug features or custom ones. The pipeline supports randomization-based ablation studies and performs robust hyperparameter tuning for all models. Evaluation is conducted using meaningful, bias-resistant metrics to avoid inflated results from artifacts such as Simpson’s paradox. All results are compiled into an interactive HTML report. - - -Overview -========= - -Check out our paper on `Nature Communications `_! - -**Focus on Innovating Your Models — DrEval Handles the Rest!** - -- DrEval is a toolkit that ensures drug response prediction evaluations are statistically sound, biologically meaningful, and reproducible. -- Focus on model innovation while using our automated standardized evaluation protocols and preprocessing workflows. -- A flexible model interface supports all model types (e.g. machine learning, statistical models, network-based analyses). - -Use DrEval to build drug response models that have an impact - - 1. Maintained, up-to-date baseline catalog, no need to re-implement literature models - - 2. Gold standard datasets for benchmarking - - 3. Consistent application-driven evaluation - - 4. Ablation studies with permutation tests - - 5. Cross-study evaluation for generalization analysis - - 6. Optimized nextflow pipeline for fast experiments - - 7. Easy-to-use hyperparameter tuning - - 8. Paper-ready visualizations to display performance - -This project is a collaboration of the Technical University of Munich (TUM, Germany) -and the Freie Universität Berlin (FU, Germany). - -Leaderboard ------------ - -.. image:: _static/img/leaderboard_light.png - :alt: DrEvalPy Leaderboard - :align: center - :width: 70% diff --git a/docs/API.rst b/docs/API.rst deleted file mode 100644 index b05a5d541..000000000 --- a/docs/API.rst +++ /dev/null @@ -1,59 +0,0 @@ -API -=== - -Import DrEvalPy using - -.. code-block:: python - - import drevalpy as dep - -Subpackages ------------ - -DrEvalPy consists of three major subpackages: - -* Datasets -* Models -* Visualization - -.. toctree:: - :maxdepth: 3 - - drevalpy.datasets - drevalpy.models - drevalpy.visualization - -Other functions ---------------- - -Major functions for running the experiment -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. automodule:: drevalpy.experiment - :members: - :undoc-members: - :show-inheritance: - -Evaluation functions -~~~~~~~~~~~~~~~~~~~~ - -.. automodule:: drevalpy.evaluation - :members: - :undoc-members: - :show-inheritance: - -Utility functions -~~~~~~~~~~~~~~~~~ - -.. automodule:: drevalpy.utils - :members: - :undoc-members: - :show-inheritance: - -Pipeline function decorator -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. automodule:: drevalpy.pipeline_function - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/_cli_click.py b/docs/_cli_click.py new file mode 100644 index 000000000..766902f5b --- /dev/null +++ b/docs/_cli_click.py @@ -0,0 +1,140 @@ +"""Docs-only helpers for generating the CLI reference from the Typer app. + +Typer vendors its own Click classes, so ``sphinx-click`` cannot introspect the +app via ``isinstance(..., click.Command)``. Instead we render a nested RST +reference at Sphinx build time. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import click +from _generated_io import write_text_if_changed +from typer.main import get_command + +from drevalpy.cli.main import app + +DOCS_DIR = Path(__file__).resolve().parent +GENERATED_REFERENCE = DOCS_DIR / "cli" / "_generated_reference.rst" + + +def _format_default(value: object) -> str | None: + if value is None: + return None + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (list, tuple)): + if not value: + return None + return " ".join(str(item) for item in value) + text = str(value) + if text in {"", "None"}: + return None + return text + + +def _param_body(param) -> str: + """Phrase one parameter's definition-list body. + + :param param: Click parameter to describe. + :returns: The help text, the rendered default, or a placeholder. + """ + parts: list[str] = [] + help_text = (getattr(param, "help", None) or "").strip() + if help_text: + parts.append(help_text) + default = _format_default(getattr(param, "default", None)) + if default is not None and not getattr(param, "required", False): + parts.append(f"Default: ``{default}``.") + return " ".join(parts) if parts else "No description." + + +def _documented_params(command) -> list: + """Return the parameters worth documenting for *command*. + + Drops positional arguments, which carry no ``opts``, and the two + shell-completion options Typer injects into every app. + + :param command: Click command to inspect. + :returns: Click parameters in declaration order. + """ + skip = {"install_completion", "show_completion"} + return [ + param for param in command.params if getattr(param, "opts", None) and getattr(param, "name", None) not in skip + ] + + +def _render_params(command) -> list[str]: + """Render options as a definition list (no section titles). + + :param command: Click command whose parameters should be rendered + :returns: RST lines for the parameter definition list + """ + lines: list[str] = [] + for param in _documented_params(command): + lines.append(" / ".join(f"``{opt}``" for opt in param.opts)) + lines.append(f" {_param_body(param)}") + lines.append("") + return lines + + +def generate_cli_reference_rst() -> str: + """Return RST for the full ``drevalpy`` CLI, including subcommands. + + :returns: generated CLI reference as an RST document string + """ + root = cast(click.Group, get_command(app)) + lines = [ + "Root command", + "------------", + "", + "Run the full experiment suite:", + "", + ".. code-block:: bash", + "", + " drevalpy [OPTIONS]", + "", + ] + help_text = (getattr(root, "help", None) or "").strip() + if help_text: + lines.extend([help_text, ""]) + lines.extend(_render_params(root)) + + lines.extend( + [ + "Subcommands", + "-----------", + "", + ] + ) + for name in sorted(root.commands): + command = root.commands[name] + heading = f"drevalpy {name}" + lines.extend([heading, "~" * len(heading), ""]) + cmd_help = (getattr(command, "help", None) or "").strip() + if cmd_help: + lines.extend([cmd_help, ""]) + lines.extend( + [ + ".. code-block:: bash", + "", + f" drevalpy {name} [OPTIONS]", + "", + ] + ) + lines.extend(_render_params(command)) + return "\n".join(lines).rstrip() + "\n" + + +def write_generated_cli_reference() -> Path: + """Write the generated CLI reference RST consumed by ``cli/reference.rst``. + + :returns: path to the generated RST file + """ + write_text_if_changed(GENERATED_REFERENCE, generate_cli_reference_rst()) + return GENERATED_REFERENCE + + +click_app = get_command(app) diff --git a/docs/_component_catalog.py b/docs/_component_catalog.py new file mode 100644 index 000000000..5086dd638 --- /dev/null +++ b/docs/_component_catalog.py @@ -0,0 +1,220 @@ +"""Generate built-in component tables from registry metadata.""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Collection +from pathlib import Path +from typing import TypedDict, TypeVar, cast + +from _generated_io import write_text_if_changed + +from drevalpy.registry._builtins import ( + BUILTIN_CELL_LINE_FEATURIZER_NAMES, + BUILTIN_DRUG_FEATURIZER_NAMES, + BUILTIN_PREDICTOR_NAMES, + get_skipped_builtin_modules, + register_builtin_components, +) +from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry +from drevalpy.registry.drug_featurizer import drug_featurizer_registry +from drevalpy.registry.predictor import predictor_registry + +DOCS_DIR = Path(__file__).resolve().parent +GENERATED_CATALOGS = { + "cell_line": DOCS_DIR / "concepts" / "_generated_cell_line_featurizers.rst", + "drug": DOCS_DIR / "concepts" / "_generated_drug_featurizers.rst", + "predictor": DOCS_DIR / "concepts" / "_generated_predictors.rst", +} +EXPECTED_BUILTIN_COMPONENT_COUNTS = {"cell_line": 17, "drug": 10, "predictor": 27} +EXPECTED_PREDICTOR_INTERFACE_COUNTS = {"feature_free": 1, "matrix": 13, "block": 13} + + +class ComponentCatalogMetadata(TypedDict): + """Registry fields shared by every generated component row.""" + + name: str + description: str + + +class FeaturizerCatalogMetadata(ComponentCatalogMetadata): + """Registry fields rendered for a featurizer.""" + + output_format: str + precompute: bool + + +class PredictorCatalogMetadata(ComponentCatalogMetadata): + """Registry fields rendered for a predictor.""" + + input_interface: str + + +MetadataT = TypeVar("MetadataT", bound=ComponentCatalogMetadata) + + +def _builtin_rows( + rows: list[MetadataT], + builtin_names: Collection[str], + component_label: str, +) -> list[MetadataT]: + """Select and sort built-in rows, failing if discovery is incomplete. + + :param rows: discovered registry metadata rows + :param builtin_names: names expected from built-in module registration + :param component_label: component kind used in validation errors + :returns: built-in metadata rows sorted by name + :raises RuntimeError: if a built-in component has no discovered metadata + """ + rows_by_name = {row["name"]: row for row in rows if row["name"] in builtin_names} + missing = sorted(set(builtin_names) - rows_by_name.keys()) + if missing: + raise RuntimeError(f"Built-in {component_label} missing registry metadata: {missing}") + return [rows_by_name[name] for name in sorted(rows_by_name)] + + +def _description(row: ComponentCatalogMetadata) -> str: + description = " ".join(row["description"].split()) + if not description: + raise RuntimeError(f"Component {row['name']!r} is missing a description") + return description + + +def _render_featurizers(rows: list[FeaturizerCatalogMetadata]) -> str: + lines = [ + ".. list-table::", + " :header-rows: 1", + " :widths: 22 16 12 50", + "", + " * - Name", + " - Output format", + " - Precomputable", + " - Description", + ] + for row in rows: + if not row["output_format"]: + raise RuntimeError(f"Featurizer {row['name']!r} is missing an output format") + precompute_marker = "\u2705" if row["precompute"] else "\u274c" + lines.extend( + [ + f" * - ``{row['name']}``", + f" - ``{row['output_format']}``", + f" - {precompute_marker}", + f" - {_description(row)}", + ] + ) + return "\n".join([*lines, ""]) + + +def _render_predictors(rows: list[PredictorCatalogMetadata]) -> str: + lines = [ + ".. list-table::", + " :header-rows: 1", + " :widths: 24 20 56", + "", + " * - Name", + " - Interface", + " - Description", + ] + for row in rows: + interface = row["input_interface"] + if not interface: + raise RuntimeError(f"Predictor {row['name']!r} is missing an input interface") + lines.extend( + [ + f" * - ``{row['name']}``", + f" - {interface.replace('_', '-').capitalize()}", + f" - {_description(row)}", + ] + ) + return "\n".join([*lines, ""]) + + +def _skipped_module_report() -> str: + """Return a human-readable report of built-in modules that failed to import. + + :returns: report text, or the empty string when every module imported cleanly + """ + skipped = get_skipped_builtin_modules() + if not skipped: + return "" + sections = [f"{name}:\n{tb.rstrip()}" for name, tb in sorted(skipped.items())] + header = "The following built-in component modules failed to import, so their components are missing:" + return "\n\n" + header + "\n\n" + "\n\n".join(sections) + + +def _validate_builtin_catalog( + *, + cell_line_rows: list[FeaturizerCatalogMetadata], + drug_rows: list[FeaturizerCatalogMetadata], + predictor_rows: list[PredictorCatalogMetadata], +) -> None: + """Fail generation when built-in catalogs diverge from supported interfaces. + + :param cell_line_rows: registered cell-line featurizer metadata rows + :param drug_rows: registered drug featurizer metadata rows + :param predictor_rows: registered predictor metadata rows + :raises RuntimeError: if component or interface counts diverge from expectations + """ + observed_counts = { + "cell_line": len(cell_line_rows), + "drug": len(drug_rows), + "predictor": len(predictor_rows), + } + if observed_counts != EXPECTED_BUILTIN_COMPONENT_COUNTS: + raise RuntimeError( + "Built-in component catalog counts do not match the supported set: " + f"expected {EXPECTED_BUILTIN_COMPONENT_COUNTS}, got {observed_counts}" + f"{_skipped_module_report()}" + ) + + interface_counts = dict(Counter(row["input_interface"] for row in predictor_rows)) + if interface_counts != EXPECTED_PREDICTOR_INTERFACE_COUNTS: + raise RuntimeError( + "Predictor interface counts do not match the supported set: " + f"expected {EXPECTED_PREDICTOR_INTERFACE_COUNTS}, got {interface_counts}" + ) + + +def generate_component_catalog_rsts() -> dict[str, str]: + """Return deterministic RST tables for every built-in component registry. + + :returns: generated RST keyed by component registry + """ + register_builtin_components() + cell_line_rows = _builtin_rows( + cast(list[FeaturizerCatalogMetadata], cell_line_featurizer_registry.list_metadata()), + BUILTIN_CELL_LINE_FEATURIZER_NAMES, + "cell-line featurizers", + ) + drug_rows = _builtin_rows( + cast(list[FeaturizerCatalogMetadata], drug_featurizer_registry.list_metadata()), + BUILTIN_DRUG_FEATURIZER_NAMES, + "drug featurizers", + ) + predictor_rows = _builtin_rows( + cast(list[PredictorCatalogMetadata], predictor_registry.list_metadata()), + BUILTIN_PREDICTOR_NAMES, + "predictors", + ) + _validate_builtin_catalog( + cell_line_rows=cell_line_rows, + drug_rows=drug_rows, + predictor_rows=predictor_rows, + ) + return { + "cell_line": _render_featurizers(cell_line_rows), + "drug": _render_featurizers(drug_rows), + "predictor": _render_predictors(predictor_rows), + } + + +def write_generated_component_catalogs() -> tuple[Path, ...]: + """Write generated RST includes consumed by the component catalog. + + :returns: paths to the generated RST files + """ + generated = generate_component_catalog_rsts() + for key, path in GENERATED_CATALOGS.items(): + write_text_if_changed(path, generated[key]) + return tuple(GENERATED_CATALOGS.values()) diff --git a/docs/_examples.py b/docs/_examples.py new file mode 100644 index 000000000..373fbf22b --- /dev/null +++ b/docs/_examples.py @@ -0,0 +1,181 @@ +"""Import the runnable plugin examples so the docs build fails when one rots. + +``docs/examples/`` holds real plugin components. The extensions page shows them +with ``literalinclude``, which keeps the page and the code identical but proves +nothing about either -- a ``literalinclude`` of a file that no longer imports +renders happily. So the build imports every example, runs drevalpy's shipped +conformance checks over them, and generates the registry table the page shows +from the registries the examples actually landed in. + +Registering mutates process-wide state, so the registries are rolled back once +the check has passed: the examples exist to be read, not to appear in the +generated component catalogs. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from typing import Any + +from _generated_io import write_text_if_changed + +from drevalpy.registry import ( + cell_line_featurizer, + drug_featurizer, + predictor, + splitter, + visualization, +) + +DOCS_DIR = Path(__file__).resolve().parent +REPO_ROOT = DOCS_DIR.parent +GENERATED_EXAMPLES = DOCS_DIR / "python" / "_generated_examples.rst" + +#: Import package of the examples. ``docs`` has no ``__init__.py`` and resolves +#: as a namespace package, which keeps the generic name ``examples`` off the top +#: level of every process that builds the docs. +EXAMPLE_PACKAGE = "docs.examples" + +#: Every example module, in the order the extensions page presents them. +EXAMPLE_MODULES: tuple[str, ...] = ( + "toy_cell_line_featurizer", + "toy_drug_featurizer", + "toy_mean_predictor", + "toy_ridge_predictor", + "toy_block_predictor", + "toy_splitter", + "toy_visualization", + "toy_conformance", +) + +#: What importing the examples must add to each registry. Pinned rather than +#: derived so a decorator that silently stops running is a build failure. +EXPECTED_REGISTRATIONS: dict[str, tuple[str, ...]] = { + "cell_line_featurizer": ("toyCellLine",), + "drug_featurizer": ("toyDrugHash",), + "predictor": ("toyBlockRidge", "toyMean", "toyRidge"), + "splitter": ("TOY_LCO",), + "visualization": ("toyResiduals",), +} + +_REGISTRIES = { + "cell_line_featurizer": cell_line_featurizer, + "drug_featurizer": drug_featurizer, + "predictor": predictor, + "splitter": splitter, + "visualization": visualization, +} + +_SINGLETONS = { + "cell_line_featurizer": cell_line_featurizer.cell_line_featurizer_registry, + "drug_featurizer": drug_featurizer.drug_featurizer_registry, + "predictor": predictor.predictor_registry, + "splitter": splitter.splitter_registry, + "visualization": visualization.visualization_registry, +} + +_LABELS = { + "cell_line_featurizer": "Cell-line featurizer", + "drug_featurizer": "Drug featurizer", + "predictor": "Predictor", + "splitter": "Splitter mode", + "visualization": "Visualization", +} + + +def _snapshot() -> dict[str, frozenset[str]]: + return {name: frozenset(module.list()) for name, module in _REGISTRIES.items()} + + +def _restore(snapshot: dict[str, frozenset[str]]) -> None: + for name, kept in snapshot.items(): + _SINGLETONS[name].retain_only(kept) + + +def _import_examples() -> None: + """Import every example, putting the repository root on the path first. + + ``docs.examples`` resolves relative to the repository root, which the + editable install deliberately no longer places on ``sys.path``. Adding it + here keeps the driver independent of the directory Sphinx was invoked from. + """ + if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + for name in EXAMPLE_MODULES: + importlib.import_module(f"{EXAMPLE_PACKAGE}.{name}") + + +def _registered_by_examples(snapshot: dict[str, frozenset[str]]) -> dict[str, tuple[str, ...]]: + return {name: tuple(sorted(set(module.list()) - snapshot[name])) for name, module in _REGISTRIES.items()} + + +def _assert_expected(observed: dict[str, tuple[str, ...]]) -> None: + if observed != EXPECTED_REGISTRATIONS: + msg = ( + "The documented examples no longer register what docs/_examples.py expects: " + f"expected {EXPECTED_REGISTRATIONS}, got {observed}. Update EXPECTED_REGISTRATIONS " + "and the extensions page together." + ) + raise RuntimeError(msg) + + +def _run_checks() -> None: + conformance = importlib.import_module(f"{EXAMPLE_PACKAGE}.toy_conformance") + conformance.check_components() + conformance.check_splitter() + conformance.check_visualization() + + +def _describe(registry_name: str, name: str) -> str: + metadata: dict[str, Any] = _REGISTRIES[registry_name].metadata(name) + return " ".join((metadata.get("description") or "").split()) or "No description." + + +def _render(observed: dict[str, tuple[str, ...]]) -> str: + lines = [ + ".. list-table::", + " :header-rows: 1", + " :widths: 24 26 50", + "", + " * - Registered name", + " - Registry", + " - Description", + ] + for registry_name in EXPECTED_REGISTRATIONS: + for name in observed[registry_name]: + lines.extend( + [ + f" * - ``{name}``", + f" - {_LABELS[registry_name]}", + f" - {_describe(registry_name, name)}", + ] + ) + return "\n".join([*lines, ""]) + + +def verify_documented_examples() -> dict[str, tuple[str, ...]]: + """Import, check and catalog the examples, then undo their registrations. + + :returns: Registry name mapped to the names the examples registered. + :raises RuntimeError: If an example fails to import, registers something + other than expected, or fails a conformance check. + """ + snapshot = _snapshot() + try: + _import_examples() + observed = _registered_by_examples(snapshot) + _assert_expected(observed) + _run_checks() + write_text_if_changed(GENERATED_EXAMPLES, _render(observed)) + except Exception as exc: + msg = ( + "A documented plugin example under docs/examples/ no longer works, so the " + "extensions page would teach code that does not run. Fix the example (or the " + "library change that broke it) rather than removing this check." + ) + raise RuntimeError(msg) from exc + finally: + _restore(snapshot) + return observed diff --git a/docs/_generated_io.py b/docs/_generated_io.py new file mode 100644 index 000000000..d197df4fb --- /dev/null +++ b/docs/_generated_io.py @@ -0,0 +1,23 @@ +"""Helpers for writing generated Sphinx includes without spurious rebuilds.""" + +from __future__ import annotations + +from pathlib import Path + + +def write_text_if_changed(path: Path, text: str, *, encoding: str = "utf-8") -> bool: + """Write ``text`` to ``path`` only when content differs. + + Leaving the mtime unchanged when content is identical prevents + ``sphinx-autobuild`` from entering a rebuild loop on generated includes. + + :param path: destination file + :param text: full file contents to write + :param encoding: text encoding + :returns: ``True`` if the file was created or updated + """ + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_file() and path.read_text(encoding=encoding) == text: + return False + path.write_text(text, encoding=encoding) + return True diff --git a/docs/_model_zoo.py b/docs/_model_zoo.py new file mode 100644 index 000000000..c901475cb --- /dev/null +++ b/docs/_model_zoo.py @@ -0,0 +1,112 @@ +"""Docs-only helpers for generating the model zoo catalog from zoo YAML + registries.""" + +from __future__ import annotations + +from pathlib import Path + +from _generated_io import write_text_if_changed + +from drevalpy.models.config import FeaturizerConfig, ModelConfig +from drevalpy.models.zoo import get_zoo_config, list_zoo_names +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.registry.predictor import metadata as get_predictor_metadata +from drevalpy.types.enums.model_scope import ModelScope + +DOCS_DIR = Path(__file__).resolve().parent +GENERATED_MODEL_ZOO = DOCS_DIR / "concepts" / "_generated_model_zoo.rst" + + +def _featurizer_recipe(feat: FeaturizerConfig | None) -> str: + if feat is None: + return "" + if feat.name == "concatFeaturizers": + parts = [_featurizer_recipe(child) for child in (feat.featurizers or ())] + return "+".join(parts) if parts else "concatFeaturizers" + if feat.view: + return f"{feat.name}[{feat.view}]" + return feat.name + + +def _routes_drugs_by_identity(config: ModelConfig) -> bool: + """Report whether *config* is a single-drug model whose drug slot only routes. + + Such a model's recipe is spelled two-part, because ``identity`` carries no + features of its own - it exists to hand the predictor a per-drug key. + + :param config: Zoo preset to inspect. + :returns: ``True`` when the drug slot should be omitted from the recipe. + """ + if config.scope != ModelScope.SINGLE_DRUG or config.cell_line_featurizer is None: + return False + return config.drug_featurizer is not None and config.drug_featurizer.name == "identity" + + +def _model_recipe(config: ModelConfig) -> str: + if config.cell_line_featurizer is None and config.drug_featurizer is None: + return config.predictor.name + cell = _featurizer_recipe(config.cell_line_featurizer) + if _routes_drugs_by_identity(config): + return f"{cell}:{config.predictor.name}" + drug = _featurizer_recipe(config.drug_featurizer) + return f"{cell}:{drug}:{config.predictor.name}" + + +def _predictor_description(predictor_name: str) -> str: + get_predictor(predictor_name) # ensure builtins are loaded for this name + meta = get_predictor_metadata(predictor_name) + description = (meta.get("description") or "").strip() + return description or "No description." + + +def _render_scope_table(scope: ModelScope) -> list[str]: + lines = [ + ".. list-table::", + " :header-rows: 1", + " :widths: 28 42 30", + "", + " * - Name", + " - Description", + " - Composition", + ] + for name in list_zoo_names(include_external=False, scope=scope): + config = get_zoo_config(name) + description = _predictor_description(config.predictor.name) + recipe = _model_recipe(config) + lines.extend( + [ + f" * - {name}", + f" - {description}", + f" - ``{recipe}``", + ] + ) + lines.append("") + return lines + + +def generate_model_zoo_rst() -> str: + """Return RST tables for built-in multi-drug and single-drug zoo presets. + + :returns: RST source with multi-drug and single-drug list tables + """ + register_builtin_components() + lines = [ + "Multi-drug models", + "-----------------", + "", + *(_render_scope_table(ModelScope.MULTI_DRUG)), + "Single-drug models", + "------------------", + "", + *(_render_scope_table(ModelScope.SINGLE_DRUG)), + ] + return "\n".join(lines).rstrip() + "\n" + + +def write_generated_model_zoo() -> Path: + """Write the generated model zoo RST consumed by ``concepts/model_zoo.rst``. + + :returns: path to the written ``_generated_model_zoo.rst`` file + """ + write_text_if_changed(GENERATED_MODEL_ZOO, generate_model_zoo_rst()) + return GENERATED_MODEL_ZOO diff --git a/drevalpy/visualization/style_utils/LCO.png b/docs/_static/img/LCO.png similarity index 100% rename from drevalpy/visualization/style_utils/LCO.png rename to docs/_static/img/LCO.png diff --git a/drevalpy/visualization/style_utils/LDO.png b/docs/_static/img/LDO.png similarity index 100% rename from drevalpy/visualization/style_utils/LDO.png rename to docs/_static/img/LDO.png diff --git a/drevalpy/visualization/style_utils/LPO.png b/docs/_static/img/LPO.png similarity index 100% rename from drevalpy/visualization/style_utils/LPO.png rename to docs/_static/img/LPO.png diff --git a/drevalpy/visualization/style_utils/LTO.png b/docs/_static/img/LTO.png similarity index 100% rename from drevalpy/visualization/style_utils/LTO.png rename to docs/_static/img/LTO.png diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst new file mode 100644 index 000000000..5819b0471 --- /dev/null +++ b/docs/_templates/autosummary/module.rst @@ -0,0 +1,27 @@ +{{ fullname | escape | underline}} + +{% if modules %} +.. automodule:: {{ fullname }} + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + :no-index: + +.. rubric:: Submodules + +.. autosummary:: + :toctree: + :recursive: +{% for item in modules %} +{%- if not item.split('.')[-1].startswith('_') and item.split('.')[-1] != 'custom_splits' %} + {{ item }} +{%- endif %} +{%- endfor %} +{% else %} +.. automodule:: {{ fullname }} + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource +{% endif %} diff --git a/docs/_templates/facade.rst b/docs/_templates/facade.rst new file mode 100644 index 000000000..ace5d6f6b --- /dev/null +++ b/docs/_templates/facade.rst @@ -0,0 +1,16 @@ +.. Template for a pure re-export module: documents the members but registers no +.. index entries, so aliasing a class does not make every unqualified reference +.. to it ambiguous. Selected with ``:template: facade.rst`` on an autosummary +.. directive. It lives at the root of ``templates_path`` rather than under +.. ``autosummary/`` because that is where the ``:template:`` option is resolved +.. from; a copy in the subdirectory is silently ignored in favour of +.. ``autosummary/base.rst``. + +{{ fullname | escape | underline}} + +.. automodule:: {{ fullname }} + :members: + :undoc-members: + :show-inheritance: + :member-order: bysource + :no-index: diff --git a/docs/cli/datasets.rst b/docs/cli/datasets.rst new file mode 100644 index 000000000..5a71794eb --- /dev/null +++ b/docs/cli/datasets.rst @@ -0,0 +1,65 @@ +Datasets +======== + +If you are reading this, we assume you are already familiar with this +concept: + +- :doc:`/concepts/datasets` + +This page covers dataset loading and splitting from the CLI. + +Loading datasets +---------------- + +``drevalpy data load`` downloads a built-in dataset (or resolves a custom +path) and writes it as a ``.h5mu`` file: + +.. code-block:: bash + + drevalpy data load GDSC1 data/GDSC1.h5mu + +The first positional argument is the dataset name (or path to an existing +``.h5mu`` file). The second is the output file path. Built-in datasets are +downloaded into the system cache on first use (see +:doc:`/getting_started/installation` for ``DREVALPY_CACHE_DIR``). + +Built-in dataset names are: ``BeatAML2``, ``CTRPv1``, ``CTRPv2``, ``GDSC1``, +``GDSC2``, ``PDX_Bruna``. Sizes and provenance for each are in +:doc:`/concepts/datasets`. + +Splitting datasets +------------------ + +``drevalpy data split`` generates cross-validation fold files from a dataset: + +.. code-block:: bash + + drevalpy data split GDSC1 splits/ --mode LCO --n-splits 5 + +Options: + +- ``--mode`` / ``-m`` — split mode: ``LPO``, ``LCO``, ``LDO``, or ``LTO`` + (default ``LPO``) +- ``--n-splits`` / ``-n`` — number of CV folds (default ``5``) +- ``--validation-ratio`` — fraction of training data for validation (default + ``0.1``) +- ``--random-state`` — random seed (default ``42``) + +Each fold is written as a ``.npz`` file (``fold_0.npz``, ``fold_1.npz``, …) +in the output directory. These files can be passed to ``drevalpy single`` for +per-fold execution. + +Split semantics (leakage constraints per mode) are documented in +:doc:`/concepts/evaluation`. + +Using ``drevalpy run`` with datasets +------------------------------------- + +The ``drevalpy run`` command handles loading and splitting automatically. Pass +``--dataset`` with a built-in name or file path: + +.. code-block:: bash + + drevalpy run ElasticNet --dataset GDSC1 --split-mode LCO + +For more on the ``run`` command, see :doc:`experiments`. diff --git a/docs/cli/experiments.rst b/docs/cli/experiments.rst new file mode 100644 index 000000000..847449566 --- /dev/null +++ b/docs/cli/experiments.rst @@ -0,0 +1,182 @@ +Experiments +=========== + +If you are reading this, we assume you are already familiar with these +concepts: + +- :doc:`/concepts/evaluation` +- :doc:`/concepts/from_components_to_models` + +The DrEvalPy CLI provides commands for the full experiment lifecycle: running +a complete pipeline, executing individual folds, and combining results. + +``drevalpy run`` — full pipeline +--------------------------------- + +The ``run`` command loads a dataset, splits it, tunes models (by default), +trains, predicts, and writes results: + +.. code-block:: bash + + drevalpy run ElasticNet RandomForest \ + --dataset GDSC1 \ + --split-mode LCO \ + --output-dir results + +Models are passed as positional arguments. Options: + +.. list-table:: + :header-rows: 1 + :widths: 30 15 55 + + * - Option + - Default + - Description + * - ``--dataset`` / ``-d`` + - + - Dataset name or ``.h5mu`` path (required). + * - ``--split-mode`` / ``-s`` + - ``LPO`` + - Split mode: ``LPO``, ``LCO``, ``LDO``, or ``LTO``. + * - ``--output-dir`` / ``-o`` + - ``results`` + - Output directory for results. + * - ``--hpo`` / ``--no-hpo`` + - ``--hpo`` + - Enable or disable hyperparameter tuning. + * - ``--hpo-metric`` + - ``RMSE`` + - Metric to optimize (``MSE``, ``MAE``, ``R^2``, ``Pearson``, ``Spearman``, ``Kendall``). + * - ``--hpo-num-samples`` + - ``16`` + - Number of Optuna trials per fold. + * - ``--hpo-random-state`` + - ``42`` + - HPO random seed. + * - ``--randomization-mode`` / ``-r`` + - None + - Randomization mode(s): ``SVRC``, ``SVCC``, ``SVRD``, ``SVCD``. + * - ``--randomization-type`` + - ``permutation`` + - ``permutation`` or ``invariant``. + * - ``--robustness-trials`` + - ``0`` + - Number of robustness permutations (0 = disabled). + * - ``--precomputed-only`` + - off + - Restrict HPO to pre-computed featurizer variants. + +Example with tuning disabled: + +.. code-block:: bash + + drevalpy run ElasticNet --dataset GDSC1 --split-mode LCO --no-hpo + +Example with custom HPO settings: + +.. code-block:: bash + + drevalpy run RandomForest \ + --dataset GDSC1 \ + --split-mode LPO \ + --hpo-metric Pearson \ + --hpo-num-samples 32 \ + --hpo-random-state 123 + +``drevalpy single`` — per-fold execution +----------------------------------------- + +For parallel or distributed workflows, run individual folds separately: + +.. code-block:: bash + + drevalpy single ElasticNet data/GDSC1.h5mu splits/fold_0.npz results/fold_0.npz \ + --hpo-metric RMSE \ + --hpo-num-samples 16 + +Arguments: + +1. Model name (zoo preset or custom) +2. Dataset path (``.h5mu`` file) +3. Split path (``.npz`` fold file from ``drevalpy data split``) +4. Output path (``.npz`` result file) + +Options are the same HPO flags as ``drevalpy run``, plus one that is specific to +per-fold execution: + +.. list-table:: + :header-rows: 1 + :widths: 30 15 55 + + * - Option + - Default + - Description + * - ``--response-transformation`` + - ``standard`` + - Target scaling fitted on the training scope only: ``None``, ``standard``, + ``minmax``, or ``robust``. Predictions are inverse-transformed before + scoring, so metrics stay in the dataset's original response units. + +``drevalpy aggregate`` — combine results +----------------------------------------- + +Combine per-fold ``RunResult`` files into a single ``ExperimentResult``: + +.. code-block:: bash + + drevalpy aggregate results/fold_0.npz results/fold_1.npz results/fold_2.npz \ + --output-dir experiment_results + +The aggregated result can then be passed to ``drevalpy report``. + +Hyperparameter tuning +--------------------- + +Tuning is **on by default** (``--hpo``). When enabled, models with a search +space are tuned with Ray Tune and Optuna before final fold evaluation. + +Ray vs Optuna +~~~~~~~~~~~~~ + +These are not two alternate backends. They play different roles in one stack: + +- **Ray Tune** runs and schedules trials (parallelism, resource allocation, + trial storage under the run directory). +- **Optuna** (via Ray's ``OptunaSearch``) chooses which hyperparameter values + to try next and optimizes ``--hpo-metric``. + +Without Ray installed, experiment-time tuning cannot run. Use ``--no-hpo`` +for defaults-only runs, or install on a platform that has Ray wheels (see +:doc:`/getting_started/installation`). + +Randomization and robustness +----------------------------- + +``--randomization-mode`` adds feature-shuffle tests (``SVCC``, ``SVRC``, +``SVCD``, ``SVRD``). ``--randomization-type`` is ``permutation`` (default) +or ``invariant``. ``--robustness-trials`` repeats training with shuffled fold +orderings; ``0`` disables. + +For standalone randomization and robustness: + +.. code-block:: bash + + drevalpy experiments randomization ElasticNet GDSC1 randomized/ --mode SVRC + drevalpy experiments robustness splits/ robustness_splits/ --n-permutations 5 + +Weights & Biases +---------------- + +Hyperparameter search can log each trial to `Weights & Biases +`_, but only from Python: ``hpam_tune`` accepts a +``wandb_project`` argument, and no CLI command forwards it. Call the tuning +API directly if you need W&B logging. + +Nextflow for large runs +----------------------- + +For demanding or highly reproducible workloads, use +`nf-core/drugresponseeval `_. The +pipeline pins the ``drevalpy`` version it runs and is driven by its own +`pipeline parameters `_, +which are distinct from the CLI options above. diff --git a/docs/cli/extensions.rst b/docs/cli/extensions.rst new file mode 100644 index 000000000..d6d5c1613 --- /dev/null +++ b/docs/cli/extensions.rst @@ -0,0 +1,83 @@ +Extensions +========== + +If you are reading this, we assume you are already familiar with this +concept: + +- :doc:`/concepts/registries` + +DrEvalPy's extension system lets you add custom components (featurizers, +predictors, splitters, visualizations, dataset sources) without modifying the +package itself. This page covers how to load extensions from the CLI; for +how to write them, see :doc:`/python/extensions`. + +``--extensions-dir`` +-------------------- + +The global ``--extensions-dir`` / ``-e`` option loads an extensions directory +before any subcommand runs: + +.. code-block:: bash + + drevalpy --extensions-dir my_components/ run ToyRidge \ + --dataset GDSC1 --split-mode LCO + +All ``.py`` files in the directory are imported (triggering any ``@register`` +decorators inside them). All ``.yaml`` files are loaded as model-zoo presets +or dataset source declarations. + +You can pass the flag multiple times to load several directories: + +.. code-block:: bash + + drevalpy -e my_featurizers/ -e my_zoo/ run MyModel --dataset GDSC1 --split-mode LCO + +``DREVALPY_EXTENSIONS_DIR`` +--------------------------- + +Set the environment variable to load extensions automatically without a CLI +flag: + +.. code-block:: bash + + export DREVALPY_EXTENSIONS_DIR=my_components/ + drevalpy run ToyRidge --dataset GDSC1 --split-mode LCO + +Both the environment variable and the CLI flag can be used together — the CLI +flag directories are loaded after the environment variable directory. + +Plugin discovery +---------------- + +For permanent extensions, package them as a Python package and declare the +``drevalpy.plugins`` entry point group. DrEvalPy discovers them automatically +on import without requiring any CLI flag or environment variable. + +In your plugin's ``pyproject.toml``: + +.. code-block:: toml + + [project.entry-points."drevalpy.plugins"] + my_plugin = "my_plugin.components" + +Once installed (``pip install my_plugin``), the custom components are available +in every ``drevalpy`` invocation. + +Extension directory layout +-------------------------- + +A typical extension directory: + +.. code-block:: text + + my_extensions/ + custom_featurizer.py # @register_cell_line_featurizer(...) + custom_predictor.py # @register_predictor(...) + custom_splitter.py # @register_splitter(...) + custom_zoo.yaml # zoo presets referencing the new components + +Python files are imported in sorted order (``__init__.py`` is skipped). +YAML files are parsed as zoo presets that map names to already-registered +component stacks. + +For full examples of each extension type, see :doc:`/python/extensions`. diff --git a/docs/cli/models.rst b/docs/cli/models.rst new file mode 100644 index 000000000..1a54d5186 --- /dev/null +++ b/docs/cli/models.rst @@ -0,0 +1,79 @@ +Models +====== + +If you are reading this, we assume you are already familiar with these +concepts: + +- :doc:`/concepts/component_catalog` +- :doc:`/concepts/from_components_to_models` +- :doc:`/concepts/model_zoo` + +This page covers how to select and configure models from the CLI. + +Passing models to ``drevalpy run`` +---------------------------------- + +Model names are passed as positional arguments to ``drevalpy run``: + +.. code-block:: bash + + drevalpy run ElasticNet RandomForest --dataset GDSC1 --split-mode LCO + +Names correspond to **zoo presets** — the same names you would pass to +``construct_model("ElasticNet")`` in Python. The full list of built-in presets +is documented in :doc:`/concepts/model_zoo`. + +Multiple models +~~~~~~~~~~~~~~~ + +Space-separate model names to evaluate several in one run: + +.. code-block:: bash + + drevalpy run ElasticNet RandomForest GradientBoosting --dataset GDSC1 --split-mode LPO + +All models are evaluated on the same folds. Results are combined into a single +``ExperimentResult`` directory. + +Custom models via extensions +---------------------------- + +If your model is defined in a custom extension (registered via decorators in +a ``.py`` file), make it available with ``--extensions-dir`` or the +``DREVALPY_EXTENSIONS_DIR`` environment variable: + +.. code-block:: bash + + drevalpy --extensions-dir my_components/ run ToyRidge --dataset GDSC1 --split-mode LCO + +The ``--extensions-dir`` flag is a **global option** that must appear before +the subcommand. It loads all ``.py`` files in the directory (triggering +registration decorators) and all ``.yaml`` files as zoo presets. + +See :doc:`extensions` for more details on extension loading and +:doc:`/python/extensions` for how to write custom components. + +Model composition +----------------- + +From the CLI, models are selected by zoo name only. For custom compositions +(recipe strings, YAML, or ``ModelConfig``), register them in a zoo YAML file +under your extensions directory: + +.. code-block:: yaml + + MyCustomEN: + cell_line_featurizer: + name: pca + view: expression + drug_featurizer: fingerprints + predictor: elasticNet + +Then reference by name: + +.. code-block:: bash + + drevalpy --extensions-dir my_zoo/ run MyCustomEN --dataset GDSC1 --split-mode LCO + +Composition concepts (recipe grammar, YAML fields, contracts) are documented +in :doc:`/concepts/from_components_to_models`. diff --git a/docs/cli/quickstart.rst b/docs/cli/quickstart.rst new file mode 100644 index 000000000..8d09cedf7 --- /dev/null +++ b/docs/cli/quickstart.rst @@ -0,0 +1,32 @@ +Quickstart +========== + +Install DrEvalPy and its dependencies first — see +:doc:`/getting_started/installation`. + +Run an LCO experiment on GDSC1 with ElasticNet: + +.. code-block:: bash + + drevalpy run ElasticNet --dataset GDSC1 --split-mode LCO --no-hpo + +This trains ElasticNet to predict drug response on the GDSC1 screen, using +leave-cell-line-out splits (LCO; see :doc:`/concepts/evaluation`). +Results are written under the output directory (default ``results/``). + +Build the HTML report: + +.. code-block:: bash + + drevalpy report results/ --output-dir report + +Open ``report/multiqc_report.html`` in your browser. + +For large or highly reproducible runs, prefer the Nextflow pipeline +`nf-core/drugresponseeval `_ +(`GitHub `_). No Nextflow +knowledge is required to use it. + +Next steps: :doc:`experiments` for more options, :doc:`visualization` for the +report command, and :doc:`/concepts/datasets` / :doc:`/concepts/evaluation` +for datasets and evaluation settings. diff --git a/docs/cli/reference.rst b/docs/cli/reference.rst new file mode 100644 index 000000000..a0a311d9f --- /dev/null +++ b/docs/cli/reference.rst @@ -0,0 +1,9 @@ +CLI reference +============= + +Generated reference for the ``drevalpy`` CLI (root experiment callback and all +subcommands). Prefer the workflow pages in this guide for usage; use this page +when you need the full option inventory. The content below is produced from the +Typer app at docs build time. + +.. include:: _generated_reference.rst diff --git a/docs/cli/visualization.rst b/docs/cli/visualization.rst new file mode 100644 index 000000000..10053a756 --- /dev/null +++ b/docs/cli/visualization.rst @@ -0,0 +1,69 @@ +Visualization and reporting +=========================== + +If you are reading this, we assume you are already familiar with this +concept: + +- :doc:`/concepts/evaluation` + +After an experiment finishes, ``drevalpy report`` builds an HTML report with +evaluation metrics and visualizations. + +``drevalpy report`` +------------------- + +.. code-block:: bash + + drevalpy report EXPERIMENT_DIR [OPTIONS] + +Arguments: + +- ``EXPERIMENT_DIR`` — path to a saved ``ExperimentResult`` directory (the + output of ``drevalpy run`` or ``drevalpy aggregate``). + +Options: + +.. list-table:: + :header-rows: 1 + :widths: 30 15 55 + + * - Option + - Default + - Description + * - ``--output-dir`` / ``-o`` + - ``report`` + - Output directory for the HTML report. + * - ``--title`` / ``-t`` + - ``Drug Response Evaluation`` + - Report title. + * - ``--reference-model`` / ``-r`` + - None + - Normalize metrics against this model. + * - ``--dataset`` / ``-d`` + - None + - Path to dataset ``.h5mu`` for metadata enrichment. + +Example +------- + +.. code-block:: bash + + drevalpy report results/ --output-dir report --title "My Benchmark" + +With a reference model for normalized metrics: + +.. code-block:: bash + + drevalpy report results/ \ + --output-dir report \ + --reference-model NaiveMeanEffectsPredictor \ + --dataset data/GDSC1.h5mu + +The report uses `MultiQC `_ internally and includes +critical-difference diagrams, metric tables, violin plots, heatmaps, and +scatter comparisons. You need enough CV folds (typically at least seven) for +meaningful critical-difference diagrams. + +Evaluation concepts (normalized metrics, critical difference) are documented +in :doc:`/concepts/evaluation`. For the Python report API, see +:doc:`/python/visualization`. diff --git a/docs/concepts/component_catalog.rst b/docs/concepts/component_catalog.rst new file mode 100644 index 000000000..c976499d8 --- /dev/null +++ b/docs/concepts/component_catalog.rst @@ -0,0 +1,128 @@ +Component catalog +================= + +This catalog is the vocabulary of a DrEvalPy model. Every model is assembled +from components with three distinct roles: + +- a **cell-line featurizer** represents the biological sample, +- a **drug featurizer** represents the compound, and +- a **predictor** maps those representations to a drug-response estimate. + +.. mermaid:: + + flowchart LR + cellLineData["Cell line"] + drugData["Drug"] + responseEstimate["Drug response estimate"] + + subgraph model [Model] + cellLineFeaturizer["Cell-line featurizer"] + drugFeaturizer["Drug featurizer"] + predictor["Predictor"] + end + + cellLineData --> cellLineFeaturizer + drugData --> drugFeaturizer + cellLineFeaturizer --> predictor + drugFeaturizer --> predictor + predictor --> responseEstimate + +The names below are the stable registry names used in recipes and model-zoo +definitions. They are case-sensitive. At this stage, focus on what each +component contributes; the next page, :doc:`from_components_to_models`, +explains how the names fit together and how compatibility is checked. + +Featurizers +----------- + +Featurizers vs encoders +~~~~~~~~~~~~~~~~~~~~~~~ + +In DrEvalPy, we distinguish between featurizers and encoders. +Both are used to transform the input data into a feature space that can be used by the predictor, but they differ in their purpose, how they are trained, and how they are used. + +**Featurizers** are strategies for extracting features from the input data in an unsupervised manner. +They can be precomputed from cell-line or drug data alone, without the need for drug response labels. +Featurizers can however have hyperparameters, for example the number of principal components to keep in a PCA transformation. +While featurizer representations are generally predictor-agnostic, the optimal hyperparameters can depend on the predictor, the data, and the task. +In DrEvalPy, we try to make sure that featurizers can be elegantly combined with different predictors, while ensuring that hyperparameters are optimized jointly with the predictor. + +**Encoders** on the other hand are parts of models that are optimized alongside the main prediction head of a model. +Examples of encoders are the per-omics encoders inside SuperFELTR or the transformer stack inside PharmaFormer. +As these components are tightly coupled to the prediction head and the weights of both are optimized jointly, they are not considered featurizers. +In DrEvalPy, encoders are baked into the predictors and cannot be combined with other predictors. + +Precomputable featurizers +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some featurizers produce representations that depend only on the entity itself +(the cell line or the drug) and not on any training labels. Their output is +deterministic and identical regardless of which predictor consumes it or which +CV fold is active. These featurizers are marked as **precomputable**. + +Precomputable featurizers are typically computationally expensive (model-based +embeddings, graph construction, BPE tokenization). Their results can be stored +inside the dataset ahead of time, so the experiment loop never has to +recompute them. Lightweight featurizers such as ``landmarkGenes`` or +``scaledGeneExpression`` are fast enough that precomputation is unnecessary -- +they run inline and are *not* marked as precomputable. + +Precomputation and hyperparameter optimization are not mutually exclusive. +A precomputable featurizer can still have hyperparameters (for example, the +fingerprint radius). Because each hyperparameter configuration yields a +different cached output, multiple variants can be stored side by side in the +same dataset. At experiment time, users choose one of two strategies: + +- **Precomputed-only mode** -- the optimizer treats the stored variants as + categorical choices and selects among them. This is fast because no + featurizer computation happens at all during the search. +- **Standard HPO mode** -- the optimizer ignores stored variants and explores + the featurizer's continuous hyperparameter space normally, recomputing + features for each trial. This is slower but can find configurations that + were never precomputed. + +Featurizer types +~~~~~~~~~~~~~~~~ + +- ``numeric_matrix`` — one dense numeric row per entity (cell line or drug). + Fingerprints, gene expression, and pathways use this format. It is the + default and the only format ``MatrixPredictor`` models can consume. +- ``graph`` — one molecular graph object per entity, held in an object array + instead of a stackable matrix. The ``drugGraph`` featurizer loads + precomputed PyG graphs (node features ``x``, ``edge_index``); block + predictors such as DrugGNN run graph convolutions on them. +- ``ragged_sequence`` — one variable-size tensor or sequence per entity, also + stored as an object array so lengths can differ across drugs. The ``molgnet`` + featurizer exposes MolGNet embeddings this way; block predictors such as + DIPK consume them without forcing a fixed-width dense matrix. + +Cell-line featurizers +~~~~~~~~~~~~~~~~~~~~~ + +.. include:: _generated_cell_line_featurizers.rst + +Drug featurizers +~~~~~~~~~~~~~~~~ + +.. include:: _generated_drug_featurizers.rst + +Predictors +---------- + +Every predictor inherits exactly one **input interface**. That interface decides whether featurizers are +required and how features are consumed: + +- **Feature-free** — the predictor only receives the drug/cell line identifiers, no featurizers are required. +- **Matrix** — the predictor receives a single numeric matrix (can be concatenated from multiple featurizers). This is the case for predictors like ``randomForest``, where it does not matter which omics layer the features originate from. +- **Block** — the predictor receives a dictionary of named featurizer outputs. This is useful for predictors that treat different omics layers separately, e.g. ``molir``, which consumes ``gene_expression``, ``mutations``, and ``copy_number_variation`` as separate blocks. It also allows for predictors to specify that they require certain featurizers to be present in their input data, otherwise they can't work. + +.. include:: _generated_predictors.rst + +Extensions +---------- + +DrEvalPy provides a convenient interface to register external components. +This is useful if you want to evaluate a new predictor or featurizer that is not yet part of the catalog. +Details on how to register external components can be found in +:doc:`/python/extensions` for the Python API and :doc:`/cli/extensions` +for the CLI workflow. diff --git a/docs/concepts/datasets.rst b/docs/concepts/datasets.rst new file mode 100644 index 000000000..befccf717 --- /dev/null +++ b/docs/concepts/datasets.rst @@ -0,0 +1,192 @@ +Datasets and response data +========================== + +DrEvalPy ships commonly used drug-response screens. Each built-in name resolves +to a response table and the matching cell-line and drug feature views used by +the model zoo. + +Built-in datasets +----------------- + +.. list-table:: + :header-rows: 1 + :widths: 18 18 14 22 28 + + * - Dataset + - Curves + - Drugs + - Samples + - Notes + * - GDSC1 + - 316506 + - 378 + - 970 cell lines + - Genomics of Drug Sensitivity in Cancer v1 + * - GDSC2 + - 234437 + - 287 + - 969 cell lines + - Genomics of Drug Sensitivity in Cancer v2 + * - CTRPv1 + - 60758 + - 354 + - 243 cell lines + - Cancer Therapeutics Response Portal v1 + * - CTRPv2 + - 395025 + - 546 + - 886 cell lines + - Cancer Therapeutics Response Portal v2 + * - BeatAML2 + - 62487 + - 166 + - 569 patients + - Ex vivo AML patient screens + * - PDX_Bruna + - 2559 + - 104 + - 37 mouse passages + - Breast cancer PDTX-derived cultures + +Response measures +----------------- + +``response.X`` holds ``pEC50``. Every other dose–response summary the fit +produces is a named ``response.layers`` entry: ``LN_IC50``, ``EC50``, ``IC50``, +``AUC``, plus the quality metrics below. Published, non-refit values from the +original study are carried alongside with a ``_published`` suffix where the +source provides them. + +All built-in screens are refit with one shared CurveCurator procedure, which is +what makes numbers comparable across studies. + +Custom data concepts +-------------------- + +You can also evaluate against custom response tables. Conceptually there are +two shapes: + +* **Raw viability (long format).** Columns ``dose``, ``response``, ``sample``, + and ``drug``, plus an optional ``replicate``. Dosages must be in µM. + DrEvalPy fits curves with the same CurveCurator procedure used for built-in + refits, so the same measures become available. +* **Prefit response CSV.** At least ``cell_line_id``, ``drug_id``, and a + measure column. Leave-Tissue-Out also needs a ``tissue`` column. + +Custom names are not restricted to the built-in list; the library treats an +unknown dataset name as a custom load path. + +On-disk format: MuData +----------------------- + +Datasets are stored as `MuData `_ objects +(``.h5mu`` files). MuData is a multimodal extension of +`AnnData `_ — the standard data structure in +the single-cell genomics ecosystem. While DrEvalPy works with bulk data (cell +lines, not single cells), the underlying data layout is identical: an +observation-by-variable matrix with metadata on both axes. + +A single MuData object bundles all modalities of a dataset: + +.. code-block:: text + + MuData + ├── response # AnnData: cell_lines × drugs (X = IC50/AUC/...) + ├── expression # AnnData: cell_lines × genes + ├── methylation # AnnData: cell_lines × CpG regions + ├── mutations # AnnData: cell_lines × genes + ├── copy_number # AnnData: cell_lines × genes + ├── proteomics # AnnData: cell_lines × proteins + └── fingerprints # AnnData: drugs × fingerprint bits + +Each modality is an AnnData with: + +- ``X`` — the data matrix (e.g. expression counts, response values) +- ``obs`` — observation (cell line / drug) metadata (tissue, name, ...) +- ``var`` — variable (gene / drug) metadata +- ``layers`` — alternative representations (e.g. raw vs normalized counts) + +.. _curve-quality: + +Curve quality metrics +--------------------- + +The built-in screens are refit with CurveCurator, and the ``response`` modality +ships **every** fitted curve — including the ones the fit itself says are +meaningless. Because ``X`` holds ``pEC50``, which exists for every curve that +converged, "not NaN" does not mean "trustworthy": the quality metrics stored +alongside as ``response.layers`` are what separate the two. + +.. list-table:: + :header-rows: 1 + :widths: 22 14 64 + + * - Layer + - Better + - Meaning + * - ``relevance_score`` + - higher + - SAM-corrected significance. This is the multiple-testing-corrected + statistic, so prefer it over ``p_value``. + * - ``fold_change`` + - larger ``abs`` + - Curve fold change, already log2. The magnitude is the effect size. + * - ``p_value`` + - lower + - Raw F-test p-value, **uncorrected**. + * - ``log_p_value`` + - higher + - ``-log10(p_value)``, also uncorrected. + * - ``f_value`` / ``f_value_sam`` + - higher + - F statistic of the fit, and its s0-corrected counterpart. + * - ``R2`` / ``RMSE`` + - higher / lower + - Goodness of fit. + * - ``signal_quality`` + - higher + - Signal quality of the underlying measurements. + * - ``slope``, ``front``, ``back`` + - see note + - Curve shape. A slope pinned at the fitting bound describes a step, which + is usually an artefact; ``front`` and ``back`` are the fitted plateaus. + * - ``regulation`` + - — + - CurveCurator's own verdict, encoded ``up = 1``, ``down = -1``, + ``not = 0``, and NaN where it reached none. + * - ``pec50_error``, ``slope_error``, ``front_error``, ``back_error`` + - lower + - Standard error of each fitted parameter, from a Moore-Penrose + pseudo-inverse of the fit's Jacobian. ``pec50_error`` is the uncertainty + on ``X`` itself, and these are the only per-curve uncertainty estimates + the pipeline produces. + +Every built-in splitter drops the pairs that fail + +.. code-block:: text + + relevance_score >= -log10(0.05) and abs(fold_change) >= 0.45 + +which are the ``alpha`` and ``fc_lim`` that DrEvalPy passes to CurveCurator, so +the rule reproduces ``regulation != 0`` exactly. This is not configurable per +split — a comparison between models is only meaningful over the same pairs. + +Filtering on any other metric, or on other thresholds, is available through +:func:`~drevalpy.plugin.curve_quality_mask`; see :doc:`/python/extensions`. + +Feature provenance +------------------ + +Cell-line and drug features shipped with the built-in screens come from public +omics and chemistry sources (expression, methylation, mutation, CNV, +proteomics, fingerprints, and model-specific embeddings). Preprocessing and +feature inventories live in the +`preprocess_drp_data `_ +repository. + +How to load and run +------------------- + +- :doc:`/cli/datasets` — CLI dataset loading and splitting +- :doc:`/python/datasets` — loading and splitting in Python +- :doc:`/python/quickstart` — end-to-end Python track map after concepts diff --git a/docs/concepts/evaluation.rst b/docs/concepts/evaluation.rst new file mode 100644 index 000000000..354db4a92 --- /dev/null +++ b/docs/concepts/evaluation.rst @@ -0,0 +1,142 @@ +Evaluation settings and metrics +=============================== + +DrEvalPy standardizes how models are split, scored, and stress-tested so +comparisons stay reproducible across studies and interfaces. + +Cross-validation settings +------------------------- + +Four leave-out modes control what is held out at validation and test time: + +.. image:: /_static/img/LPO.png + :width: 24% + :alt: Leave-Pair-Out setting + +.. image:: /_static/img/LCO.png + :width: 24% + :alt: Leave-Cell-Line-Out setting + +.. image:: /_static/img/LTO.png + :width: 24% + :alt: Leave-Tissue-Out setting + +.. image:: /_static/img/LDO.png + :width: 24% + :alt: Leave-Drug-Out setting + +* **LPO (Leave-Pair-Out).** Random cell-line–drug pairs are held out, but both + the drug and the cell line may already appear in training. Easiest setting; + mainly useful to check whether a model can complete missing training + entries. +* **LCO (Leave-Cell-Line-Out).** Entire cell lines are held out while drugs may + overlap training. Relevant for personalized medicine: can the model predict + a new cell line? +* **LTO (Leave-Tissue-Out).** Entire tissues are held out. Harder than LCO; + relevant for tissue transfer and drug-repurposing style questions. +* **LDO (Leave-Drug-Out).** Entire drugs are held out while cell lines may + overlap training. Usually the hardest setting; relevant for new-drug + prediction in discovery workflows. + +Baselines and naive predictors +------------------------------ + +Drug response values often have strong drug- or cell-line-specific means. A +model that only recovers those effects can look strong on naive correlation +or R²-style scores. We therefore always compare against naive predictors +(overall mean, cell-line mean, drug mean, tissue mean, tissue–drug mean, and +the ANOVA-style ``NaiveMeanEffectsPredictor``). + +Baselines are tuned and scored alongside primary models, but randomization +and robustness stress tests apply to primary models only. +``NaiveMeanEffectsPredictor`` is required for normalized metrics and is added +when missing. It combines dataset, tissue (when available), cell-line, and drug +effects and is usually the strongest naive reference across settings. + +Randomization modes +------------------- + +Randomization asks how much performance drops when input views are scrambled +while the rest of the experiment stays fixed. + +Modes: + +* **SVCC** — Single View Constant (cell lines): one cell-line view stays + intact; other cell-line views are perturbed. +* **SVRC** — Single View Random (cell lines): one cell-line view is + randomized; others stay intact. +* **SVCD** — Single View Constant (drugs): one drug view stays intact; other + drug views are perturbed. +* **SVRD** — Single View Random (drugs): one drug view is randomized; others + stay intact. + +Types: + +* **permutation** — shuffle features across instances, preserving feature + distributions but breaking the link to the target (default). +* **invariant** — scramble while preserving a key statistic (for matrices, + mean and standard deviation per instance; for networks, degree + distribution). + +Robustness +---------- + +Robustness reruns training with varying random seeds. The number of trials +controls how many independent seeds you collect to judge run-to-run +stability. + +Metrics +------- + +Reported metrics include **MSE**, **RMSE**, **MAE**, **R²**, **Pearson**, +**Spearman**, and **Kendall**. Hyperparameter optimization defaults to +**RMSE**, not R². + +Normalized metrics +~~~~~~~~~~~~~~~~~~ + +Drug- and cell-line-specific means can make ordinary R² and correlation look +strong even when a model adds little beyond those effects (Simpson's paradox +style inflation). The HTML report therefore also computes **normalized** +variants of R², Pearson, Spearman, and Kendall. + +For each prediction row, DrEvalPy subtracts the corresponding +``NaiveMeanEffectsPredictor`` prediction from both the true response and the +model prediction, then recomputes those metrics on the residuals: + +.. code-block:: text + + y_true_norm = y_true - y_pred_NaiveMeanEffects + y_pred_norm = y_pred - y_pred_NaiveMeanEffects + +MSE, RMSE, and MAE are **not** reported in normalized form — residualizing by +a strong mean-effects baseline mainly changes R² and the correlation metrics. +Normalized scores answer: “after removing what the mean-effects baseline +already explains, how much remaining structure does the model capture?” +Positive normalized R² / correlation means the model beats that baseline on +the residual signal; values near zero mean it mostly tracks mean effects. + +Response transforms +------------------- + +Optional response transforms applied before fitting: + +* **none** — leave the measure unchanged +* **standard** — zero-mean / unit-variance scaling +* **minmax** — scale into a fixed range +* **robust** — scale using robust statistics (median / IQR-style) + +Cross-study evaluation +---------------------- + +Cross-study evaluation trains on one dataset and scores on others that share +compatible identifiers and feature spaces. Prefer CurveCurator-refit measures +so the response definition itself is aligned across studies. + +How to run experiments +---------------------- + +- :doc:`/cli/experiments` — CLI experiment settings +- :doc:`/cli/visualization` — HTML report after a run +- :doc:`/python/experiments` — experiment pipeline options and results +- :doc:`/python/visualization` — ``evaluate``, plots, and ``create_report`` diff --git a/docs/concepts/from_components_to_models.rst b/docs/concepts/from_components_to_models.rst new file mode 100644 index 000000000..389d66fde --- /dev/null +++ b/docs/concepts/from_components_to_models.rst @@ -0,0 +1,487 @@ +From components to models +========================= + +The :doc:`component_catalog` introduced the available building blocks. This +page supplies the grammar for combining them into a runnable model. + +A model consists of three components: + +- a cell-line featurizer +- a drug featurizer +- a predictor + +DrEvalPy provides multiple ways of defining which components should be used in +a model: + +- **Recipe strings** — concise; do not carry hyperparameter spaces +- **YAML files** — more verbose; can declare hyperparameter spaces +- **ModelConfig** — same information as YAML, but Python-native + +Examples below use a tab switcher so you can compare all three notations for +the same architecture. + +Basic composition +----------------- + +The three slots are always cell-line featurizer, drug featurizer, then +predictor: + +.. tab-set:: + :sync-group: composition + + .. tab-item:: Recipe string + :sync: recipe + + .. code-block:: text + + cell-line featurizer : drug featurizer : predictor + + .. tab-item:: YAML + :sync: yaml + + .. code-block:: yaml + + cell_line_featurizer: + drug_featurizer: + predictor: + + .. tab-item:: ModelConfig + :sync: modelconfig + + .. code-block:: python + + from drevalpy.models import config + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig(name=""), + drug_featurizer=config.DrugFeaturizerConfig(name=""), + predictor=config.PredictorConfig(name=""), + ) + +A very simple complete stack is gene-expression scaling, drug fingerprints, +and an elastic-net predictor: + +.. tab-set:: + :sync-group: composition + + .. tab-item:: Recipe string + :sync: recipe + + .. code-block:: text + + scaledGeneExpression:fingerprints:elasticNet + + .. tab-item:: YAML + :sync: yaml + + .. code-block:: yaml + + cell_line_featurizer: scaledGeneExpression + drug_featurizer: fingerprints + predictor: elasticNet + + .. tab-item:: ModelConfig + :sync: modelconfig + + .. code-block:: python + + from drevalpy.models import config + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="scaledGeneExpression" + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig(name="elasticNet"), + ) + +Read it from left to right: scale gene expression for each cell line, compute +drug fingerprints, then fit an elastic-net predictor. The architecture is +fully determined by those three names. + +Other single-view stacks follow the same pattern: + +.. tab-set:: + :sync-group: composition + + .. tab-item:: Recipe string + :sync: recipe + + .. code-block:: text + + normalizedProteomics:fingerprints:randomForest + landmarkGenes:fingerprints:xgboost + scaledGeneExpression:singleDrugElasticNet + + .. tab-item:: YAML + :sync: yaml + + .. code-block:: yaml + + cell_line_featurizer: normalizedProteomics + drug_featurizer: fingerprints + predictor: randomForest + + .. code-block:: yaml + + cell_line_featurizer: landmarkGenes + drug_featurizer: fingerprints + predictor: xgboost + + .. code-block:: yaml + + cell_line_featurizer: scaledGeneExpression + predictor: singleDrugElasticNet + + .. tab-item:: ModelConfig + :sync: modelconfig + + .. code-block:: python + + from drevalpy.models import config + + .. code-block:: python + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="normalizedProteomics" + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig(name="randomForest"), + ) + + .. code-block:: python + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="landmarkGenes" + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig(name="xgboost"), + ) + + .. code-block:: python + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="scaledGeneExpression" + ), + predictor=config.PredictorConfig(name="singleDrugElasticNet"), + ) + +In the last example, ``singleDrugElasticNet`` omits an explicit drug +featurizer from the recipe. The config normalizer injects the implicit +``identity`` routing featurizer, which one-hot encodes drug identifiers to +create a single estimator per drug. Nothing states the training scope either: +``singleDrugElasticNet`` is a per-drug predictor, so ``cfg.scope`` reads +``single_drug`` off the predictor rather than off the config. + +Featurizers that can operate on multiple omics layers +----------------------------------------------------- + +The ``raw`` and ``pca`` cell-line featurizers are flexible towards which omics +layer to read. Put that view in brackets as part of the featurizer's +qualified name: + +.. tab-set:: + :sync-group: composition + + .. tab-item:: Recipe string + :sync: recipe + + .. code-block:: text + + raw[expression]:fingerprints:randomForest + pca[methylation]:fingerprints:randomForest + raw[proteomics]:fingerprints:randomForest + + .. tab-item:: YAML + :sync: yaml + + .. code-block:: yaml + + cell_line_featurizer: + name: raw + view: expression + drug_featurizer: fingerprints + predictor: randomForest + + .. code-block:: yaml + + cell_line_featurizer: + name: pca + view: methylation + drug_featurizer: fingerprints + predictor: randomForest + + .. code-block:: yaml + + cell_line_featurizer: + name: raw + view: proteomics + drug_featurizer: fingerprints + predictor: randomForest + + .. tab-item:: ModelConfig + :sync: modelconfig + + .. code-block:: python + + from drevalpy.models import config + + .. code-block:: python + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="raw", + view="expression", + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig(name="randomForest"), + ) + + .. code-block:: python + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="pca", + view="methylation", + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig(name="randomForest"), + ) + + .. code-block:: python + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="raw", + view="proteomics", + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig(name="randomForest"), + ) + +Common view aliases include ``expression``, ``methylation``, ``mutations``, +``proteomics``, and ``cnv``. + +Combining multiple representations +---------------------------------- + +Within a featurizer slot, ``+`` concatenates several featurizers into +``concatFeaturizers``: + +.. tab-set:: + :sync-group: composition + + .. tab-item:: Recipe string + :sync: recipe + + .. code-block:: text + + raw[expression]+pca[methylation]:fingerprints:xgboost + landmarkGenes+normalizedProteomics:fingerprints:lightgbm + + .. tab-item:: YAML + :sync: yaml + + .. code-block:: yaml + + cell_line_featurizer: + name: concatFeaturizers + featurizers: + - name: raw + view: expression + - name: pca + view: methylation + drug_featurizer: fingerprints + predictor: xgboost + + .. code-block:: yaml + + cell_line_featurizer: + - landmarkGenes + - normalizedProteomics + drug_featurizer: fingerprints + predictor: lightgbm + + .. tab-item:: ModelConfig + :sync: modelconfig + + .. code-block:: python + + from drevalpy.models import config + + .. code-block:: python + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="concatFeaturizers", + featurizers=( + {"name": "raw", "view": "expression"}, + {"name": "pca", "view": "methylation"}, + ), + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig(name="xgboost"), + ) + + .. code-block:: python + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="concatFeaturizers", + featurizers=( + "landmarkGenes", + "normalizedProteomics", + ), + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig(name="lightgbm"), + ) + +The left slot can concatenate several cell-line featurizers; the middle slot +can concatenate drug featurizers the same way when needed. The right slot is +always a single predictor name. + +Composition validation +---------------------- + +Composition is validated before training. Each featurizer declares a +``FeatureFormat`` (numeric matrix, graph, or ragged sequence); each predictor +declares which formats and which input interface +(``FeatureFreePredictor``, ``MatrixPredictor``, or ``BlockPredictor``) it +accepts. Matrix predictors reject graph/ragged payloads, while block +predictors consume the corresponding fitted blocks. An incompatible recipe +fails early rather than reaching the training loop. + +Hyperparameter spaces +--------------------- + +Only the YAML and ModelConfig interfaces allow specifying hyperparameter +spaces. Recipe strings describe architecture only; when you use a recipe, +each component falls back to its built-in hyperparameter space. + +On a YAML or ModelConfig stack, set ``hyperparameter_space`` on a component to +**replace** that component's built-in search space. Specs use local parameter +names (``alpha``, ``n_components``, …); DrEvalPy prefixes them for tuning. + +.. tab-set:: + :sync-group: composition + + .. tab-item:: YAML + :sync: yaml + + .. code-block:: yaml + + cell_line_featurizer: + name: pca + view: expression + hyperparameter_space: + n_components: + type: int + low: 8 + high: 512 + default: 128 + drug_featurizer: fingerprints + predictor: + name: elasticNet + hyperparameter_space: + alpha: + type: float + low: 1.0e-4 + high: 10.0 + log: true + default: 1.0 + l1_ratio: + type: float + low: 0.0 + high: 1.0 + default: 0.5 + + .. tab-item:: ModelConfig + :sync: modelconfig + + .. code-block:: python + + from drevalpy.models import config + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="pca", + view="expression", + hyperparameter_space={ + "n_components": { + "type": "int", + "low": 8, + "high": 512, + "default": 128, + }, + }, + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig( + name="elasticNet", + hyperparameter_space={ + "alpha": { + "type": "float", + "low": 1e-4, + "high": 10.0, + "log": True, + "default": 1.0, + }, + "l1_ratio": { + "type": "float", + "low": 0.0, + "high": 1.0, + "default": 0.5, + }, + }, + ), + ) + +Hyperparameter names during search +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When tuning runs, Ray Tune / Optuna see a **merged** space whose keys are +dotted and mirror the composed stack: + +.. code-block:: text + + predictor.. + cell_line_featurizer.. + drug_featurizer.. + +The featurizer selector is the same qualified name as in a recipe, including +the view bracket when present (``pca[expression]``, ``landmarkGenes``, …). +For the example above, that yields: + +.. code-block:: text + + predictor.elasticNet.alpha + predictor.elasticNet.l1_ratio + cell_line_featurizer.pca[expression].n_components + +The same base featurizer on different views stays independently tunable +(``pca[expression]`` vs ``pca[proteomics]``). Repeating the same qualified +selector in one slot is rejected. + +At construction time, DrEvalPy accepts the same qualified keys. When a local +parameter name is unique in the stack (for example ``alpha`` on a single +predictor), you may also pass the short name. When the same local name appears +on more than one component, use qualified keys or a documented alias. +DrEvalPy rejects ambiguous short names instead of broadcasting one value to +every matching component. + +Continue the story +------------------ + +- **Next:** :doc:`model_zoo` — choose a named, ready-to-run architecture +- **Previous:** :doc:`component_catalog` — look up a registered component name +- :doc:`/python/models` — ``construct_model``, lifecycle, and scope rules + (recipe grammar stays on this page) +- :doc:`/python/extensions` — registering components, ``ModelInputBatch``, + and predictor interfaces +- :doc:`/python/datasets` — applied recipes for custom CSVs / alternate views +- :doc:`/python/experiments` — running search on a fixed stack + diff --git a/docs/concepts/model_zoo.rst b/docs/concepts/model_zoo.rst new file mode 100644 index 000000000..12478c8cb --- /dev/null +++ b/docs/concepts/model_zoo.rst @@ -0,0 +1,27 @@ +Model zoo +========= + +The :doc:`component_catalog` lists the available building blocks, and +:doc:`from_components_to_models` shows how they form a recipe. + +While these interfaces allow building models in a flexible way, we are aware that certain featurizer-predictor combinations are used frequently. +In order to make it easier to use these frequently used combinations, we provide a so-called 'model zoo' which is a collection of curated model configurations. + +Each model configuration is a YAML configuration file. The name is derived from the file name. +Just like any configuration YAML file, zoo YAML files can contain overrides for the hyperparameter spaces and default hyperparameters of the components. + +In the table below, you can find the currently available zoo models. + +- **Name** is the alias of the zoo model. It can be used everywhere a recipe string is accepted. +- **Description** the description of the predictor. +- **Composition** the equivalent recipe string, using the exact atoms introduced in the component catalog. + +.. include:: _generated_model_zoo.rst + +Continue from here +------------------ + +- :doc:`/cli/models` — selecting zoo names in CLI experiments +- :doc:`/python/models` — ``construct_model``, class vs instance, lifecycle +- :doc:`/python/experiments` — tuning a fixed preset architecture +- :doc:`from_components_to_models` — revisit recipes and custom composition diff --git a/docs/concepts/registries.rst b/docs/concepts/registries.rst new file mode 100644 index 000000000..9bc3d7661 --- /dev/null +++ b/docs/concepts/registries.rst @@ -0,0 +1,140 @@ +Registries & Extensibility +========================== + +Registries are the discovery layer of DrEvalPy. Every extensible concept in the +framework -- predictors, featurizers, splitters, datasets, and visualizations +-- is managed by a registry that maps human-readable names to implementations. + +When you write ``elasticNet`` in a recipe, zoo YAML, or CLI invocation, +DrEvalPy resolves that string through the predictor registry to find the class +that implements elastic-net training and prediction. The same principle applies +to featurizers, splitter modes, dataset sources, and plot types. + +Common interface +---------------- + +Every registry exposes the same core operations: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Operation + - Purpose + * - ``register`` + - Add a new entry (decorator or function call) + * - ``list`` + - Retrieve a sorted list of all registered names + * - ``get`` + - Look up an implementation by name + * - ``metadata`` + - Return one entry's record as a dict + * - ``table`` + - Return a summary ``DataFrame`` (useful in notebooks) + +Registering a name that is already taken **raises** in every registry. Each +``register`` takes an ``override=True`` escape hatch for the cases where +replacing an entry is the intent; the point of the default is that one package +cannot quietly change another package's semantics. + +All registries are populated automatically when the package is imported. +Third-party plugins installed via pip are discovered at the same time +(see `Plugin discovery`_ below). + +Predictor registry +------------------ + +Stores predictor classes that map featurized cell-line and drug representations +to a drug-response estimate. Each predictor declares: + +- a **cell-line contract** -- what feature format it expects from the cell-line + featurizer (numeric matrix, graph, or ragged sequence) +- a **drug contract** -- what feature format it expects from the drug featurizer +- an **input interface** -- whether it is feature-free, matrix-based, or + block-based (see :doc:`component_catalog` for details) + +Cell-line featurizer registry +----------------------------- + +Stores featurizer classes that transform raw cell-line data (gene expression, +methylation, mutations, CNV, proteomics) into a feature representation +consumable by predictors. Each featurizer declares a **contract** describing +its output format. + +Drug featurizer registry +------------------------ + +Stores featurizer classes that transform raw drug data (SMILES strings, +molecular structures, precomputed embeddings) into feature representations. +Like cell-line featurizers, each drug featurizer declares an output contract. + +Splitter registry +----------------- + +Stores splitting strategies that divide datasets into train, validation, and +test folds. Each splitter is registered under a mode name (such as ``LPO``, +``LCO``, ``LDO``, or ``LTO``) and declares which **leakage constraint** to +enforce. After every split, the registry automatically validates that the +chosen constraint holds. + +Dataset registry +---------------- + +Manages named datasets and their remote (or local) sources. Unlike the other +registries, dataset entries are persisted to a local configuration file so that +custom datasets survive across sessions. + +The registry tracks two kinds of entries: + +- **Sources** -- a named base URL that serves as the prefix for one or more + dataset files. A source can point to any location that + `fsspec `_ supports: public HTTPS + servers, Amazon S3 buckets, Google Cloud Storage, Azure Blob Storage, local + file paths, or any other protocol with an fsspec implementation. Optional + storage credentials (tokens, keys) are stored alongside the URL. +- **Datasets** -- a named reference that combines a source with a filename. + For example, a dataset named ``GDSC2`` might reference the built-in HTTPS + source and the file ``GDSC2.h5mu``. When loading, DrEvalPy resolves the full + path by joining the source URL with the filename. + +This two-level design means you register a source once (for instance, a private +S3 bucket with credentials) and then add as many dataset entries under it as +you need -- each pointing to a different ``.h5mu`` file at that location. + +Visualization registry +---------------------- + +Stores visualization classes that generate plots from experiment results. +Each visualization declares **requirements** -- conditions that must be met by +the experiment result for the plot to be applicable (for example: multiple CV +folds, multiple models, or a reference model). The reporting system +automatically selects which visualizations to render based on these +requirements. + +Plugin discovery +---------------- + +When the package is imported, it scans for installed Python packages that +advertise the ``drevalpy.plugins`` entry point group. Importing the advertised +module triggers registration decorators, making a plugin's components +available without any explicit user action beyond installation. + +A plugin that raises while importing would otherwise remove every component it +declares without a trace, so the failure is recorded rather than swallowed and +can be read back afterwards. The default stays non-fatal, so one broken +third-party package cannot take the CLI down with it; an environment variable +makes it fatal for the plugin's own CI, where a plugin that does not load is a +failure rather than a degraded experience. + +Extension directories +--------------------- + +For quick local experimentation, both the CLI and the Python API accept an +**extensions directory** containing ``.py`` and ``.yaml`` files. All Python +files in the directory are imported (triggering registration decorators for +any registry), and all YAML files are loaded as model-zoo presets or dataset +declarations. An environment variable (``DREVALPY_EXTENSIONS_DIR``) provides +the same mechanism without requiring a CLI flag. + +For code examples of how to interact with each registry, see +:doc:`/python/extensions` (Python) and :doc:`/cli/experiments` (CLI). diff --git a/docs/conf.py b/docs/conf.py index c541f6090..7d5f2d241 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,6 @@ #!/usr/bin/env python """Configuration file for the Sphinx documentation builder.""" -# mypy: ignore-errors # drevalpy documentation build configuration file # # If extensions (or modules to document with autodoc) are in another @@ -13,18 +12,18 @@ import os import sys from datetime import datetime +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as pkg_version from pathlib import Path from jinja2.defaults import DEFAULT_FILTERS sys.path.insert(0, os.path.abspath("../")) +sys.path.insert(0, os.path.abspath(".")) # -- General configuration --------------------------------------------- -# If your documentation needs a minimal Sphinx version, state it here. -# needs_sphinx = '1.0' - # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -32,12 +31,34 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.autosummary", + "sphinx.ext.napoleon", "sphinx_autodoc_typehints", "sphinx.ext.intersphinx", - "sphinx_click", "sphinx.ext.autosectionlabel", + "sphinx_design", + "sphinxcontrib.mermaid", ] +# Generate registry- and application-driven tables before sources are read. +# `verify_documented_examples` runs last: it imports docs/examples/, which +# registers components, and the generators above assert on registry contents. + + +def _write_generated_references() -> None: + from _cli_click import write_generated_cli_reference + from _component_catalog import write_generated_component_catalogs + from _examples import verify_documented_examples + from _model_zoo import write_generated_model_zoo + + write_generated_cli_reference() + write_generated_component_catalogs() + write_generated_model_zoo() + verify_documented_examples() + + +_write_generated_references() + + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -55,23 +76,36 @@ # The version info for the project you're documenting, acts as replacement # for |version| and |release|, also used in various other places throughout # the built documents. -# -# The short X.Y version. -version = "1.5.1" -# The full version, including alpha/beta/rc tags. -release = "1.5.1" +try: + release = pkg_version("drevalpy") +except PackageNotFoundError: + release = "1.5.1" +version = ".".join(release.split(".")[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. language = "en" -# List of patterns, relative to source directory, that match files and +# List of patterns relative to source directory that match files and # directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +exclude_patterns = [ + "_build", + "Thumbs.db", + ".DS_Store", + "_cli_click.py", + "_component_catalog.py", + "_examples.py", + "_generated_io.py", + "_model_zoo.py", + "examples", + "cli/_generated_reference.rst", + "concepts/_generated_cell_line_featurizers.rst", + "concepts/_generated_drug_featurizers.rst", + "concepts/_generated_model_zoo.rst", + "concepts/_generated_predictors.rst", + "python/_generated_examples.rst", +] + # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False @@ -88,18 +122,9 @@ "github_url": "https://github.com/daisybio/drevalpy/", } -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# +# The theme to use for HTML and HTML Help pages. html_theme = "sphinx_rtd_theme" -# The names of the Pygments (syntax highlighting) styles to use. -html_theme_options = {"pygment_light_style": "default", "pygment_dark_style": "lightbulp"} - -# Theme options are theme-specific and customize the look and feel of a -# theme further. For a list of options available for each theme, see the -# documentation. -# html_theme_options = dict( navigation_depth=6, logo_only=True, @@ -115,13 +140,12 @@ # -- Options for HTMLHelp output --------------------------------------- -# Output file base name for HTML help builder. htmlhelp_basename = "drevalpydoc" # -- Options for LaTeX output ------------------------------------------ -latex_elements = { +latex_elements: dict[str, str] = { # The paper size ("letterpaper" or "a4paper"). # # "papersize": "letterpaper", @@ -165,10 +189,18 @@ ] autodoc_typehints = "description" - - -# autodoc_mock_imports = ["numpy", "sklearn", "pandas", "networkx", "yaml", "pytorch_lightning", "torch", "scipy", -# "copy", "os", "abc", "pathlib", "typing"] +napoleon_google_docstring = True +napoleon_numpy_docstring = False +napoleon_use_param = True +napoleon_use_rtype = True +autosummary_generate = True +autosummary_imported_members = False +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, + "member-order": "bysource", +} # -- Options for Texinfo output ---------------------------------------- @@ -208,8 +240,7 @@ def get_obj_module(qualname): - """ - Get a module/class/attribute and its original module by qualname. + """Get a module/class/attribute and its original module by qualname. :param qualname: The qualified name of the object. :returns: The object and its original module. @@ -224,8 +255,11 @@ def get_obj_module(qualname): # retrieve object and find original module name if classname: cls = getattr(sys.modules[modname], classname) - modname = cls.__module__ - obj = getattr(cls, attrname) if attrname else cls + modname = getattr(cls, "__module__", modname) + if attrname: + obj = getattr(cls, attrname, None) + else: + obj = cls else: obj = None @@ -233,8 +267,7 @@ def get_obj_module(qualname): def get_linenos(obj): - """ - Get an object’s line numbers. + """Get an object's line numbers. :param obj: The object. :returns: The start and end line numbers. @@ -252,8 +285,7 @@ def get_linenos(obj): def modurl(qualname): - """ - Get the full GitHub URL for some object’s qualname. + """Get the full GitHub URL for some object's qualname. :param qualname: The qualified name of the object. :returns: The full GitHub URL. @@ -265,8 +297,8 @@ def modurl(qualname): return f"{github_url}/{path}{fragment}" -# html_context doesn’t apply to autosummary templates ☹ -# and there’s no way to insert filters into those templates +# html_context doesn't apply to autosummary templates +# and there's no way to insert filters into those templates # so we have to modify the default filters DEFAULT_FILTERS["modurl"] = modurl diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index 1b8c10011..000000000 --- a/docs/contributing.rst +++ /dev/null @@ -1,94 +0,0 @@ -Contributor Guide -================= - -Thank you for your interest in improving this project. -This project is open-source under the `MIT license`_ and -highly welcomes contributions in the form of bug reports, feature requests, and pull requests. - -Here is a list of important resources for contributors: - -- `Source Code`_ -- `Documentation`_ -- `Issue Tracker`_ - -.. _MIT license: https://opensource.org/license/mit -.. _Source Code: https://github.com/daisybio/drevalpy -.. _Documentation: https://drevalpy.readthedocs.io/ -.. _Issue Tracker: https://github.com/daisybio/drevalpy/issues - -How to report a bug -------------------- - -Report bugs on the `Issue Tracker`_. - - -How to request a feature ------------------------- - -Request features on the `Issue Tracker`_. - - -How to set up your development environment ------------------------------------------- - -1. Fork the repository on GitHub. -2. Make a new conda environment with Python 3.11, 3.12, or 3.13. -3. ``pip install poetry`` : we use poetry to manage dependencies -4. ``pip install poetry-plugin-export`` -5. ``poetry install`` : this will install all dependencies -6. Test whether the installation was successful by running the following command: - - .. code:: console - - $ drevalpy --run_id my_first_run --models NaiveDrugMeanPredictor ElasticNet --dataset_name TOYv1 --test_mode LCO - -6. Visualize the results by running the following command: - - .. code:: console - - $ drevalpy report --run_id my_first_run --dataset_name TOYv1 - -How to test the project ------------------------ - -Unit tests are located in the ``tests`` directory, -and are written using the pytest_ testing framework. - -.. _pytest: https://pytest.readthedocs.io/ - -How to submit changes ---------------------- - -Open a `pull request`_ to submit changes to this project against the ``development`` branch. - -Your pull request needs to meet the following guidelines for acceptance: - -- The code must pass all tests. -- Include unit tests. This project maintains a high code coverage. -- If your changes add functionality, update the documentation accordingly. - -To run linting and code formatting checks before committing your change, you can install pre-commit as a -Git hook by running the following command: - -.. code:: console - - $ nox --session=pre-commit -- install - -It is recommended to open an issue before starting work on anything. - -.. _pull request: https://github.com/daisybio/drevalpy/pulls - -How to build and view the documentation ---------------------------------------- - -This project uses Sphinx_ together with several extensions to build the documentation. -To build the documentation, change into the docs/ directory and run: - -.. code:: console - - $ make html - -The generated static HTML files can be found in the `_build/html` folder. -Simply open them with your favorite browser. - -.. _sphinx: https://www.sphinx-doc.org/en/master/ diff --git a/docs/drevalpy.datasets.rst b/docs/drevalpy.datasets.rst deleted file mode 100644 index 1e1ce76f9..000000000 --- a/docs/drevalpy.datasets.rst +++ /dev/null @@ -1,35 +0,0 @@ -Datasets -========================= - -Dataset module --------------- - -.. automodule:: drevalpy.datasets.dataset - :members: - :undoc-members: - :show-inheritance: - -Loaders -------- - -.. automodule:: drevalpy.datasets.loader - :members: - :undoc-members: - :show-inheritance: - -CurveCurator ------------- - -.. automodule:: drevalpy.datasets.curvecurator - :members: - :undoc-members: - :show-inheritance: - -Utility functions ------------------ - -.. automodule:: drevalpy.datasets.utils - :members: - :undoc-members: - :show-inheritance: - diff --git a/docs/drevalpy.models.DIPK.rst b/docs/drevalpy.models.DIPK.rst deleted file mode 100644 index 7016651f8..000000000 --- a/docs/drevalpy.models.DIPK.rst +++ /dev/null @@ -1,43 +0,0 @@ -DIPK -===== - -DIPK Model --------------------------------- - -.. automodule:: drevalpy.models.DIPK.dipk - :members: - :undoc-members: - :show-inheritance: - -Attention utils --------------------------------------------- - -.. automodule:: drevalpy.models.DIPK.attention_utils - :members: - :undoc-members: - :show-inheritance: - -Data utils ---------------------------------------- - -.. automodule:: drevalpy.models.DIPK.data_utils - :members: - :undoc-members: - :show-inheritance: - -Gene expression encoder ------------------------------------------------------ - -.. automodule:: drevalpy.models.DIPK.gene_expression_encoder - :members: - :undoc-members: - :show-inheritance: - -Model utils ----------------------------------------- - -.. automodule:: drevalpy.models.DIPK.model_utils - :members: - :undoc-members: - :show-inheritance: - diff --git a/docs/drevalpy.models.DrugGNN.rst b/docs/drevalpy.models.DrugGNN.rst deleted file mode 100644 index 7036061ff..000000000 --- a/docs/drevalpy.models.DrugGNN.rst +++ /dev/null @@ -1,9 +0,0 @@ -DrugGNN -======== - -DrugGNN Model ---------------- -.. automodule:: drevalpy.models.DrugGNN.drug_gnn - :members: - :undoc-members: - :show-inheritance: \ No newline at end of file diff --git a/docs/drevalpy.models.MOLIR.rst b/docs/drevalpy.models.MOLIR.rst deleted file mode 100644 index 943b2df1a..000000000 --- a/docs/drevalpy.models.MOLIR.rst +++ /dev/null @@ -1,19 +0,0 @@ -MOLIR -============================= - -MOLIR Model ----------------------------------- - -.. automodule:: drevalpy.models.MOLIR.molir - :members: - :undoc-members: - :show-inheritance: - -Model utils ----------------------------------- - -.. automodule:: drevalpy.models.MOLIR.utils - :members: - :undoc-members: - :show-inheritance: - diff --git a/docs/drevalpy.models.PharmaFormer.rst b/docs/drevalpy.models.PharmaFormer.rst deleted file mode 100644 index 2adf093fa..000000000 --- a/docs/drevalpy.models.PharmaFormer.rst +++ /dev/null @@ -1,18 +0,0 @@ -PharmaFormer -============================= - -PharmaFormer Model ----------------------------------- - -.. automodule:: drevalpy.models.PharmaFormer.pharmaformer - :members: - :undoc-members: - :show-inheritance: - -Model utils ----------------------------------- - -.. automodule:: drevalpy.models.PharmaFormer.model_utils - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/drevalpy.models.Precily.rst b/docs/drevalpy.models.Precily.rst deleted file mode 100644 index 496e3b0d6..000000000 --- a/docs/drevalpy.models.Precily.rst +++ /dev/null @@ -1,18 +0,0 @@ -Precily -============================= - -Precily Model ----------------------------------- - -.. automodule:: drevalpy.models.Precily.precily - :members: - :undoc-members: - :show-inheritance: - -Model utils ----------------------------------- - -.. automodule:: drevalpy.models.Precily.model_utils - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/drevalpy.models.SRMF.rst b/docs/drevalpy.models.SRMF.rst deleted file mode 100644 index 5e82c0169..000000000 --- a/docs/drevalpy.models.SRMF.rst +++ /dev/null @@ -1,10 +0,0 @@ -SRMF -============================ - -SRMF Model --------------------------------- - -.. automodule:: drevalpy.models.SRMF.srmf - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/drevalpy.models.SimpleNeuralNetwork.rst b/docs/drevalpy.models.SimpleNeuralNetwork.rst deleted file mode 100644 index 7125788e5..000000000 --- a/docs/drevalpy.models.SimpleNeuralNetwork.rst +++ /dev/null @@ -1,78 +0,0 @@ -Simple Neural Network -=========================================== - -.. _flexible-inputs-simplenn: - -Flexible Input System --------------------------------------------- - -The baseline neural network models support **flexible inputs**. Rather than hardcoding which omic data type a model uses, -you configure ``cell_line_views`` and ``drug_views`` directly in the ``hyperparameters.yaml`` file. - -By doing this, we have replaced the ``ChemBERTaNeuralNetwork`` whose only difference to the ``SimpleNeuralNetwork`` was -its usage of ChemBERTa embeddings instead of fingerprints as input. - -Configuring the input views -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The default ``SimpleNeuralNetwork`` configuration uses gene expression and fingerprints: - -.. code-block:: yaml - - SimpleNeuralNetwork: - cell_line_views: - - gene_expression - drug_views: - - fingerprints - dropout_prob: - - 0.3 - units_per_layer: - - - 32 - - 16 - - 8 - - 4 - ... - -To train the same ``SimpleNeuralNetwork`` with **ChemBERTa** embeddings instead, change ``drug_views``: - -.. code-block:: yaml - - SimpleNeuralNetwork: - cell_line_views: - - gene_expression - drug_views: - - drug_chemberta_embeddings - dropout_prob: - - 0.3 - units_per_layer: - - - 32 - - 16 - - 8 - - 4 - ... - -For more, see the documentation of the sklearn models: :ref:`flexible-inputs`. - -Simple Neural Network Model ------------------------------------------------------------------- - -.. automodule:: drevalpy.models.SimpleNeuralNetwork.simple_neural_network - :members: - :undoc-members: - :show-inheritance: - -Multi-OMICS Neural Network ----------------------------------------------------------------------- - -.. automodule:: drevalpy.models.SimpleNeuralNetwork.multi_view_neural_network - :members: - :undoc-members: - :show-inheritance: - -Model utils ------------------------------------------------- - -.. automodule:: drevalpy.models.SimpleNeuralNetwork.utils - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/drevalpy.models.SuperFELTR.rst b/docs/drevalpy.models.SuperFELTR.rst deleted file mode 100644 index 7ab3f75ed..000000000 --- a/docs/drevalpy.models.SuperFELTR.rst +++ /dev/null @@ -1,18 +0,0 @@ -SuperFELTR -================================== - -SuperFELTR Model --------------------------------------------- - -.. automodule:: drevalpy.models.SuperFELTR.superfeltr - :members: - :undoc-members: - :show-inheritance: - -Model utils ---------------------------------------- - -.. automodule:: drevalpy.models.SuperFELTR.utils - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/drevalpy.models.baselines.rst b/docs/drevalpy.models.baselines.rst deleted file mode 100644 index 57224e850..000000000 --- a/docs/drevalpy.models.baselines.rst +++ /dev/null @@ -1,144 +0,0 @@ -Implemented baselines -================================= - -.. _flexible-inputs: - -Flexible Input System --------------------------------------------- - -The sklearn baseline models support **flexible inputs**. Rather than hardcoding which omic data type a model uses, -you configure ``cell_line_views`` and ``drug_views`` directly in the ``hyperparameters.yaml`` file. -A single model class (e.g., ``ElasticNet``, ``RandomForest``, ``KNNRegressor``) can therefore be trained on gene expression, -proteomics, or any other available omic without needing a separate Python class for each combination. - -This replaces the previously separate model classes (``ProteomicsRandomForest``, ``ProteomicsElasticNet``, -``SingleDrugProteomicsRandomForest``, ``SingleDrugProteomicsElasticNet``), which have been removed in favor -of this unified approach. - -Configuring the input views -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The default ``RandomForest`` configuration uses gene expression and fingerprints: - -.. code-block:: yaml - - RandomForest: - cell_line_views: - - gene_expression - drug_views: - - fingerprints - n_estimators: - - 100 - max_depth: - - 5 - - 10 - - 30 - ... - -To train the same Random Forest on **proteomics** data instead, change ``cell_line_views``: - -.. code-block:: yaml - - RandomForest: - cell_line_views: - - proteomics - drug_views: - - fingerprints - n_estimators: - - 100 - ... - -For the ``MultiViewRandomForest``, multiple cell line views can be specified as a nested list: - -.. code-block:: yaml - - MultiViewRandomForest: - cell_line_views: - - - gene_expression - - methylation - - mutations - - copy_number_variation_gistic - drug_views: - - fingerprints - ... - -How features are loaded -^^^^^^^^^^^^^^^^^^^^^^^ - -The feature loading depends on which view is specified in the configuration: - -- **gene_expression**: Loaded with the ``landmark_genes_reduced`` gene list for feature selection. -- **fingerprints**: Loaded using the precomputed Morgan fingerprints provided with each dataset. -- **proteomics**: Loaded as a generic CSV. The ``ProteomicsMedianCenterAndImputeTransformer`` is - automatically initialized for preprocessing. -- **Any other feature name** (e.g., ``methylation``, ``mutations``, ``copy_number_variation_gistic``, - or a custom name): The model calls ``load_generic_csv``, which looks for a CSV file at - ``//.csv``. The CSV must have ``cell_line_name`` as the index column. - All columns (except ``cellosaurus_id``, which is dropped if present) are used as features. - -This means you can use **any custom omic** by placing a correctly formatted CSV in the dataset directory -and setting ``cell_line_views`` to the file's name (without the ``.csv`` extension). - -For drug features the same logic applies: ``fingerprints`` loads the precomputed fingerprints, an empty -``drug_views`` list loads only the drug IDs, and any other name loads the CSV at -``//.csv``. - -Proteomics-specific hyperparameters -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -When ``proteomics`` is specified as a cell line view, the following hyperparameters control the -preprocessing transformer: - -- ``proteomics_feature_threshold`` (default: 0.7): minimum fraction of non-NA values required per protein -- ``proteomics_n_features`` (default: 1000): number of top-variance features to select -- ``proteomics_normalization_width`` (default: 0.3): width parameter for median-center normalization -- ``proteomics_normalization_downshift`` (default: 1.8): downshift parameter for median-center normalization - -Naive Predictors --------------------------------------------- - -Simple mean-based predictors that serve as lower-bound baselines. These models do not use any cell line -or drug features. They predict the mean response value computed from the training set, aggregated at -different levels (global, per drug, per cell line, per tissue, or per tissue-drug combination). - -.. automodule:: drevalpy.models.baselines.naive_pred - :members: - :undoc-members: - :show-inheritance: - -Sklearn Models ------------------------------------------------- - -Scikit-learn-based models for drug response prediction. All models in this module support flexible inputs -(see :ref:`flexible-inputs` above). By default they concatenate cell line features and drug features into -a single input matrix. Available models: ``ElasticNetModel``, ``RandomForest``, ``SVMRegressor``, -``GradientBoosting``, and ``AdaBoostDecisionTree``. - -.. automodule:: drevalpy.models.baselines.sklearn_models - :members: - :undoc-members: - :show-inheritance: - -Single-Drug Baselines ------------------------------------------------------------ - -Single-drug variants of the sklearn models. These models are trained separately for each drug, using only -cell line features (no drug features). Available models: ``SingleDrugRandomForest`` and -``SingleDrugElasticNet``. Both support flexible inputs for the cell line view. - -.. automodule:: drevalpy.models.baselines.singledrug_baselines - :members: - :undoc-members: - :show-inheritance: - -Multi-View Random Forest -------------------------------------------------------------- - -A Random Forest that accepts multiple cell line views simultaneously (e.g., gene expression, methylation, -mutations, and copy number variation). Each view is loaded and preprocessed independently, then all feature -matrices are concatenated before training. Methylation data is reduced with PCA before concatenation. - -.. automodule:: drevalpy.models.baselines.multi_view_random_forest - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/drevalpy.models.rst b/docs/drevalpy.models.rst deleted file mode 100644 index 3fda95618..000000000 --- a/docs/drevalpy.models.rst +++ /dev/null @@ -1,35 +0,0 @@ -Models -======================= - -DRP Model ----------- - -.. _DRP-label: -.. automodule:: drevalpy.models.drp_model - :members: - :undoc-members: - :show-inheritance: - -Utility functions ----------------------------- - -.. automodule:: drevalpy.models.utils - :members: - :undoc-members: - :show-inheritance: - -Implemented models ------------------- - -.. toctree:: - :maxdepth: 2 - - drevalpy.models.DIPK - drevalpy.models.DrugGNN - drevalpy.models.MOLIR - drevalpy.models.PharmaFormer - drevalpy.models.Precily - drevalpy.models.SRMF - drevalpy.models.SimpleNeuralNetwork - drevalpy.models.SuperFELTR - drevalpy.models.baselines \ No newline at end of file diff --git a/docs/drevalpy.visualization.rst b/docs/drevalpy.visualization.rst deleted file mode 100644 index e609099ad..000000000 --- a/docs/drevalpy.visualization.rst +++ /dev/null @@ -1,76 +0,0 @@ -Visualization -============================== - -Outplot -------------------------------------- - -.. automodule:: drevalpy.visualization.outplot - :members: - :undoc-members: - :show-inheritance: - -Comparison scatter plot -------------------------------------------------- - -.. automodule:: drevalpy.visualization.comp_scatter - :members: - :undoc-members: - :show-inheritance: - -Critical difference plot --------------------------------------------------------- - -.. automodule:: drevalpy.visualization.critical_difference_plot - :members: - :undoc-members: - :show-inheritance: - -Cross study tables ------------------------------------------- - -.. automodule:: drevalpy.visualization.cross_study_tables - :members: - :undoc-members: - :show-inheritance: - -Regression slider plot ------------------------------------------------------- - -.. automodule:: drevalpy.visualization.regression_slider_plot - :members: - :undoc-members: - :show-inheritance: - -Violin and heatmap parent class -------------------------------------- - -.. automodule:: drevalpy.visualization.vioheat - :members: - :undoc-members: - :show-inheritance: - -Heatmap -------------------------------------- - -.. automodule:: drevalpy.visualization.heatmap - :members: - :undoc-members: - :show-inheritance: - - -Violin plot ------------------------------------- - -.. automodule:: drevalpy.visualization.violin - :members: - :undoc-members: - :show-inheritance: - -Utility functions ------------------------------------ - -.. automodule:: drevalpy.visualization.utils - :members: - :undoc-members: - :show-inheritance: - diff --git a/docs/example_flexible_inputs.rst b/docs/example_flexible_inputs.rst deleted file mode 100644 index 8860777ea..000000000 --- a/docs/example_flexible_inputs.rst +++ /dev/null @@ -1,289 +0,0 @@ -Custom input with drevalpy's baselines -====================================== - -These example use cases are about how to use your own custom input for baseline models implemented in drevalpy. - -Example: Flexible Inputs with DrEvalPy's Baselines -------------------------------------------------------------- - -The sklearn baseline models (``ElasticNet``, ``Lasso``, ``RandomForest``, ``GradientBoosting``, ``SVR``, ``AdaBoostDecisionTree``, ``KNNRegressor``, -``SingleDrugRandomForest``, ``SingleDrugElasticNet``, ``MultiViewRandomForest``, ``MultiViewXGBoost``) and the neural network baselines (``SimpleNeuralNetwork``, ``MultiViewNeuralNetwork``) -support **flexible inputs**. Instead of writing a new Python class for each omic data type, you can simply change which omic the model uses by editing ``hyperparameters.yaml``. - -For example, to run a Random Forest on **mynewdatamodality** data instead of gene expression, change the -``cell_line_views`` in ``models/baselines/hyperparameters.yaml``: - -.. code-block:: yaml - - RandomForest: - cell_line_views: - - mynewdatamodality - drug_views: - - fingerprints - .. - -.. important:: - If you do not want to write a custom loading function, this requires that there exists a csv file with that name in - ``{path_to_data}/{dataset_name}/``. I.e., if you specify ``mynewdatamodality``, you need to have a ``mynewdatamodality.csv`` file. - - -The data is then loaded by the ``load_generic_csv`` function: - -.. code-block:: python - - def load_generic_csv(path: str, dataset_name: str, feature_name: str, index_col=CELL_LINE_IDENTIFIER) -> FeatureDataset: - """ - Loads a generic CSV file with cell line IDs as index and features as columns. - - :param path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :param feature_name: name of the feature, e.g., gene_expression - :param index_col: name of the index column, e.g., cell_line_id - :returns: FeatureDataset with the features - """ - feature_csv = pd.read_csv(f"{path}/{dataset_name}/{feature_name}.csv", index_col=index_col) - feature_csv.index = feature_csv.index.astype(str) - if "cellosaurus_id" in feature_csv.columns: - feature_csv = feature_csv.drop(columns=["cellosaurus_id"]) - return FeatureDataset(features=iterate_features(df=feature_csv, feature_type=feature_name)) - -Depending on whether you define it in ``cell_line_views`` or ``drug_views``, the index column will have to be the -``CELL_LINE_IDENTIFIER`` ("cell_line_name") or the ``DRUG_IDENTIFIER`` ("pubchem_id"). - - -You can then run it the same way as before: - -.. code-block:: shell - - drevalpy --models RandomForest --dataset_name CTRPv2 --data_path data - -For more details on the flexible input system, see the sklearn :ref:`flexible-inputs` documentation -and the SimpleNeuralNetwork :ref:`flexible-inputs-simplenn` documentation. - -Example: Using Flexible Inputs with Sklearn Baselines With Custom Preprocessing ---------------------------------------------------------------------------------- - -For the ``proteomics`` input, we implemented custom preprocessing in the ``SklearnModel`` parent class and for that, -we define custom hyperparameters in ``hyperparameters.yaml``: - -.. code-block:: yaml - - RandomForest: - cell_line_views: - - proteomics - drug_views: - - fingerprints - ... - proteomics_feature_threshold: - - 0.7 - proteomics_n_features: - - 1000 - proteomics_normalization_width: - - 0.3 - proteomics_normalization_downshift: - - 1.8 - -We add these parameters to the ``SklearnModel`` init method: - -.. code-block:: python - - def __init__(self): - # ... existing init method - # proteomics-specific defaults - self.proteomics_transformer = None - self.proteomics_feature_threshold = 0.7 - self.proteomics_n_features = 1000 - self.proteomics_normalization_width = 0.3 - self.proteomics_normalization_downshift = 1.8 - -These parameters are filled with the parameters from the hyperparameter file in the ``build_model`` method: - -.. code-block:: python - - def build_model(self, hyperparameters: dict): - # ... existing build_model method - # proteomics features are not supported for all models - if "proteomics" in self.cell_line_views: - self._init_proteomics_features(hyperparameters) - - def _init_proteomics_features(self, hyperparameters: dict): - self.proteomics_feature_threshold = hyperparameters.get("proteomics_feature_threshold", 0.7) - self.proteomics_n_features = hyperparameters.get("proteomics_n_features", 1000) - self.proteomics_normalization_width = hyperparameters.get("proteomics_normalization_width", 0.3) - self.proteomics_normalization_downshift = hyperparameters.get("proteomics_normalization_downshift", 1.8) - self.proteomics_transformer = ProteomicsMedianCenterAndImputeTransformer( - feature_threshold=self.proteomics_feature_threshold, - n_features=self.proteomics_n_features, - normalization_downshift=self.proteomics_normalization_downshift, - normalization_width=self.proteomics_normalization_width, - ) - -We want to normalize the proteomics data with a custom method which we implement in ``ProteomicsMedianCenterAndImputeTransformer`` (code see below). - -.. warning:: - This can't be done before training because it will compute medians. If the medians are computed on the whole dataset, - the test set's medians are **leaked** into the training set. The correct way to handle this is to compute the median - on the training set only (fit_transform function) and then only apply the median to the validation and test set (transform function). - -In the training function, we call our custom preprocessing function: - -.. code-block:: python - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - # [...] - if len(output) > 0: - if "gene_expression" in self.cell_line_views: - cell_line_input = scale_gene_expression( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - gene_expression_scaler=self.gene_expression_scaler, - ) - elif "proteomics" in self.cell_line_views: - cell_line_input = prepare_proteomics( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - transformer=self.proteomics_transformer, - ) - # [...] - -In the predict function, too: - -.. code-block:: python - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - # [...] - if "gene_expression" in self.cell_line_views: - cell_line_input = scale_gene_expression( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - gene_expression_scaler=self.gene_expression_scaler, - ) - elif "proteomics" in self.cell_line_views: - cell_line_input = prepare_proteomics( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - transformer=self.proteomics_transformer, - ) - # [...] - -Utility functions: - -.. code-block:: python - - class ProteomicsMedianCenterAndImputeTransformer(BaseEstimator, TransformerMixin): - """Performs median centering and imputation of proteomics data.""" - - def __init__(self, feature_threshold=0.7, n_features=1000, normalization_downshift=1.8, normalization_width=0.3): - """ - Hyperparameters for the normalization. - - :param feature_threshold: Require that, e.g., 70% of the proteins are measured without NAs - over all cell lines -> n_complete_features = number of proteins with at least 70% of the cell lines - :param n_features: fallback for feature selection. Take top n complete features. - Select max(n_complete_features, n_features) features. - :param normalization_downshift: downshift factor for the mean - :param normalization_width: width factor for the standard deviation - """ - self.feature_threshold = feature_threshold - self.n_features = n_features - self.normalization_downshift = normalization_downshift - self.normalization_width = normalization_width - self.protein_indices = np.array([]) - self.mean_median = 0 - - def fit(self, X, y=None): - """ - Learns the top n_feature complete proteins and calculates the mean median of the train cell lines. - - :param X: input proteomics data - :param y: not used - :returns: self - """ - required_proteins = int(X.shape[0] * self.feature_threshold) - # identify the complete columns - completeness = np.sum(~np.isnan(X), axis=0) - n_complete_features = np.count_nonzero(completeness >= required_proteins) - if n_complete_features < self.n_features: - # select top 1000 complete features - # sort by completeness - sorted_indices = np.argsort(completeness)[::-1] - self.protein_indices = sorted_indices[: self.n_features] - else: - # select the features meeting the required threshold - self.protein_indices = np.where(completeness >= required_proteins)[0] - X = X[:, self.protein_indices] - # calculate mean of sample medians - medians = np.nanmedian(X, axis=1) - self.mean_median = np.nanmean(medians) - return self - - def transform(self, X): - """ - Median center the data and impute missing values with downshifted normal distribution. - - :param X: input proteomics data - :returns: transformed proteomics data - """ - X = X[0] - - X = X[self.protein_indices] - - correction_factor = self.mean_median / np.nanmedian(X) - X = X * correction_factor - # downshifted mean - np.random.seed(seed=100) - cell_line_mean = np.nanmean(X) - cell_line_sd = np.nanstd(X) - downshifted_mean = cell_line_mean - (self.normalization_downshift * cell_line_sd) - shrinked_sd = self.normalization_width * cell_line_sd - n_missing = np.count_nonzero(np.isnan(X)) - X[np.isnan(X)] = np.random.normal(loc=downshifted_mean, scale=shrinked_sd, size=n_missing) - return [X] - - def prepare_proteomics( - cell_line_input: FeatureDataset, - cell_line_ids: np.ndarray, - training: bool, - transformer: ProteomicsMedianCenterAndImputeTransformer, - ) -> FeatureDataset: - """ - Applies log10 transform and proteomics normalization (centering + imputation) to proteomics view. - - :param cell_line_input: FeatureDataset with proteomics features - :param cell_line_ids: cell line IDs for training or transformation - :param training: whether to fit or only transform - :param transformer: Proteomics transformer - :returns: transformed FeatureDataset - """ - cell_line_input = cell_line_input.copy() - cell_line_input.apply(log10_and_set_na, view="proteomics") - if training: - cell_line_input.fit_transform_features( - train_ids=cell_line_ids, - transformer=transformer, - view="proteomics", - ) - else: - cell_line_input.transform_features( - ids=cell_line_ids, - transformer=transformer, - view="proteomics", - ) - return cell_line_input diff --git a/docs/example_wandb.rst b/docs/example_wandb.rst deleted file mode 100644 index e2509a405..000000000 --- a/docs/example_wandb.rst +++ /dev/null @@ -1,60 +0,0 @@ -DrEvalPy and Weights & Biases -====================================== - -We have a weights and biases integration for all our models. You can use this functionality very easily -by just supplying an extra parameter ``--wandb_project``: - -.. code-block:: bash - - drevalpy --run_id my_wandb_run --models model1 model2 --baselines baseline1 baseline2 --dataset_name CTRPv2 --wandb_project my_new_project_name - -You will be asked to generate an API key in the console. After inputting it, your project is connected to your -wandb account and you can look at your models online. - -Example: Compare Flexible Inputs for DrEvalPy's Baselines -------------------------------------------------------------- - -Through the :ref:`flexible-inputs`, we now treat omic input as hyperparameter for our sklearn baselines. -With wandb, we can compare model performances: - -.. code-block:: yaml - - [All sklearn models]: - cell_line_views: - - gene_expression - - proteomics - drug_views: - - fingerprints - .. - -.. code-block:: bash - - drevalpy --run_id compare_baselines \ - --models RandomForest \ - --baselines ElasticNet NaiveMeanEffectsPredictor GradientBoosting AdaBoostDecisionTree \ - --dataset_name TOYv1 \ - --wandb_project compare_baselines - -With ``+ Add Panels``, you can add interesting visualization. Add ``Parameter Importance`` (with respect to -val_R^2) and select your hyperparameters of interest to be visible: - -.. image:: _static/img/wandb_parameter_importance.png - :alt: Parameter importance displayed by wandb - :align: center - :width: 100% - -Add a ``Parallel Coordinates Plot``, too: - -.. image:: _static/img/wandb_parallel_coords.png - :alt: Parallel coordinates plot - :align: center - :width: 100% - -By filtering, you can investigate in a more detailed manner: Here, we filter to ``split_index=4`` and -``model_name="Elastic Net"`` and extend the parallel coordinates plot. - -.. image:: _static/img/wandb_parallel_coords2.png - :alt: Parallel coordinates plot Elastic Net - :align: center - :width: 100% - diff --git a/docs/examples/__init__.py b/docs/examples/__init__.py new file mode 100644 index 000000000..ed21df366 --- /dev/null +++ b/docs/examples/__init__.py @@ -0,0 +1,14 @@ +"""Runnable plugin examples that the extensions guide includes verbatim. + +Every module here is a real, importable plugin component. The docs build imports +all of them and runs the conformance checks over them before rendering a single +page, and ``docs/python/extensions.rst`` pulls each file in with +``literalinclude``. So the code on the page is code that ran: an example that +stops working fails ``sphinx-build`` instead of quietly rotting. + +Importing a module here executes its ``@register_*`` decorator and mutates the +process-wide registries, which is why nothing imports this package implicitly. +The docs build goes through ``docs/_examples.py``, which rolls the registries +back afterwards, and ``tests/docs/test_examples.py`` does the import in a +subprocess so drevalpy's own registry-count assertions stay unaffected. +""" diff --git a/docs/examples/toy_block_predictor.py b/docs/examples/toy_block_predictor.py new file mode 100644 index 000000000..029812563 --- /dev/null +++ b/docs/examples/toy_block_predictor.py @@ -0,0 +1,82 @@ +"""Block predictor example: ridge on one named cell-line block. + +``BlockPredictor`` is the interface for predictors that must keep the sides (or +individual featurizer outputs) apart instead of flattening everything into one +matrix. ``required_cell_line_blocks`` names the blocks the stack has to supply; +composition rejects a recipe whose featurizers emit none of them. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np +from sklearn.linear_model import Ridge + +from drevalpy.plugin import ( + BlockPredictor, + FeatureFormat, + ModelInputBatch, + register_predictor, +) + +BLOCK = "gene_expression" + + +@register_predictor( + "toyBlockRidge", + description="Ridge regression on a named gene-expression block plus the drug features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class ToyBlockRidgePredictor(BlockPredictor): + """Read one named block instead of the flattened design matrix.""" + + required_cell_line_blocks: ClassVar[tuple[str, ...]] = (BLOCK,) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Create an untrained predictor. + + Args: + hyperparameters: Overrides merged onto the declared defaults. + """ + super().__init__(hyperparameters) + self._estimator: Ridge | None = None + + def _fit(self, batch: ModelInputBatch) -> None: + """Train on the named block, widened with the drug features when present.""" + self._estimator = Ridge(alpha=1.0) + self._estimator.fit(self._design_matrix(batch), batch.response) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Score the same design matrix ``_fit`` was trained on.""" + if self._estimator is None: + msg = "ToyBlockRidgePredictor must be fitted before predicting" + raise RuntimeError(msg) + return np.asarray(self._estimator.predict(self._design_matrix(batch)), dtype=np.float64) + + @staticmethod + def _design_matrix(batch: ModelInputBatch) -> np.ndarray: + """Expand the entity-level block to one row per pair. + + Blocks are indexed by entity, not by pair, so they have to be expanded + through ``cell_line_pair_idx`` before a pair-level model can use them. + """ + matrix = batch.cell_line_blocks[BLOCK].values[batch.cell_line_pair_idx] + if batch.drug_features is not None and batch.drug_pair_idx is not None: + matrix = np.hstack([matrix, batch.drug_features[batch.drug_pair_idx]]) + return matrix + + def get_state(self) -> dict[str, object]: + """Return the trained estimator for checkpoint persistence.""" + return {} if self._estimator is None else {"estimator": self._estimator} + + def set_state(self, state: dict[str, object]) -> None: + """Restore the state produced by ``get_state``.""" + estimator = state.get("estimator") + if estimator is not None: + self._estimator = estimator # type: ignore[assignment] + + def is_fitted(self) -> bool: + """Report whether ``fit`` has run.""" + return self._estimator is not None diff --git a/docs/examples/toy_cell_line_featurizer.py b/docs/examples/toy_cell_line_featurizer.py new file mode 100644 index 000000000..e74e0b73f --- /dev/null +++ b/docs/examples/toy_cell_line_featurizer.py @@ -0,0 +1,89 @@ +"""Cell-line featurizer example: standardize one raw omics view. + +Shows the three hooks every featurizer implements -- ``_fit``, +``_transform_blocks`` and the ``output_dim`` property -- plus the ``input_views`` +declaration, without which registration is rejected. +""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.plugin import ( + CellLineFeaturizer, + FeatureBlock, + FeatureFormat, + FeatureSource, + numeric_feature_block, + register_cell_line_featurizer, +) + + +@register_cell_line_featurizer( + "toyCellLine", + description="Gene expression standardized with statistics learned on the training cell lines.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class ToyCellLineFeaturizer(CellLineFeaturizer): + """Standardize the ``gene_expression`` view column by column.""" + + input_views: ClassVar[tuple[str, ...]] = ("gene_expression",) + + def __init__(self) -> None: + """Create an unfitted featurizer.""" + self._mean: np.ndarray | None = None + self._scale: np.ndarray | None = None + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> ToyCellLineFeaturizer: + """Learn one mean and one standard deviation per column.""" + _ = pair_expanded_ids, pair_expanded_es_ids + ids = entity_ids if entity_ids is not None else source.identifiers + matrix = source.get_view_matrix(self.input_views[0], ids) + self._mean = np.asarray(matrix.mean(axis=0), dtype=np.float64) + scale = np.asarray(matrix.std(axis=0), dtype=np.float64) + self._scale = np.where(scale > 0.0, scale, 1.0) + return self + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Return the standardized view as one named numeric block. + + The block name matters: a ``BlockPredictor`` asks for it by name through + ``required_cell_line_blocks``. Naming it after the input view is the + default the registry assumes when a featurizer declares no + ``output_block_specs``. + """ + if self._mean is None or self._scale is None: + msg = "ToyCellLineFeaturizer must be fitted before transforming" + raise RuntimeError(msg) + matrix = source.get_view_matrix(self.input_views[0], entity_ids) + standardized = ((matrix - self._mean) / self._scale).astype(np.float32) + return {self.input_views[0]: numeric_feature_block(standardized)} + + @property + def output_dim(self) -> int: + """Number of feature columns produced after fitting.""" + return 0 if self._mean is None else int(self._mean.shape[0]) + + def get_state(self) -> dict[str, object]: + """Return the fitted statistics so a checkpoint can round-trip them.""" + if self._mean is None or self._scale is None: + return {} + return {"mean": self._mean.tolist(), "scale": self._scale.tolist()} + + def set_state(self, state: dict[str, object]) -> None: + """Restore the statistics a previous ``get_state`` returned.""" + mean = state.get("mean") + scale = state.get("scale") + if mean is None or scale is None: + return + self._mean = np.asarray(mean, dtype=np.float64) + self._scale = np.asarray(scale, dtype=np.float64) diff --git a/docs/examples/toy_conformance.py b/docs/examples/toy_conformance.py new file mode 100644 index 000000000..14653bb72 --- /dev/null +++ b/docs/examples/toy_conformance.py @@ -0,0 +1,86 @@ +"""Testing example: run drevalpy's conformance checks over the toy components. + +``drevalpy.testing`` ships the fixtures and checks a plugin's own suite needs, so +no plugin has to hand-roll a dataset or re-derive what "conforms" means. This +module is what the docs build runs to prove the examples on the extensions page +still work. +""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.plugin import ModelResult, RunResult +from drevalpy.registry import splitter +from drevalpy.testing import ( + FEATURIZER_CHECKS, + PREDICTOR_CHECKS, + build_synthetic_batch, + build_synthetic_dataset, +) + +from .toy_block_predictor import BLOCK, ToyBlockRidgePredictor +from .toy_cell_line_featurizer import ToyCellLineFeaturizer +from .toy_drug_featurizer import ToyDrugHashFeaturizer +from .toy_mean_predictor import ToyMeanPredictor +from .toy_ridge_predictor import ToyRidgePredictor +from .toy_visualization import ToyResidualHistogram + +FEATURIZERS = (ToyCellLineFeaturizer, ToyDrugHashFeaturizer) +PREDICTORS = (ToyMeanPredictor, ToyRidgePredictor, ToyBlockRidgePredictor) + + +def check_components() -> None: + """Run every shipped check against every toy featurizer and predictor. + + ``build_synthetic_dataset`` returns a response-only dataset by default; the + ``omics`` argument adds the cell-line modality ``ToyCellLineFeaturizer`` + reads. ``build_synthetic_batch`` skips featurization entirely and draws the + feature matrices, which is what makes a predictor testable on its own. + """ + dataset = build_synthetic_dataset(omics=["gene_expression"]) + batch = build_synthetic_batch(dataset, cell_line_block_names=(BLOCK,)) + for check in FEATURIZER_CHECKS: + for featurizer in FEATURIZERS: + check(featurizer, dataset) + for check in PREDICTOR_CHECKS: + for predictor in PREDICTORS: + check(predictor, batch) + + +def check_splitter() -> None: + """Run the registered splitter and let the registry validate its folds. + + Resolving the mode through the registry rather than calling the function + directly is what matters: the registry wrapped it in the ``LCO`` leakage + check, so a splitter that leaked would raise here. + """ + dataset = build_synthetic_dataset() + folds = splitter.get("TOY_LCO")(dataset, n_splits=3) + if len(folds) != 3: + msg = f"TOY_LCO produced {len(folds)} folds, expected 3" + raise AssertionError(msg) + + +def check_visualization() -> None: + """Drive the toy visualization through the contract its base class promises. + + There is no conformance check for visualizations yet, so a plot is exercised + by hand: ``compute`` first, then the renderers, which raise until it has run. + """ + rng = np.random.default_rng(0) + run = RunResult( + model_name="toyRidge", + dataset_name="SYNTHETIC", + fold_index=0, + predictions=rng.normal(size=32), + ground_truth=rng.normal(size=32), + cell_line_ids=np.array([f"CVCL_S{index:03d}" for index in range(32)]), + drug_ids=np.array(["100000"] * 32), + ) + plot = ToyResidualHistogram() + plot.compute(ModelResult(model_name="toyRidge", dataset_name="SYNTHETIC", runs=[run])) + sections = plot.to_multiqc() + if not sections: + msg = "ToyResidualHistogram.to_multiqc returned no sections" + raise AssertionError(msg) diff --git a/docs/examples/toy_drug_featurizer.py b/docs/examples/toy_drug_featurizer.py new file mode 100644 index 000000000..496822f31 --- /dev/null +++ b/docs/examples/toy_drug_featurizer.py @@ -0,0 +1,69 @@ +"""Drug featurizer example: features derived from the identifier alone. + +Shows the two declarations the cell-line example does not: a ``contract`` on the +class body rather than in the decorator, and ``entity_id_only``, which is how a +featurizer states that it reads no raw feature view. Leaving both +``entity_id_only`` and ``input_views`` unset makes registration raise. +""" + +from __future__ import annotations + +import hashlib +from typing import ClassVar + +import numpy as np + +from drevalpy.plugin import ( + DrugFeaturizer, + FeatureBlock, + FeatureFormat, + FeatureSource, + numeric_feature_block, + register_drug_featurizer, +) + +N_COLUMNS = 8 + + +@register_drug_featurizer( + "toyDrugHash", + description="Deterministic pseudo-random drug features hashed from the drug identifier.", +) +class ToyDrugHashFeaturizer(DrugFeaturizer): + """Hash each drug id into a fixed-width vector. + + A stand-in for a real embedding: it reads no feature view, so it works + against any dataset, including a response-only one. + """ + + contract = FeatureFormat.NUMERIC_MATRIX + entity_id_only: ClassVar[bool] = True + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> ToyDrugHashFeaturizer: + """Do nothing: hashing needs no statistics from the training drugs.""" + _ = source, entity_ids, pair_expanded_ids, pair_expanded_es_ids + return self + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Return one row of hashed bytes per drug.""" + _ = source + rows = [self._hash_row(str(entity_id)) for entity_id in entity_ids] + values = np.vstack(rows) if rows else np.empty((0, N_COLUMNS), dtype=np.float32) + return {"toy_drug_hash": numeric_feature_block(values)} + + @staticmethod + def _hash_row(entity_id: str) -> np.ndarray: + digest = hashlib.sha256(entity_id.encode()).digest()[:N_COLUMNS] + return np.frombuffer(digest, dtype=np.uint8).astype(np.float32) / 255.0 + + @property + def output_dim(self) -> int: + """Fixed width, known before fitting.""" + return N_COLUMNS diff --git a/docs/examples/toy_mean_predictor.py b/docs/examples/toy_mean_predictor.py new file mode 100644 index 000000000..10664279a --- /dev/null +++ b/docs/examples/toy_mean_predictor.py @@ -0,0 +1,67 @@ +"""Feature-free predictor example: predict the training mean. + +``FeatureFreePredictor`` sees pair identifiers and responses only. Composition +forbids pairing it with cell-line or drug featurizers, but registration still +wants both contracts, because the composition checker compares them before it +knows the interface. +""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.plugin import ( + FeatureFormat, + FeatureFreePredictor, + ModelInputBatch, + register_predictor, +) + + +@register_predictor( + "toyMean", + description="Predict the mean training response for every pair.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class ToyMeanPredictor(FeatureFreePredictor): + """The simplest possible baseline.""" + + def __init__(self, hyperparameters: dict[str, object] | None = None) -> None: + """Create an untrained predictor. + + Args: + hyperparameters: Overrides merged onto the declared defaults. This + predictor has none, but the signature is part of the interface. + """ + super().__init__(hyperparameters) + self._mean: float | None = None + + def _fit(self, batch: ModelInputBatch) -> None: + """Store the mean response. + + ``Predictor.fit`` has already rejected a batch without responses and + dropped pairs whose features are NaN, so ``_fit`` never has to. + """ + self._mean = float(np.mean(batch.response)) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Return the stored mean once per pair.""" + if self._mean is None: + msg = "ToyMeanPredictor must be fitted before predicting" + raise RuntimeError(msg) + return np.full(batch.n_pairs, self._mean, dtype=np.float64) + + def get_state(self) -> dict[str, object]: + """Return the fitted mean for checkpoint persistence.""" + return {} if self._mean is None else {"mean": self._mean} + + def set_state(self, state: dict[str, object]) -> None: + """Restore the fitted mean produced by ``get_state``.""" + mean = state.get("mean") + if mean is not None: + self._mean = float(mean) # type: ignore[arg-type] + + def is_fitted(self) -> bool: + """Report whether ``fit`` has run.""" + return self._mean is not None diff --git a/docs/examples/toy_ridge_predictor.py b/docs/examples/toy_ridge_predictor.py new file mode 100644 index 000000000..efc421c74 --- /dev/null +++ b/docs/examples/toy_ridge_predictor.py @@ -0,0 +1,98 @@ +"""Matrix predictor example: ridge regression on the flattened batch. + +``MatrixPredictor`` implements ``_fit``/``_predict`` for you by calling +``batch.to_feature_matrix()``, so a subclass only sees a dense pair-level design +matrix through ``_fit_matrix``/``_predict_matrix``. Both contracts must be +``numeric_matrix``; registration rejects anything else for this interface. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from sklearn.linear_model import Ridge + +from drevalpy.plugin import ( + FeatureFormat, + LiteratureReference, + MatrixPredictor, + register_predictor, +) + +#: Optional provenance metadata. It documents where a ported component came from +#: and changes nothing about training, composition or checkpoints. +TOY_RIDGE_REFERENCE = LiteratureReference( + repo_url="https://github.com/daisybio/drevalpy", + citation_text="Ridge baseline written for the DrEvalPy extension guide.", + deviations="Illustrative only; not a port of any published model.", +) + + +@register_predictor( + "toyRidge", + description="Ridge regression on concatenated dense cell-line and drug features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + reference=TOY_RIDGE_REFERENCE, +) +class ToyRidgePredictor(MatrixPredictor): + """Wrap :class:`sklearn.linear_model.Ridge`.""" + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Create an untrained predictor. + + Args: + hyperparameters: Overrides merged onto the declared defaults. + """ + super().__init__(hyperparameters) + self._estimator: Ridge | None = None + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Declare the search space HPO samples from. + + Every entry needs a ``default``, which is what the predictor uses when + nothing is tuned; registration rejects a space that omits one. + """ + return { + "alpha": { + "type": "float", + "low": 1e-4, + "high": 10.0, + "log": True, + "default": 1.0, + }, + } + + def _fit_matrix(self, x: np.ndarray, y: np.ndarray) -> None: + """Train on the dense pair-level design matrix.""" + self._estimator = Ridge(alpha=float(self._hyperparameters["alpha"])) + self._estimator.fit(x, y) + + def _predict_matrix(self, x: np.ndarray) -> np.ndarray: + """Score the dense pair-level design matrix.""" + if self._estimator is None: + msg = "ToyRidgePredictor must be fitted before predicting" + raise RuntimeError(msg) + return np.asarray(self._estimator.predict(x), dtype=np.float64) + + def get_state(self) -> dict[str, object]: + """Return the trained estimator and the hyperparameters it was built with.""" + if self._estimator is None: + return {} + return {"estimator": self._estimator, "hyperparameters": dict(self._hyperparameters)} + + def set_state(self, state: dict[str, object]) -> None: + """Restore the state produced by ``get_state``.""" + estimator = state.get("estimator") + if estimator is None: + return + self._estimator = estimator # type: ignore[assignment] + hyperparameters = state.get("hyperparameters") + if isinstance(hyperparameters, dict): + self._hyperparameters = dict(hyperparameters) + + def is_fitted(self) -> bool: + """Report whether ``fit`` has run.""" + return self._estimator is not None diff --git a/docs/examples/toy_splitter.py b/docs/examples/toy_splitter.py new file mode 100644 index 000000000..11b3d681f --- /dev/null +++ b/docs/examples/toy_splitter.py @@ -0,0 +1,77 @@ +"""Splitter example: a leave-cell-line-out variant with a custom fold count. + +A splitter is a plain function, not a class. ``validation=`` picks the leakage +constraint the registry enforces after every call, so a splitter that leaks a +cell line into both train and test raises ``SplitValidationError`` rather than +silently producing an optimistic score. + +It also shows the curve-quality step every built-in splitter performs: +:func:`~drevalpy.plugin.curve_quality_mask` marks the pairs whose fitted +dose-response curve is trustworthy, and blanking the rest keeps them out of +every fold. +""" + +from __future__ import annotations + +import numpy as np +from sklearn.model_selection import KFold + +from drevalpy.plugin import MuDataLike, SplitMask, SplitMasks, curve_quality_mask, register_splitter + + +@register_splitter( + "TOY_LCO", + "Leave-Cell-Line-Out with a fixed 80/20 train/validation carve-out", + validation="LCO", +) +def toy_leave_cell_line_out( + mudataset: MuDataLike, + n_splits: int = 5, + validation_ratio: float = 0.1, + random_state: int = 42, +) -> list[SplitMasks]: + """Split so that every cell line is tested exactly once. + + Args: + mudataset: Dataset whose response matrix is split. + n_splits: Number of folds. + validation_ratio: Ignored; this splitter always holds out a fifth of the + training cell lines, which is what makes it worth registering + separately from the built-in ``LCO``. + random_state: Seed for the fold assignment. + + Returns: + One :class:`~drevalpy.plugin.SplitMasks` per fold, each carrying + train/test/validation masks shaped like the response matrix. + """ + _ = validation_ratio + # Blank the pairs whose dose-response fit fails the quality thresholds, so + # the folds only ever contain curves worth training on. The two default + # thresholds are the ones the built-in splitters use; ``min_r2`` is an extra + # this splitter opts into. + response = mudataset.response_matrix.copy() + response[~curve_quality_mask(mudataset, min_r2=0.5)] = np.nan + observed = ~np.isnan(response) + folds: list[SplitMasks] = [] + rng = np.random.default_rng(random_state) + + for train_rows, test_rows in KFold(n_splits=n_splits, shuffle=True, random_state=random_state).split( + np.arange(observed.shape[0]) + ): + shuffled = rng.permutation(train_rows) + n_validation = max(1, len(shuffled) // 5) + folds.append( + SplitMasks( + train=SplitMask(_rows_mask(observed, shuffled[n_validation:])), + test=SplitMask(_rows_mask(observed, test_rows)), + val=SplitMask(_rows_mask(observed, shuffled[:n_validation])), + ) + ) + return folds + + +def _rows_mask(observed: np.ndarray, rows: np.ndarray) -> np.ndarray: + """Keep the measured pairs of *rows* and blank everything else.""" + mask = np.zeros_like(observed) + mask[rows, :] = observed[rows, :] + return mask diff --git a/docs/examples/toy_visualization.py b/docs/examples/toy_visualization.py new file mode 100644 index 000000000..063aa6056 --- /dev/null +++ b/docs/examples/toy_visualization.py @@ -0,0 +1,63 @@ +"""Visualization example: a residual histogram for one model. + +``Visualization`` declares four abstract methods -- ``compute``, ``to_png``, +``to_multiqc`` and ``show``. ``ImageVisualization`` implements the last three on +top of a Matplotlib figure, so a static plot only has to supply ``compute`` (which +must leave the figure in ``self._fig``) and ``_create_figure``. +""" + +from __future__ import annotations + +import matplotlib.figure +import matplotlib.pyplot as plt +import numpy as np + +from drevalpy.plugin import ( + Dataset, + ImageVisualization, + ModelResult, + register_visualization, +) + + +@register_visualization( + "toyResiduals", + "Histogram of prediction residuals pooled across a model's folds.", + result_type="ModelResult", + requirements=frozenset(), +) +class ToyResidualHistogram(ImageVisualization): + """Pool every fold's residuals and draw one histogram.""" + + def __init__(self) -> None: + """Create an empty visualization.""" + self._residuals = np.empty(0, dtype=np.float64) + self._title = "" + + def compute(self, result: ModelResult, dataset: Dataset | None = None) -> None: + """Pool the residuals and build the figure. + + ``compute`` must assign ``self._fig``; the inherited ``to_png``, + ``to_multiqc`` and ``show`` all raise until it does. + + Args: + result: The model whose folds are pooled. + dataset: Unused here. It is offered so a plot can look up cell-line + or drug metadata that the result itself does not carry. + """ + _ = dataset + residuals = [np.asarray(run.predictions) - np.asarray(run.ground_truth) for run in result.runs] + pooled = np.concatenate(residuals) if residuals else np.empty(0) + self._residuals = pooled[~np.isnan(pooled)] + self._title = f"{result.model_name} residuals ({len(result.runs)} folds)" + self._fig = self._create_figure() + + def _create_figure(self) -> matplotlib.figure.Figure: + """Draw the histogram.""" + figure, axes = plt.subplots(figsize=(6, 4)) + axes.hist(self._residuals, bins=20) + axes.set_xlabel("predicted - observed") + axes.set_ylabel("pairs") + axes.set_title(self._title) + figure.tight_layout() + return figure diff --git a/docs/getting_started/installation.rst b/docs/getting_started/installation.rst new file mode 100644 index 000000000..934ad34c5 --- /dev/null +++ b/docs/getting_started/installation.rst @@ -0,0 +1,127 @@ +.. highlight:: shell + +Installation +============ + +DrEvalPy can be installed on all three major platforms (Linux, MacOS, Windows). +If something goes wrong, feel free to open an issue on `GitHub `_. + +With pip +-------- + +DrEvalPy requires Python >=3.11 and is available on PyPI: + +.. code-block:: bash + + pip install drevalpy + +With Conda +---------- + +DrEvalPy requires python >=3.11. Best practice is to use a clean +(`mamba `_) or +conda environment (`Miniconda `_). Mamba is automatically installed +when downloading (`Miniforge `_) and is generally faster and better at +resolving dependencies. +Follow the installation guide for your operating system, then create a new environment using + +.. code-block:: bash + + mamba create -y -n drevalpy python=3.13 + +Activate your conda environment and install the package using + +.. code-block:: bash + + mamba activate drevalpy + pip install drevalpy + +With venv +--------- + +DrEvalPy can also be installed using the built-in `venv` module. First, create a new environment and activate it: + +.. code-block:: bash + + python -m venv drevalpy-env + source drevalpy-env/bin/activate + +Then, install the package using pip: + +.. code-block:: bash + + pip install drevalpy + +With Docker +----------- + +DrEvalPy is available as a `Docker image `_. + +Pull the image: + +.. code-block:: bash + + docker pull ghcr.io/daisybio/drevalpy:latest + +Run the image: + +.. code-block:: bash + + docker run -it ghcr.io/daisybio/drevalpy:latest + +From Source +----------- + +To install DrEvalPy from source, clone the repository and let +`uv `_ create the environment. ``uv sync`` installs +the project together with the locked ``dev`` dependency group: + +.. code-block:: bash + + git clone https://github.com/daisybio/drevalpy.git + cd drevalpy + uv sync + +Verify the console script: + +.. code-block:: bash + + uv run drevalpy --help + +Then :doc:`choose the CLI or Python API ` +for your first experiment. Built-in datasets download on first use into a +system cache directory (``platformdirs.user_cache_dir("drevalpy")``, for +example ``~/.cache/drevalpy`` on Linux or ``~/Library/Caches/drevalpy`` on +macOS). Set the ``DREVALPY_CACHE_DIR`` environment variable to use a +different location. Predictions and reports go under the output directory you +pass to the command (``--output-dir`` / ``-o``, default ``results`` for +``drevalpy run`` and ``report`` for ``drevalpy report``). + +Pre-trained model artifacts (ChemBERTa weights, MolGNet checkpoint, PPI +embeddings, ...) are fetched on first use from an object-storage location and +cached under ``/artifacts``. Two environment variables control this: + +``DREVALPY_ARTIFACTS_URI`` + Base URI to fetch artifacts from. Any fsspec-supported protocol works, so + this can point at a mirror bucket or a local directory (useful for offline + or air-gapped runs). + +``DREVALPY_ARTIFACTS_STORAGE_OPTIONS`` + JSON object passed to fsspec, for example ``{"profile": "my-aws-profile"}`` + or ``{"anon": true}``. Unset by default, so the ambient credential chain + (environment variables, shared AWS config, instance roles) applies. + +Hyperparameter tuning on Windows +-------------------------------- + +Experiment-time HPO depends on `Ray `_. +Unfortunately, Ray only publishes Windows wheels for Python 3.10-3.12. +This means, if you are using Windows and a Python version outside of that range, the following will happen: + +1. Installation of DrEvalPy will succeed, but ``ray`` won't be installed. +2. Running a workflow with hyperparameter tuning (which is enabled by default) will fail with ``ImportError: Ray Tune with Optuna requires ray[tune] and optuna to be installed``. +3. Running a workflow without hyperparameter tuning (``--no-hpo``) will succeed + +To run HPO from Windows make sure to use a supported Python version (3.10-3.12), use +`WSL `_, or the +:ref:`Docker image ` above. diff --git a/docs/getting_started/run_first_experiment.rst b/docs/getting_started/run_first_experiment.rst new file mode 100644 index 000000000..b9c84d779 --- /dev/null +++ b/docs/getting_started/run_first_experiment.rst @@ -0,0 +1,13 @@ +Run your first experiment +========================= + +DrEvalPy exposes the same experiment workflow through three interfaces: + +- Use the :doc:`CLI quickstart ` to run an experiment from + the terminal. +- Use the :doc:`Python quickstart ` to load data, construct + models, and run an experiment in Python. +- Use the `nf-core/drugresponseeval pipeline `__ to run experiments as a nextflow workflow. + +Feel free to choose the interface that fits your workflow and environment best; all three use the same datasets, +models, evaluation logic, and result layout. diff --git a/docs/index.rst b/docs/index.rst index 99cf3cfd8..7b506ea6a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,26 +4,79 @@ .. role:: smaller -.. include:: quickstart.rst +DrEvalPy documentation is organized into shared **concepts** plus separate **Python** +and **CLI** tracks. Both interfaces run the same evaluation pipeline; only +the wiring differs. -.. include:: news.rst +Suggested path +-------------- -.. include:: _key_contributors.rst +1. :doc:`getting_started/installation` — Python version, pip/Conda/Docker, and + verify with ``drevalpy --help``. +2. :doc:`getting_started/run_first_experiment` — choose the CLI or Python API + track for your first run. +3. Read :doc:`concepts/datasets` and :doc:`concepts/evaluation` when you change + datasets, split modes (LPO/LCO/LTO/LDO), or metrics. +4. :doc:`concepts/component_catalog` then + :doc:`concepts/from_components_to_models` and :doc:`concepts/model_zoo` — + registered atoms, how they compose, and named zoo presets. +5. Go deeper on your track. For Python: :doc:`python/datasets` → + :doc:`python/models` → :doc:`python/extensions` → + :doc:`python/experiments` (then visualization). For CLI: :doc:`cli/experiments`. + +For demanding or highly reproducible runs, use the Nextflow pipeline +`nf-core/drugresponseeval `_. + +.. toctree:: + :maxdepth: 1 + :caption: Getting started + + getting_started/installation + getting_started/run_first_experiment + +.. toctree:: + :maxdepth: 1 + :caption: Concepts + + concepts/datasets + concepts/evaluation + concepts/component_catalog + concepts/from_components_to_models + concepts/model_zoo + concepts/registries .. toctree:: - :hidden: :maxdepth: 2 - :caption: Contents: - - installation - usage - runyourmodel - example_flexible_inputs - example_wandb - contributing - reference - memes - API + :caption: Python guide -.. _github: https://github.com/daisybio/drevalpy + python/quickstart + python/datasets + python/models + python/extensions + python/experiments + python/visualization + python/api/index +.. toctree:: + :maxdepth: 1 + :caption: CLI guide + + cli/quickstart + cli/datasets + cli/models + cli/experiments + cli/visualization + cli/extensions + cli/reference + +.. toctree:: + :maxdepth: 1 + :caption: Project + + project/contributing + project/citing + project/news + project/contributors + project/memes + +.. _github: https://github.com/daisybio/drevalpy diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index 5fc8ba93e..000000000 --- a/docs/installation.rst +++ /dev/null @@ -1,121 +0,0 @@ -.. highlight:: shell - -Installation -============ - -DrEvalPy can be installed on all three major platforms (Linux, MacOS, Windows). -If something goes wrong, feel free to open an issue on `GitHub `_. - -With Conda ----------- - -DrEvalPy requires python >=3.11. Best practice is to use a clean -(`mamba `_) or -conda environment (`Miniconda `_). Mamba is automatically installed -when downloading (`Miniforge `_) and is generally faster and better at -resolving dependencies. -Follow the installation guide for your operating system, then create a new environment using - -.. code-block:: bash - - mamba create -y -n drevalpy python=3.13 - -DrEvalPy is listed on the Python Package Index (PyPI) and can be installed with pip. -Activate your conda environment (or skip this if you use a system wide python installation) -and install the package using - -.. code-block:: bash - - mamba activate drevalpy - pip install drevalpy - -With venv ---------- - -DrEvalPy can also be installed using the built-in `venv` module. First, create a new environment and activate it: - -.. code-block:: bash - - python -m venv drevalpy-env - source drevalpy-env/bin/activate - -Then, install the package using pip: - -.. code-block:: bash - - pip install drevalpy - - -Optional Features (Extras) --------------------------- - -Some models and features require additional dependencies that are **not** installed by the -default ``pip install drevalpy``. They are provided as optional `extras`: - -.. list-table:: - :header-rows: 1 - :widths: 20 25 55 - - * - Extra - - Enables - - Extra dependencies - * - ``precily`` - - The ``Precily`` model (GSVA pathway features) - - ``gseapy`` - * - ``xgboost`` - - The ``MultiViewXGBoost`` baseline model - - ``xgboost`` - * - ``multiprocessing`` - - Parallelized cross-validation / tuning via Ray - - ``ray`` (and ``pydantic``, usually already present) - -Install one or more extras by listing them in square brackets, for example: - -.. code-block:: bash - - pip install "drevalpy[precily]" - pip install "drevalpy[precily,xgboost,multiprocessing]" - -If you install from source with Poetry, install the extras with ``-E`` (or use -``--all-extras`` to install all of them): - -.. code-block:: bash - - poetry install -E precily -E xgboost -E multiprocessing - # or, equivalently - poetry install --all-extras - - -With Docker ------------ - -DrEvalPy is available as a `Docker image `_. - -Pull the image: - -.. code-block:: bash - - docker pull ghcr.io/daisybio/drevalpy:latest - -Run the image: - -.. code-block:: bash - - docker run -it ghcr.io/daisybio/drevalpy:latest - -From Source ------------ - -To install DrEvalPy from source, clone the repository and install the package using Poetry -(ensure that Poetry is >=1.2.0 because otherwise, the group dependencies will not work, e.g., 2.4.1 works): - -.. code-block:: bash - - git clone https://github.com/daisybio/drevalpy.git - cd drevalpy - mamba create -y -n drevalpy python==3.13 poetry==2.4.1 - poetry --version - pip install poetry-plugin-export - poetry install - -Now, you can test the functionality quickly via `drevalpy --help`. Or take a look at the `Quickstart <./quickstart.html>`_ documentation. diff --git a/docs/reference.rst b/docs/project/citing.rst similarity index 95% rename from docs/reference.rst rename to docs/project/citing.rst index 29a2d1a41..fe832b855 100644 --- a/docs/reference.rst +++ b/docs/project/citing.rst @@ -1,5 +1,5 @@ -How to cite -=========== +Citing DrEvalPy +=============== If you want to cite DrEvalPy or nf-core/drugresponseeval in your work, please use the following: @@ -20,4 +20,4 @@ If you want to cite DrEvalPy or nf-core/drugresponseeval in your work, please us Bernett, J., Iversen, P., Picciani, M., Wilhelm, M., Baum, K., & List, M. (2026). Critical evaluation of drug response prediction models with DrEval. Nature Communications, 17(1), 4238. `Link to the paper`_. -.. _Link to the paper: https://www.nature.com/articles/s41467-026-72903-w \ No newline at end of file +.. _Link to the paper: https://www.nature.com/articles/s41467-026-72903-w diff --git a/docs/project/contributing.rst b/docs/project/contributing.rst new file mode 100644 index 000000000..4099c708b --- /dev/null +++ b/docs/project/contributing.rst @@ -0,0 +1,155 @@ +Contributor Guide +================= + +Thank you for your interest in improving this project. +This project is open-source under the `GPL-3.0 license`_ and +highly welcomes contributions in the form of bug reports, feature requests, and pull requests. + +Here is a list of important resources for contributors: + +- `Source Code`_ +- `Documentation`_ +- `Issue Tracker`_ + +.. _GPL-3.0 license: https://www.gnu.org/licenses/gpl-3.0.html +.. _Source Code: https://github.com/daisybio/drevalpy +.. _Documentation: https://drevalpy.readthedocs.io/ +.. _Issue Tracker: https://github.com/daisybio/drevalpy/issues + +How to report a bug +------------------- + +Report bugs on the `Issue Tracker`_. + + +How to request a feature +------------------------ + +Request features on the `Issue Tracker`_. + + +How to set up your development environment +------------------------------------------ + +1. Fork the repository on GitHub. +2. Install `uv `_, which manages both the Python + interpreter and the dependencies. +3. ``uv sync`` : this creates the virtual environment and installs the project + with its ``dev`` dependency group. The ``docs`` group is pulled in on demand + with ``--group docs``. +4. Test whether the installation was successful by running a small experiment: + + .. code:: console + + $ uv run drevalpy run NaiveDrugMeanPredictor ElasticNet --dataset GDSC1 --split-mode LCO --no-hpo + +5. Visualize the results by running the following command: + + .. code:: console + + $ uv run drevalpy report results/ --output-dir report + + Then open ``report/multiqc_report.html`` in your browser. + +How to test the project +----------------------- + +Unit tests are located in the ``tests`` directory, +and are written using the pytest_ testing framework. + +The suite is tiered. The bare command runs the **fast tier** — 3844 tests plus 7 +skipped, about 98% of the suite, in roughly 14 seconds — which is also what the +commit hook runs: + +.. code:: console + + $ uv run pytest + +The remaining tests carry markers and are deselected by default: + +``slow`` + The extended tier: tests that spawn a fresh interpreter, fit dose-response + curves, or train models. A test belongs here if it costs 0.2s or more. 65 + tests, about 27 seconds. + +``network`` + Tests that download pretrained weights or remote annotations from the + artifacts bucket, which is not readable without credentials. These run on a + weekly schedule in CI. 8 tests. + +A ``-m`` on the command line replaces the default marker expression rather than +adding to it, so run the extended tier — or both tiers, which is what CI does on +every pull request — like this: + +.. code:: console + + $ uv run pytest -m slow + $ uv run pytest -m "not network" + +The full run is 3909 passed and 7 skipped. + +Coverage is measured and enforced in CI on the full ``-m "not network"`` run, +both as an aggregate floor (currently 91% against a floor of 89) and per module +across 276 modules. The commit hook does not measure it, because the floors are +only meaningful on the full suite. To reproduce the CI check locally: + +.. code:: console + + $ uv run pytest -m "not network" --cov --cov-report=json --cov-report=term-missing + $ uv run python tools/coverage_gate.py + +Run the suite serially. ``pytest-xdist`` is deliberately not a dependency: it was +measured at best ~11% faster on the full coverage run and 9–70% *slower* on the +fast tier, because a cheap ``import drevalpy`` (0.21s, down from 3.59s) left +parallelism nothing to hide. More importantly, ``--dist loadfile``, +``--dist loadscope`` and random ordering are **not** safe yet: the component +registries are process-global, so a test that registers a component without +evicting it again changes what a later test sees. If you introduce parallelism or +shuffling, audit every test that loads an extension or registers a component for +cleanup first. + +.. _pytest: https://pytest.readthedocs.io/ + +How to submit changes +--------------------- + +Open a `pull request`_ to submit changes to this project against the ``development`` branch. + +Your pull request needs to meet the following guidelines for acceptance: + +- The code must pass all tests. +- Include unit tests. This project maintains a high code coverage. +- If your changes add functionality, update the documentation accordingly. + +Linting and formatting run through `prek `_, a +drop-in replacement for pre-commit. Run every hook against the whole tree — all +ten take about 22 seconds, most of it the fast test tier: + +.. code:: console + + $ uv run prek run --all-files + +To run the same checks automatically on each commit, install the Git hook: + +.. code:: console + + $ uv run prek install + +It is recommended to open an issue before starting work on anything. + +.. _pull request: https://github.com/daisybio/drevalpy/pulls + +How to build and view the documentation +--------------------------------------- + +This project uses Sphinx_ together with several extensions to build the documentation. +Build it from the repository root with warnings treated as errors, exactly as CI does: + +.. code:: console + + $ uv run --group docs sphinx-build -W docs docs/_build + +The generated static HTML files can be found in ``docs/_build``. +Simply open them with your favorite browser. + +.. _sphinx: https://www.sphinx-doc.org/en/master/ diff --git a/docs/_key_contributors.rst b/docs/project/contributors.rst similarity index 97% rename from docs/_key_contributors.rst rename to docs/project/contributors.rst index c7fa1aa97..56628b3e9 100644 --- a/docs/_key_contributors.rst +++ b/docs/project/contributors.rst @@ -1,3 +1,6 @@ +Key contributors +================ + .. container:: contributors **Maintainers and Lead Contributors** @@ -20,4 +23,3 @@ * `Markus List `_: Advisor and PI of Data Science in Systems Biology, TUM * `Katharina Baum `_: Advisor and PI of Data Integration in the Life Sciences, FU Berlin * `Mathias Wilhelm `_: Advisor and PI of Computational Mass Spectrometry, TUM - diff --git a/docs/memes.rst b/docs/project/memes.rst similarity index 65% rename from docs/memes.rst rename to docs/project/memes.rst index 70e02e1db..07318c41e 100644 --- a/docs/memes.rst +++ b/docs/project/memes.rst @@ -1,12 +1,12 @@ Memes and fun stuff -========================= +=================== Don't forget to have a little fun with science! Here are some memes and fun stuff related to the project. The drug response iceberg --------------------------------- -.. image:: _static/memes/iceberg.png +------------------------- +.. image:: ../_static/memes/iceberg.png :width: 400 px :align: center @@ -14,18 +14,18 @@ The drug response iceberg Some people have pointed out the phonetic similarity between DrEval and DrEvil. -------------------------------------------------------------------------------- -.. image:: _static/memes/r2.png +.. image:: ../_static/memes/r2.png :width: 400 px :align: center CI magic -------------------- -.. image:: _static/memes/ci_prettier.png +-------- +.. image:: ../_static/memes/ci_prettier.png :width: 400 px :align: center Garbage in, garbage out -------------------- -.. image:: _static/memes/isthisdrugresponse.jpeg +----------------------- +.. image:: ../_static/memes/isthisdrugresponse.jpeg :width: 400 px :align: center diff --git a/docs/news.rst b/docs/project/news.rst similarity index 100% rename from docs/news.rst rename to docs/project/news.rst diff --git a/docs/python/api/index.rst b/docs/python/api/index.rst new file mode 100644 index 000000000..311d9b6b6 --- /dev/null +++ b/docs/python/api/index.rst @@ -0,0 +1,91 @@ +Python API reference +==================== + +Guides first — this page is symbol lookup. Prefer the +:doc:`/python/quickstart` track map and the task pages above when learning +workflows; use the packages below when you need signatures and member lists. + +The root ``drevalpy`` package re-exports a few conveniences alongside +``__version__`` — ``load``, ``split``, ``run``, ``single``, +``construct_model``, ``randomization``, ``robustness``, and ``registry``. For +everything else, import from the subpackages in the table. + +.. list-table:: + :header-rows: 1 + :widths: 22 30 24 24 + + * - Package + - Purpose + - Python guide + - Concepts + * - ``drevalpy.data`` + - Load screens and custom response tables + - :doc:`/python/datasets` + - :doc:`/concepts/datasets` + * - ``drevalpy.types`` + - Shared enums and value objects (scopes, literature refs, …) + - :doc:`/python/models` + - :doc:`/concepts/from_components_to_models` + * - ``drevalpy.experiment`` + - ``randomization`` and ``robustness`` stress tests + - :doc:`/python/experiments` + - :doc:`/concepts/evaluation` + * - ``drevalpy.evaluation`` + - ``evaluate`` and metric helpers + - :doc:`/python/visualization` + - :doc:`/concepts/evaluation` + * - ``drevalpy.models`` + - ``construct_model``, ``ModelConfig``, zoo, save/load + - :doc:`/python/models` + - :doc:`/concepts/model_zoo` + * - ``drevalpy.components`` + - Featurizers, predictors, registries, tuning + - :doc:`/python/extensions` + - :doc:`/concepts/component_catalog` + * - ``drevalpy.plugin`` + - The single supported import surface for third-party plugins + - :doc:`/python/extensions` + - :doc:`/concepts/registries` + * - ``drevalpy.testing`` + - Synthetic fixtures and conformance checks for plugin test suites + - :doc:`/python/extensions` + - — + * - ``drevalpy.utils`` + - Shared helpers + - — + - — + * - ``drevalpy.visualization`` + - Plots and ``create_report`` + - :doc:`/python/visualization` + - :doc:`/concepts/evaluation` + +Submodules, classes, and functions are generated recursively from the package +tree. + +.. autosummary:: + :toctree: _autosummary + :caption: Packages + :recursive: + + drevalpy.data + drevalpy.types + drevalpy.experiment + drevalpy.evaluation + drevalpy.models + drevalpy.components + drevalpy.testing + drevalpy.utils + drevalpy.visualization + +``drevalpy.plugin`` defines nothing of its own — every name on it is an alias for +a symbol documented above, bar a few whose defining module is private and which +are therefore documented on the facade page itself. Its page is generated without +index entries, so a cross-reference to ``Dataset`` resolves to the class rather +than to one of two equally valid spellings of it. + +.. autosummary:: + :toctree: _autosummary + :recursive: + :template: facade.rst + + drevalpy.plugin diff --git a/docs/python/datasets.rst b/docs/python/datasets.rst new file mode 100644 index 000000000..016da4a7f --- /dev/null +++ b/docs/python/datasets.rst @@ -0,0 +1,95 @@ +Datasets +======== + +If you are reading this, we assume you are already familiar with these +concepts: + +- :doc:`/concepts/datasets` +- :doc:`/concepts/evaluation` + +This page explains how to load built-in and custom datasets, and how to split +them for training and evaluation. + +Built-in datasets +----------------- + +Built-in names are listed in the packaged registry. Use the dataset registry +to discover them and :func:`~drevalpy.data.load` to load: + +.. code-block:: python + + from drevalpy.data import load + + dataset = load("GDSC1") + +Built-in loaders download into the system cache directory on first use (see +:doc:`/getting_started/installation` for ``DREVALPY_CACHE_DIR``). ``X`` holds +``pEC50``; other measures are ``response.layers`` entries — see +:doc:`/concepts/datasets`. + +Custom .h5mu files +------------------ + +Point :func:`~drevalpy.data.load` at a ``.h5mu`` file path +directly: + +.. code-block:: python + + dataset = load("/path/to/MyStudy.h5mu") + +Any path that is not a recognized built-in name is treated as a file path. + +Splits +------ + +:func:`~drevalpy.run` splits the loaded dataset for you (``split_mode`` +of ``LPO``, ``LCO``, ``LTO``, or ``LDO``). You can also use +:func:`~drevalpy.data.split` yourself before a custom training loop: + +.. code-block:: python + + from drevalpy.data import load, split + + dataset = load("GDSC1") + folds = split(dataset, mode="LCO", n_splits=5) + +Each fold is a :class:`~drevalpy.types.SplitMasks` object containing boolean +masks for train, validation, and test sets. Pass a fold directly to +:func:`~drevalpy.single` for per-fold execution: + +.. code-block:: python + + from drevalpy.models import construct_model + from drevalpy import single + + ElasticNet = construct_model("ElasticNet") + result = single(ElasticNet, dataset, folds[0], hyperparameter_tuning=False) + +Curve quality +~~~~~~~~~~~~~ + +Every built-in splitter first drops the pairs whose fitted dose-response curve +fails ``relevance_score >= -log10(0.05)`` and ``abs(fold_change) >= 0.45``, so a +fold never contains a curve the fit itself calls meaningless. There is no knob +for this on :func:`~drevalpy.data.split`: a comparison between models is only +meaningful over the same pairs. + +To see how many pairs a mode dropped, compare the fold masks against the +measured pairs: + +.. code-block:: python + + import numpy as np + + measured = ~np.isnan(dataset.response_matrix) + used = np.zeros_like(measured) + for fold in folds: + used |= fold.train.mask | fold.test.mask | fold.val.mask + dropped = int((measured & ~used).sum()) + +See :ref:`curve-quality` for what the metrics mean, and +:func:`~drevalpy.plugin.curve_quality_mask` to filter on the others from a +custom splitter. + +Split semantics (leakage constraints per mode) are documented in +:doc:`/concepts/evaluation`. diff --git a/docs/python/experiments.rst b/docs/python/experiments.rst new file mode 100644 index 000000000..a4c90f5d6 --- /dev/null +++ b/docs/python/experiments.rst @@ -0,0 +1,277 @@ +Experiments and Hyperparameter Optimization +=========================================== + +If you are reading this, we assume you are already familiar with these +concepts: + +- :doc:`/concepts/evaluation` +- :doc:`/concepts/from_components_to_models` + +The experiment pipeline +----------------------- + +DrEvalPy provides two levels of experiment execution: + +- :func:`~drevalpy.run` — orchestrates models × folds × randomization in + one call. Returns an :class:`~drevalpy.types.results.ExperimentResult`. +- :func:`~drevalpy.single` — trains a single model on a single fold. + Returns a :class:`~drevalpy.types.results.RunResult`. Used when you need + per-fold control (or parallelism via the CLI). + +For day-to-day benchmarking, prefer ``run`` over a hand-rolled train/predict +loop on individual models (see :doc:`models` for the low-level lifecycle). + +Minimal call +------------ + +.. code-block:: python + + from drevalpy.data import load + from drevalpy.models import construct_model + from drevalpy import run + + dataset = load("GDSC1") + ElasticNet = construct_model("ElasticNet") + + result = run( + models=[ElasticNet], + dataset=dataset, + split_mode="LCO", + hyperparameter_tuning=False, + ) + +Pass model **classes** from :func:`~drevalpy.models.construct_model`, not +instances. The result is an +:class:`~drevalpy.types.results.ExperimentResult` containing all fold +predictions and metrics. + +``run`` parameters +------------------ + +.. list-table:: + :header-rows: 1 + :widths: 30 15 55 + + * - Parameter + - Default + - Description + * - ``models`` + - + - List of ``DRPModel`` subclasses to evaluate. + * - ``dataset`` + - + - ``Dataset`` object or name string (auto-loaded). + * - ``split_mode`` + - + - Split mode: ``"LPO"``, ``"LCO"``, ``"LDO"``, or ``"LTO"`` (see :doc:`/concepts/evaluation`). + * - ``hyperparameter_tuning`` + - ``True`` + - Whether to run HPO on each model. + * - ``hpo_metric`` + - ``"RMSE"`` + - Metric to optimize (also ``MSE``, ``MAE``, ``R^2``, ``Pearson``, ``Spearman``, ``Kendall``). + * - ``hpo_num_samples`` + - ``16`` + - Number of Optuna trials per fold. + * - ``hpo_random_state`` + - ``42`` + - Seed for the Optuna sampler. + * - ``randomization_modes`` + - ``None`` + - Optional list of feature-shuffle modes: ``"SVCC"``, ``"SVRC"``, ``"SVCD"``, ``"SVRD"``. + * - ``randomization_type`` + - ``"permutation"`` + - ``"permutation"`` or ``"invariant"``. + * - ``robustness_trials`` + - ``0`` + - Number of shuffled robustness repetitions (0 = disabled). + * - ``precomputed_only`` + - ``False`` + - Restrict HPO to pre-computed featurizer variants. + +Per-fold execution: ``single`` +------------------------------ + +For parallel or custom workflows, split first and execute folds individually: + +.. code-block:: python + + from drevalpy.data import load, split + from drevalpy.models import construct_model + from drevalpy import single + + dataset = load("GDSC1") + folds = split(dataset, mode="LCO", n_splits=5) + ElasticNet = construct_model("ElasticNet") + + results = [] + for fold in folds: + result = single( + ElasticNet, + dataset, + fold, + hyperparameter_tuning=True, + hpo_metric="RMSE", + hpo_num_samples=16, + ) + results.append(result) + +Aggregate into an :class:`~drevalpy.types.results.ExperimentResult`: + +.. code-block:: python + + from drevalpy.types.results import ExperimentResult + + experiment = ExperimentResult(results) + experiment.save("results/") + +``single`` additionally supports: + +- ``response_transformation`` — an unfitted sklearn ``TransformerMixin`` + (``StandardScaler``, ``MinMaxScaler``, ``RobustScaler``). A clone is fitted on + the fold's training scope only, the training targets are transformed, and + predictions are inverted before scoring, so metrics stay in the dataset's + original response units and the caller's instance is left unfitted. + +Hyperparameter tuning +--------------------- + +Tuning is **on by default**. When ``hyperparameter_tuning=True``, each model +with a search space is tuned with Ray Tune and Optuna before final fold +evaluation. Set ``hyperparameter_tuning=False`` to skip search and use each +model's ``get_default_hyperparameters()`` only. + +Ray and Optuna +~~~~~~~~~~~~~~ + +Ray and Optuna fulfill different roles in the hyperparameter tuning process: + +- **Ray Tune** runs and schedules trials (parallelism, resource allocation, + trial storage under the run directory). +- **Optuna** (via Ray's ``OptunaSearch``) chooses which hyperparameter values + to try next and optimizes ``hpo_metric``. + +.. note:: + + Ray is generally installed as a dependency of drevalpy. + However, Ray is only compatible with a limited set of Python versions on + Windows. See + :ref:`getting_started/installation:Hyperparameter tuning on Windows` + for more details. + +Search spaces +~~~~~~~~~~~~~ + +The hyperparameter search space of a model is fixed when you +:func:`~drevalpy.models.construct_model` the class. +Whether you can customize the space depends on how you construct the class: + +- **Recipe strings** always use each component's built-in search space; they + cannot express overrides. +- **Built-in zoo presets** ship as YAML inside the ``drevalpy`` package, so + you cannot override their search space from the call site. A preset may + still deviate from each component's built-in defaults when its YAML sets + ``hyperparameter_space``; otherwise it falls back to those defaults. +- **YAML** (via ``config.from_yaml``) and the **``ModelConfig`` + constructor** let you set ``hyperparameter_space`` on a component to + **replace** its built-in space. Use these when you need a custom search + space (including your own zoo-style YAML files). + +.. tab-set:: + + .. tab-item:: YAML + + .. code-block:: yaml + + cell_line_featurizer: + name: pca + view: expression + hyperparameter_space: + n_components: + type: int + low: 8 + high: 512 + default: 128 + drug_featurizer: fingerprints + predictor: + name: elasticNet + hyperparameter_space: + alpha: + type: float + low: 1.0e-4 + high: 10.0 + log: true + default: 1.0 + l1_ratio: + type: float + low: 0.0 + high: 1.0 + default: 0.5 + + .. code-block:: python + + from drevalpy.models import config, construct_model + + cfg = config.from_yaml("my_zoo/custom_en.yaml") + MyEN = construct_model("MyElasticNet", cfg) + + .. tab-item:: ModelConfig + + .. code-block:: python + + from drevalpy.models import config, construct_model + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="pca", + view="expression", + hyperparameter_space={ + "n_components": { + "type": "int", + "low": 8, + "high": 512, + "default": 128, + }, + }, + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig( + name="elasticNet", + hyperparameter_space={ + "alpha": { + "type": "float", + "low": 1e-4, + "high": 10.0, + "log": True, + "default": 1.0, + }, + "l1_ratio": { + "type": "float", + "low": 0.0, + "high": 1.0, + "default": 0.5, + }, + }, + ), + ) + MyEN = construct_model("MyElasticNet", cfg) + +Specs use local parameter names (``alpha``, ``n_components``, …). During +search, Ray Tune / Optuna see dotted qualified keys to prevent name +collisions, for example ``predictor.elasticNet.alpha`` and +``cell_line_featurizer.pca[expression].n_components``. See +:doc:`/concepts/from_components_to_models` for the full key rules and +:doc:`models` for how to construct classes from YAML or ``ModelConfig``. + +Randomization and robustness +----------------------------- + +Pass ``randomization_modes`` to ``run`` for feature-shuffle tests +(``"SVCC"``, ``"SVRC"``, ``"SVCD"``, ``"SVRD"``; ``None`` disables). +``randomization_type`` is ``"permutation"`` (default) or ``"invariant"``. +``robustness_trials`` repeats training with shuffled fold orderings; +``0`` (default) disables the robustness test. + +These extras apply to all ``models`` in the run. See +:doc:`/concepts/evaluation` for the interpretation of randomization and +robustness results. diff --git a/docs/python/extensions.rst b/docs/python/extensions.rst new file mode 100644 index 000000000..8772a6533 --- /dev/null +++ b/docs/python/extensions.rst @@ -0,0 +1,603 @@ +Extensions +========== + +If you are reading this, we assume you are already familiar with: + +- :doc:`models` — ``construct_model``, recipes, ``ModelConfig``, and lifecycle +- :doc:`/concepts/component_catalog` +- :doc:`/concepts/from_components_to_models` +- :doc:`/concepts/registries` + +Every extensible concept in DrEvalPy — predictors, featurizers, splitters, +datasets, and visualizations — is managed by a registry that maps +human-readable names to implementations. This page shows how to register +custom implementations for each extension point and make them available to the +pipeline. + +One import surface: ``drevalpy.plugin`` +--------------------------------------- + +Import everything you need from :mod:`drevalpy.plugin`. It re-exports every +base class, value type, and registration decorator a component needs: + +.. code-block:: python + + from drevalpy.plugin import CellLineFeaturizer, FeatureFormat, register_cell_line_featurizer + +Nothing is defined there — each name is an alias for a symbol that lives deeper +in the package. That indirection is the point: **only the aliases are a +compatibility promise.** The deep paths still import, but they are private in +the sense that matters, and a refactor may rename them without a deprecation +cycle. The five ``register_*`` aliases point at the per-registry ``register`` +decorators, which are all spelled ``register`` in their own modules; naming them +apart is what makes several registrations in one module readable. + +DrEvalPy also ships a ``py.typed`` marker, so the annotations on those aliases +are visible to a type checker running over your plugin. + +The examples on this page are executed +-------------------------------------- + +Every snippet below is ``literalinclude``\ d from a real module under +``docs/examples/``. The documentation build imports each of them, runs +DrEvalPy's shipped conformance checks over the result, and compares what landed +in the registries against a pinned list. An example that stops working fails the +build rather than misleading you, and these are the components it registers: + +.. include:: _generated_examples.rst + +Nothing in the shipped package imports ``docs/examples/``, and the build rolls +the registrations back once it has checked them, so the toy names above are not +present in a normal session. + +Custom featurizers +------------------ + +Subclass ``CellLineFeaturizer`` or ``DrugFeaturizer`` and register with +``@register_cell_line_featurizer`` or ``@register_drug_featurizer``. Before +writing a base of your own, check :ref:`featurizer-reuse` below — the shipped +featurizers are built on three reusable pieces that ``drevalpy.plugin`` exports. + +What a featurizer must provide +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The public ``fit`` / ``transform`` / ``transform_blocks`` methods are +**implemented for you** on the base class: they detect entities whose feature +rows are entirely NaN, run your code on the rest, and splice NaN rows back into +the result. You override the hooks underneath them: + +.. list-table:: + :header-rows: 1 + :widths: 34 66 + + * - Member + - Responsibility + * - ``_fit(source, *, entity_ids=None, ...)`` + - Learn whatever ``_transform_blocks`` needs, on pre-validated entity ids. + Return ``self``. + * - ``_transform_blocks(source, entity_ids)`` + - Return a ``dict[str, FeatureBlock]``, one row per entity id. + * - ``output_dim`` (property) + - Feature width after fitting. + * - ``get_state`` / ``set_state`` + - Optional, but required for a fitted featurizer to survive a ``*.zip`` + checkpoint: those store the state mapping, not the object. + +``_transform(source, entity_ids)`` returning a flat matrix is optional; the base +class derives it by concatenating your numeric blocks. + +Two declarations are mandatory, and registration is **rejected** without them: + +**The feature contract.** A ``FeatureFormat`` (``numeric_matrix``, ``graph``, or +``ragged_sequence``) describing the payload format this featurizer produces. +Composition validation compares it to the predictor's ``cell_line_contract`` / +``drug_contract`` and rejects stacks whose formats disagree. Declare it either +as ``contract`` on the class body or as ``contract=`` on the decorator; when +both are present the decorator argument wins. + +**The input views.** Which raw feature views the featurizer reads, so the +data-loading layer never needs a name-to-view lookup table. Any one of these +satisfies it: + +- ``input_views = ("gene_expression",)`` — a fixed set of views. +- ``entity_id_only = True`` — no view at all, just the identifiers. +- ``requires_view = True`` — the view comes from the config, as a ``view=`` + construction kwarg. +- overriding ``resolve_input_views`` — the views depend on other + hyperparameters. + +Registration also rejects a class that still has unimplemented abstract +methods, naming the members it is missing. That check runs at registration +rather than at instantiation, so a missing ``_fit`` fails next to its cause. + +A view-reading cell-line featurizer +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. literalinclude:: /examples/toy_cell_line_featurizer.py + :language: python + :caption: docs/examples/toy_cell_line_featurizer.py + +An identifier-only drug featurizer +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This one declares its contract on the class body instead, and uses +``entity_id_only`` in place of ``input_views``: + +.. literalinclude:: /examples/toy_drug_featurizer.py + :language: python + :caption: docs/examples/toy_drug_featurizer.py + +.. _featurizer-reuse: + +Reusing the shipped featurizer machinery +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**One implementation, both sides.** A featurizer whose logic does not depend on +the entity side is written **once** and bound to both sides with +``register_for_sides``, which registers it under the same name in both +featurizer registries. Do not write a cell-line copy and a drug copy: the side +is a class-level fact the registry stamps on, so a second registration would +overwrite the first one's side, and the decorator derives one subclass per side +precisely so each registry gets its own class to stamp. The built-in +``identity``, ``constant`` and ``concat`` featurizers are each a single class +this way. + +.. code-block:: python + + from typing import ClassVar + + import numpy as np + + from drevalpy.plugin import ( + BlockSpec, + FeatureFormat, + Featurizer, + numeric_feature_block, + register_for_sides, + ) + + + @register_for_sides( + "myOnes", + description={ + "cell_line": "A constant column per cell line.", + "drug": "A constant column per drug.", + }, + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class SharedOnesFeaturizer(Featurizer): + """Emit one column of ones per entity.""" + + entity_id_only: ClassVar[bool] = True + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("ones", FeatureFormat.NUMERIC_MATRIX), + ) + + def _fit(self, source, **kwargs): + return self + + def _transform_blocks(self, source, entity_ids): + ones = np.ones((len(entity_ids), 1), dtype=np.float32) + return {"ones": numeric_feature_block(ones)} + + @property + def output_dim(self): + return 1 + +``description`` also accepts a plain string used for every side, and +``sides=("cell_line",)`` binds one side only. The decorated class is returned +**unregistered**, so it stays importable as the shared logic it is; what lands in +the registries are the derived ``CellLineOnesFeaturizer`` and +``DrugOnesFeaturizer``, named by stripping a leading ``Shared`` and prefixing the +side. Both are injected into your module's namespace, which is what keeps them +findable when the registries are rebuilt. + +**One dense matrix from one view.** ``DenseViewFeaturizer`` implements ``_fit``, +``_transform`` and ``_transform_blocks`` for the shape every dense featurizer +shares — ask the storage layer whether the matrix is already pre-computed, +otherwise compute it from the view, then emit one numeric block. A subclass +overrides only its distinct step: usually ``_compute_matrix``, plus ``_fit_state`` +when there is state to learn. For a one-sided featurizer subclass the side-bound +``DenseViewCellLineFeaturizer`` or ``DenseViewDrugFeaturizer``, as the built-in +``pca``, ``landmark`` and ``scaled_gene_expression`` featurizers do; subclass +bare ``DenseViewFeaturizer`` when the implementation is side-agnostic and you +bind it with ``register_for_sides``. + +**Pre-computed variants.** ``fetch``, ``store`` and ``list_stored_variants`` live +on ``FeaturizerStorageMixin``, which ``Featurizer`` already mixes in — so every +featurizer has them, and you name the mixin only to annotate against it or to +override ``_fetch_by_key`` / ``_store_by_key`` for storage of your own. + +Custom predictors +----------------- + +Every predictor must inherit **exactly one** input interface and register with +``@register_predictor``. The three interfaces were introduced in +:doc:`/concepts/component_catalog`. + +As with featurizers, the public ``fit`` / ``predict`` are implemented on the +base class — they reject a batch with no responses, drop pairs whose features +are NaN, and return NaN for those pairs on predict. Contracts may be declared on +the class body or passed to the decorator, and the decorator wins. + +.. tab-set:: + + .. tab-item:: Feature-free + + ``FeatureFreePredictor`` sees pair identifiers and response values only. + Composition forbids cell-line and drug featurizers for it, since it would + consume neither, but registration still wants both contracts because the + composition checker compares them before it knows the interface. + Implement ``_fit`` and ``_predict``. + + .. literalinclude:: /examples/toy_mean_predictor.py + :language: python + :caption: docs/examples/toy_mean_predictor.py + + .. tab-item:: Matrix + + ``MatrixPredictor`` implements ``_fit`` / ``_predict`` for you by calling + ``batch.to_feature_matrix()``, so you implement ``_fit_matrix`` / + ``_predict_matrix`` on the dense pair-level design matrix — the pattern + ElasticNet, RandomForest and friends use. Both contracts must be + ``numeric_matrix``; registration rejects anything else for this interface. + + .. literalinclude:: /examples/toy_ridge_predictor.py + :language: python + :caption: docs/examples/toy_ridge_predictor.py + + .. tab-item:: Block + + ``BlockPredictor`` reads named featurizer blocks from + ``batch.cell_line_blocks`` / ``batch.drug_blocks`` instead of one + flattened matrix. Contracts still constrain the **format** of each side; + ``required_cell_line_blocks`` / ``required_drug_blocks`` additionally + require named blocks to be present in the stack. Implement ``_fit`` and + ``_predict``. + + .. literalinclude:: /examples/toy_block_predictor.py + :language: python + :caption: docs/examples/toy_block_predictor.py + +Custom splitters +---------------- + +A splitter is a function, not a class. Register it under a mode name with +``@register_splitter``; it must accept the splitter protocol signature and +return a list of :class:`~drevalpy.types.SplitMasks`. + +The dataset argument is typed as :class:`~drevalpy.plugin.MuDataLike`, a +``runtime_checkable`` protocol with six members: the ``cell_line_ids``, +``drug_ids`` and ``response_matrix`` properties, plus ``get_tissue(ids)``, +``response_layer_names()`` and ``get_response_layer(name)``. + +.. note:: + + The last two members are **new**. Code that implements ``MuDataLike`` by hand + — a test double, say — rather than passing a real + :class:`~drevalpy.plugin.Dataset` must add them, or ``isinstance`` checks + against the protocol will start failing. They exist so a splitter can reach + the curve-quality metrics; see below. + +.. literalinclude:: /examples/toy_splitter.py + :language: python + :caption: docs/examples/toy_splitter.py + +The ``validation`` argument names the leakage constraint the registry enforces +automatically after every split (``"LCO"``, ``"LDO"``, ``"LPO"``, or ``"LTO"``). +The registry wraps your function, so the check cannot be bypassed by calling the +registered splitter directly; a violation raises ``SplitValidationError``. + +Registering a mode name that is already taken **raises**. Pass +``override=True`` when replacing an existing mode is the intent — a silent +overwrite would let one package quietly change another's split semantics. + +Once registered, the mode works anywhere a mode string is accepted: + +.. code-block:: python + + from drevalpy.data import split + + folds = split(dataset, mode="TOY_LCO", n_splits=5) + +Filtering on curve quality +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The datasets ship every fitted dose-response curve, including the meaningless +ones, so a splitter that treats "not NaN" as "usable" trains on junk. The +built-in splitters blank the failing pairs first, and +``curve_quality_mask`` is that step: + +.. code-block:: python + + from drevalpy.plugin import curve_quality_mask + + response = mudataset.response_matrix.copy() + response[~curve_quality_mask(mudataset)] = np.nan + observed = ~np.isnan(response) + +Called bare it applies the same rule the built-ins use — +``relevance_score >= -log10(0.05)`` and ``abs(fold_change) >= 0.45``. Every other +quality metric the datasets carry is a keyword option, defaulting to ``None``, +i.e. not checked: + +.. list-table:: + :header-rows: 1 + :widths: 34 66 + + * - Option + - Effect + * - ``min_relevance_score``, ``min_abs_fold_change`` + - The default rule. Pass ``None`` to switch a half of it off. + * - ``max_p_value``, ``min_log_p_value`` + - Raw, **uncorrected** significance. ``min_relevance_score`` is the + corrected equivalent and is why these are off by default. + * - ``min_f_value``, ``min_f_value_sam`` + - F statistic, raw and s0-corrected. + * - ``min_r2``, ``max_rmse``, ``min_signal_quality`` + - Goodness of fit. + * - ``min_abs_slope``, ``max_abs_slope`` + - Hill slope bounds. The upper bound excludes step-like fits. + * - ``min_front``, ``max_back`` + - Plateau bounds of a well-formed descending curve. + * - ``min_pec50``, ``max_pec50`` + - Inflection point range, read from the response matrix. + * - ``regulation`` + - Keep only CurveCurator's own labels, from ``"up"``, ``"down"``, ``"not"``. + +A metric that is NaN always fails: a curve CurveCurator could not score is not +worth training on. A requested metric whose layer is absent raises ``KeyError`` +rather than being skipped silently. See :ref:`curve-quality` for the measured +range and direction of each metric. + +Custom visualizations +--------------------- + +Register a visualization class with ``@register_visualization``. +``Visualization`` declares **four** abstract methods: + +.. list-table:: + :header-rows: 1 + :widths: 26 74 + + * - Method + - Responsibility + * - ``compute(result, dataset=None)`` + - Derive the plot data from the result and store it on the instance. + * - ``to_png(path)`` + - Write a static image. + * - ``to_multiqc()`` + - Return ``Section`` objects for the report. + * - ``show()`` + - Display interactively in a notebook. + +For a static Matplotlib plot, subclass ``ImageVisualization`` instead: it +implements the last three in terms of a figure, leaving you ``compute`` — which +must assign the figure to ``self._fig`` — and ``_create_figure``. + +.. literalinclude:: /examples/toy_visualization.py + :language: python + :caption: docs/examples/toy_visualization.py + +``result_type`` declares whether the visualization operates on an +``ExperimentResult`` (aggregated across models) or a ``ModelResult`` (a single +model). ``requirements`` is a frozenset of ``PlotRequirement`` values naming +conditions the report system checks before selecting the plot automatically — +multiple CV folds, multiple models, randomization, or robustness data. + +As with splitters, a name that is already registered raises unless you pass +``override=True``. + +Testing your components +----------------------- + +:mod:`drevalpy.testing` ships in the wheel precisely so a plugin's own test +suite can import it. It removes the two things that otherwise block an offline +plugin CI: every registered dataset lives in a credentialed bucket, and a +predictor needs a fully featurized batch. + +.. list-table:: + :header-rows: 1 + :widths: 34 66 + + * - Helper + - What it gives you + * - ``build_synthetic_dataset(...)`` + - An in-memory ``Dataset``. Response-only by default; pass ``omics=[...]`` + to add the cell-line modalities your featurizer reads. + * - ``build_synthetic_batch(dataset, ...)`` + - A ``ModelInputBatch`` with drawn features and a learnable response, so a + predictor is testable without composing a model. + * - ``observed_pairs(dataset)`` + - Every measured cell-line/drug pair, as a ``ResponseBatch``. + * - ``FEATURIZER_CHECKS`` / ``PREDICTOR_CHECKS`` + - Every conformance check, as a tuple to parametrize over. + * - ``check_plugin(name)`` + - Assert an installed plugin's entry point is declared, loaded, and that + its components resolve through the registries. + +Each check takes ``(cls, fixture, **kwargs)`` — the fixture being a dataset for +featurizers and a batch for predictors, and optional in both cases — so a suite +parametrizes over a whole family at once: + +.. code-block:: python + + import pytest + + from drevalpy.testing import FEATURIZER_CHECKS, build_synthetic_dataset + + from my_plugin.featurizers import MyFeaturizer + + + @pytest.mark.parametrize("check", FEATURIZER_CHECKS) + def test_my_featurizer_conforms(check): + check(MyFeaturizer, build_synthetic_dataset(omics=["gene_expression"])) + +The checks catch what registration cannot: that the component instantiates, +that ``output_dim`` agrees with the width ``transform`` actually produced, and +that a fresh instance restored from ``get_state`` reproduces the original's +output. The last one is the expensive bug — a fitted attribute left out of +``get_state`` makes a reloaded checkpoint silently predict something else. A +failing check raises ``ConformanceError``, which subclasses ``AssertionError``. + +This page's own examples are verified exactly this way; see +``docs/examples/toy_conformance.py`` in the repository. + +Custom dataset sources +---------------------- + +Register remote or local storage locations as **sources**, then point named +datasets at files under those sources: + +.. code-block:: python + + from drevalpy.registry.dataset import register_dataset, register_source + + register_source( + "my_s3_bucket", + "s3://my-bucket/datasets/", + storage_options={"key": "...", "secret": "..."}, + ) + + register_dataset("MyScreen", source="my_s3_bucket", file="MyScreen.h5mu") + +The two-level design means you register a source once and then add as many +dataset entries under it as needed. Any protocol that +`fsspec `_ supports works: HTTPS, +S3, GCS, Azure Blob Storage, or local file paths. Unlike the other registries, +dataset entries are persisted to a local configuration file, so they survive +across sessions. Once registered, load by name as usual: + +.. code-block:: python + + from drevalpy.data import load + + dataset = load("MyScreen") + +Literature references +--------------------- + +``LiteratureReference`` is optional **provenance metadata** for components +ported from a paper or external repository. Pass it as ``reference=...`` on the +register decorator, as ``toyRidge`` above does. It does **not** change training, +composition checks, or checkpoints — it only documents where the idea came from. +``repo_url`` is required; ``citation_text``, ``citation_doi`` and ``deviations`` +are optional strings. + +Loading extensions +------------------ + +Import your components +~~~~~~~~~~~~~~~~~~~~~~ + +``@register_*`` runs when the module is imported. If your package is +installable (or otherwise on ``PYTHONPATH``), a normal import is enough: + +.. code-block:: python + + import my_components.toy_featurizer # registers toyCellLine + import my_components.toy_predictors # registers toyMean, toyRidge, … + + from drevalpy.models import construct_model + + ToyRidge = construct_model( + "ToyRidge", + "toyCellLine:toyDrugHash:toyRidge", + ) + model = ToyRidge() + +Other sources: ``load_extensions`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use :func:`~drevalpy.registry.load_extensions` when components are not a +normal importable package, or when you also want to register external zoo +YAML in one call: + +- ``modules`` — dotted names (same effect as ``import``) +- ``files`` — individual ``.py`` paths executed as temporary modules +- ``directories`` — all ``*.py`` in a folder (non-recursive; ``__init__.py`` + skipped; sorted by filename) +- ``zoo_files`` — YAML presets that map a **name** to an already-registered + stack (not Python classes, not experiment hpam YAML) + +.. code-block:: python + + from drevalpy.models import construct_model + from drevalpy.registry import load_extensions + + load_extensions( + directories=["my_components"], + zoo_files=["my_zoo/toy.yaml"], + ) + ToyMean = construct_model("toyMean") # zoo preset name + +Every entry point rolls the registries back if loading fails part-way, so a +module that registers two components and then raises leaves neither behind. + +Plugin discovery +~~~~~~~~~~~~~~~~ + +When the package is imported, it scans for installed Python packages that +advertise the ``drevalpy.plugins`` entry point group. Importing the advertised +module triggers registration decorators, making a plugin's components +available without any explicit user action beyond installation. + +In your plugin's ``pyproject.toml``: + +.. code-block:: toml + + [project.entry-points."drevalpy.plugins"] + my_plugin = "my_plugin.components" + +When a plugin fails to load +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A plugin that raises on import silently removes every component it would have +registered, which surfaces much later as an unknown-predictor error far from the +cause. DrEvalPy therefore records the failure instead of swallowing it: + +.. code-block:: python + + from drevalpy.registry import get_failed_plugins, get_loaded_plugins + + get_loaded_plugins() # {entry point name: declared object reference} + get_failed_plugins() # {entry point name: formatted traceback} + +The default is non-fatal, so one broken third-party package cannot brick the +CLI for everyone else. Setting the environment variable +``DREVALPY_STRICT_PLUGINS=1`` re-raises instead — which is what a plugin's own +CI wants, since a plugin that does not load is a failure there rather than a +degraded experience. + +Extension directories +~~~~~~~~~~~~~~~~~~~~~ + +Both the CLI and the Python API accept an **extensions directory** containing +``.py`` and ``.yaml`` files. All Python files in the directory are imported +(triggering registration decorators for any registry), and all YAML files are +loaded as model-zoo presets or dataset declarations. The environment variable +``DREVALPY_EXTENSIONS_DIR`` provides the same mechanism without requiring a +CLI flag. + +Saving and loading with custom components +----------------------------------------- + +Checkpoints are ZIP archives that store the resolved ``ModelConfig`` (component +**names**) and fitted state — not the Python classes themselves. On load, +DrEvalPy looks those names up in the registries again, then restores state. If +a custom featurizer or predictor is not registered in the process that calls +``load`` / ``load_model``, reconstruction fails. + +Import the same modules (or call ``load_extensions``) before loading: + +.. code-block:: python + + import my_components.toy_featurizer + import my_components.toy_predictors + + from drevalpy.models import load_model + + model = load_model("checkpoints/toy_ridge.zip") + +Built-in zoo models need no extra step; only custom component names require +this. See :doc:`models` for the general save/load lifecycle. diff --git a/docs/python/models.rst b/docs/python/models.rst new file mode 100644 index 000000000..51abd2241 --- /dev/null +++ b/docs/python/models.rst @@ -0,0 +1,207 @@ +Models +====== + +If you are reading this, we assume you are already familiar with these +concepts: + +- :doc:`/concepts/component_catalog` +- :doc:`/concepts/from_components_to_models` +- :doc:`/concepts/model_zoo` + +Every runnable model in DrEvalPy is a thin ``DRPModel`` subclass produced by +:func:`~drevalpy.models.construct_model`. You never hand-write that subclass: +you declare a ``ModelConfig`` (or something that becomes one), resolve a +**class**, then construct a fresh **instance**. + +DrEvalPy has two cooperating layers: a **component stack** under +``drevalpy.components`` (featurizers, predictors, registries, tuning helpers) +and **public orchestration** under ``drevalpy.models`` (``ModelConfig``, zoo +YAML, and ``construct_model`` returning thin ``DRPModel`` subclasses). A +resolved instance materializes featurizer(s) and a predictor as an internal +component stack. + +From declaration to instance +---------------------------- + +Constructing (custom) model classes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:func:`~drevalpy.models.construct_model` is the only way of creating DRPModel classes. +There are two ways to call it: + +#. With **one argument**, pass a zoo preset name (for example + ``construct_model("ElasticNet")``). The name must exist in the model zoo. +#. With **two arguments**, pass a custom class name and a **spec** (for example + ``construct_model("MyRF", spec)``). Use this form when the name is not a zoo + preset. ``spec`` must be either a recipe string or a ``ModelConfig`` object — + YAML paths are not accepted directly. Build a ``ModelConfig`` with the + constructor or ``config.from_yaml(...)``, then pass that object as + ``spec``. + +The tabs below show each call form: + +.. tab-set:: + + .. tab-item:: Zoo + + .. code-block:: python + + from drevalpy.models import construct_model + + ElasticNet = construct_model("ElasticNet") + + Discover names with ``list_zoo_names()`` (optionally filter by + ``ModelScope``). Presets are listed in :doc:`/concepts/model_zoo`. + + .. tab-item:: Recipe string + + .. code-block:: python + + from drevalpy.models import construct_model + + MyRF = construct_model( + "MyRF", + "scaledGeneExpression:fingerprints:randomForest", + ) + + .. tab-item:: YAML + + .. code-block:: yaml + + cell_line_featurizer: scaledGeneExpression + drug_featurizer: fingerprints + predictor: randomForest + + .. code-block:: python + + from drevalpy.models import config, construct_model + + cfg = config.from_yaml("my_zoo/custom_rf.yaml") + MyRF = construct_model("MyRF", cfg) + + .. tab-item:: ModelConfig + + .. code-block:: python + + from drevalpy.models import config, construct_model + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="scaledGeneExpression" + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig(name="randomForest"), + ) + MyRF = construct_model("MyRF", cfg) + + .. tab-item:: ModelConfig + hyperparameter space + + Set ``hyperparameter_space`` on a component to **replace** its built-in + search space (see :doc:`/concepts/from_components_to_models`). Recipe + strings cannot express this; use YAML or ``ModelConfig``. + + .. code-block:: python + + from drevalpy.models import config, construct_model + + cfg = config.ModelConfig( + cell_line_featurizer=config.CellLineFeaturizerConfig( + name="scaledGeneExpression" + ), + drug_featurizer=config.DrugFeaturizerConfig(name="fingerprints"), + predictor=config.PredictorConfig( + name="elasticNet", + hyperparameter_space={ + "alpha": { + "type": "float", + "low": 1e-4, + "high": 10.0, + "log": True, + "default": 1.0, + }, + "l1_ratio": { + "type": "float", + "low": 0.0, + "high": 1.0, + "default": 0.5, + }, + }, + ), + ) + MyEN = construct_model("MyElasticNet", cfg) + +Recipe grammar and YAML field names are documented in +:doc:`/concepts/from_components_to_models`. Applied featurizer examples with +custom CSV views are in :doc:`datasets`. + +Instantiating models +~~~~~~~~~~~~~~~~~~~~ + +:func:`~drevalpy.models.construct_model` returns a ``DRPModel`` **subclass**, not a +runnable model. Call the class to create an instance: + +.. code-block:: python + + from drevalpy.models import construct_model + + ElasticNet = construct_model("ElasticNet") + + model = ElasticNet() + model = ElasticNet({"alpha": 0.1}) + +With no arguments, the constructor uses each component's default hyperparameters +(from the zoo preset, recipe, or ``ModelConfig``). Pass a **public hyperparameter +mapping** to override values at construction time. + +Use **local** parameter names (``alpha``, ``n_components``, …) when the name is +unique in the stack — the usual case for zoo presets and simple recipes: + +.. code-block:: python + + model = ElasticNet({"alpha": 0.1}) + +When the same local name exists on more than one component, DrEvalPy raises an +error and lists the accepted **qualified** keys. Target one slot explicitly: + +.. code-block:: python + + model = MyModel( + { + "cell_line_featurizer.pca[expression].n_components": 32, + "cell_line_featurizer.pca[proteomics].n_components": 16, + } + ) + +Aliases such as ``methylation_n_components`` remain accepted on input. +Hyperparameters are fixed after construction; create a new instance to change +them. + +Implementing predictors (``ModelInputBatch``, input interfaces, and +``FeatureFormat`` contracts) is covered in :doc:`extensions`. + +Training and persistence +------------------------ + +Each ``DRPModel`` subclass exposes: + +1. ``train(...)`` / ``predict(...)`` — fit and score on response + feature + inputs (the experiment runner constructs a fresh instance per fold). +2. ``save(path)`` / ``ModelClass.load(path)`` / ``load_model(path)`` — + native ZIP checkpoints (format ``drevalpy-model``). + +``save`` writes one deflated ZIP archive containing the resolved +``ModelConfig`` and fitted component state. If you already have a class handle, +you can use ``ModelClass.load`` for loading. Otherwise, use ``load_model``: +It first constructs the model class from the stored checkpoint and then loads the parameters. + +.. code-block:: python + + from drevalpy.models import construct_model, load_model + + ElasticNet = construct_model("ElasticNet") + model = ElasticNet() + model.train(...) + model.save("checkpoints/elastic_net") + + loaded = ElasticNet.load("checkpoints/elastic_net") + loaded = load_model("checkpoints/elastic_net.zip") diff --git a/docs/python/quickstart.rst b/docs/python/quickstart.rst new file mode 100644 index 000000000..ba1ed6159 --- /dev/null +++ b/docs/python/quickstart.rst @@ -0,0 +1,64 @@ +Quickstart +========== + +Install DrEvalPy and its dependencies first — see +:doc:`/getting_started/installation`. + +Load the GDSC1 dataset, resolve ElasticNet from the model zoo, and run the +evaluation pipeline: + +.. code-block:: python + + from drevalpy.data import load + from drevalpy.models import construct_model + from drevalpy import run + + dataset = load("GDSC1") + + ElasticNet = construct_model("ElasticNet") + + result = run( + models=[ElasticNet], + dataset=dataset, + split_mode="LCO", + hyperparameter_tuning=False, + ) + +:func:`~drevalpy.models.construct_model` returns a **class**. The pipeline +expects classes (or a list of classes) so each CV fold can construct a +fresh configured instance and call ``train``. For a single-model script +outside the experiment runner: + +.. code-block:: python + + model = ElasticNet() # or ElasticNet({"alpha": 0.1}) + model.train(...) + +:func:`~drevalpy.run` returns an +:class:`~drevalpy.types.results.ExperimentResult` that groups predictions, +metrics, and metadata for all folds. Save it and generate a report: + +.. code-block:: python + + result.save("results/") + + from drevalpy.visualization.report import create_report + + create_report(result, "report/") + +See :doc:`visualization` for report options, :doc:`datasets` for loading and +splitting, and :doc:`experiments` for tuning and stress-test options. + +After concepts +-------------- + +Shared vocabulary lives under **Concepts**. Use this map when you leave the +concepts track for Python: + +- :doc:`/concepts/datasets` → :doc:`datasets` +- :doc:`/concepts/evaluation` → :doc:`experiments` and :doc:`visualization` +- :doc:`/concepts/component_catalog` → :doc:`extensions` +- :doc:`/concepts/from_components_to_models` → :doc:`models` and + :doc:`experiments` +- :doc:`/concepts/model_zoo` → :doc:`models` +- :doc:`/concepts/registries` → :doc:`extensions` diff --git a/docs/python/visualization.rst b/docs/python/visualization.rst new file mode 100644 index 000000000..524d507d5 --- /dev/null +++ b/docs/python/visualization.rst @@ -0,0 +1,101 @@ +Visualization and evaluation +============================ + +If you are reading this, we assume you are already familiar with this +concept: + +- :doc:`/concepts/evaluation` + +Score predictions with :func:`~drevalpy.evaluation.evaluate`, draw comparison +plots with the ``drevalpy.visualization`` classes, or render a full HTML +report with :func:`~drevalpy.visualization.report.create_report`. + +evaluate +-------- + +``evaluate`` computes one or more metrics given predictions and observed +response values: + +.. code-block:: python + + import numpy as np + from drevalpy.evaluation import evaluate + + predictions = np.array([1.2, 3.4, 2.5]) + response = np.array([1.0, 3.5, 2.0]) + + metrics = evaluate(predictions, response, metric=["RMSE", "Pearson", "R^2"]) + # {"RMSE": ..., "Pearson": ..., "R^2": ...} + +You can also pass an object that has ``.predictions`` and ``.response`` +attributes: + +.. code-block:: python + + metrics = evaluate(run_result, metric="Pearson") + +Available metric names include ``MSE``, ``RMSE``, ``MAE``, ``R^2``, +``Pearson``, ``Spearman``, and ``Kendall``. + +Plot classes +------------ + +The visualization package exports plot helpers used by the report pipeline: + +.. code-block:: python + + from drevalpy.visualization import ( + ComparisonScatter, + CriticalDifferencePlot, + CrossStudyTables, + Heatmap, + RegressionSliderPlot, + Violin, + ) + +Typical use after an experiment (as ``create_report`` does internally): + +- ``Violin`` / ``Heatmap`` — metric distributions and heatmaps across models +- ``ComparisonScatter`` — model-vs-model scatter comparisons +- ``RegressionSliderPlot`` — true-vs-predicted regression views +- ``CriticalDifferencePlot`` — critical-difference diagrams over CV folds +- ``CrossStudyTables`` — transfer / cross-study summary tables + +Most plot classes implement the ``Visualization`` interface (``compute`` and +``to_multiqc``). Prefer ``create_report`` unless you need a custom figure +layout. + +create_report +------------- + +After :func:`~drevalpy.run` finishes, build the HTML report from the +result object: + +.. code-block:: python + + from drevalpy.visualization.report import create_report + + create_report(result, "report/") + +Or load a previously saved experiment: + +.. code-block:: python + + from drevalpy.types.results import ExperimentResult + from drevalpy.visualization.report import create_report + + experiment = ExperimentResult.load("results/") + create_report(experiment, "report/", title="My Benchmark") + +Parameters: + +- ``result`` — an ``ExperimentResult``, ``ModelResult``, or ``RunResult`` +- ``output_dir`` — where to write the HTML report +- ``title`` — report title (default ``"Drug Response Evaluation"``) +- ``reference_model`` — if set, normalize metrics against this model +- ``dataset`` — optional ``Dataset`` for drug/cell-line metadata in plots + +The report uses `MultiQC `_ internally. Evaluation +concepts (normalized metrics, critical difference) are documented in +:doc:`/concepts/evaluation`. For the CLI report command, see +:doc:`/cli/visualization`. diff --git a/docs/quickstart.rst b/docs/quickstart.rst deleted file mode 100644 index 1fca5f22e..000000000 --- a/docs/quickstart.rst +++ /dev/null @@ -1,40 +0,0 @@ -Quickstart ----------- - -Make sure you have installed DrEvalPy and its dependencies (see `Installation <./installation.html>`_). - -To make sure the pipeline runs, you can use the fast models NaiveMeanEffectsPredictor and NaiveDrugMeanPredictor on the TOYv1 (subset of CTRPv2) or TOYv2 (subset of GDSC2) -dataset with the LCO test mode. - -.. code-block:: bash - - drevalpy --run_id my_first_run --models NaiveTissueMeanPredictor NaiveDrugMeanPredictor --baselines NaiveMeanEffectsPredictor --dataset_name TOYv1 --test_mode LCO - -This will train the three baseline models to predict LN_IC50 values of our Toy dataset which is a subset of CTRPv2. -It will evaluate in "LCO" which is the leave-cell-line-out splitting strategy -(leave random cell lines out for testing) using 7 fold cross validation. -The results will be stored in - -.. code-block:: bash - - results/my_first_run/TOYv1/LCO - -You can visualize them using - -.. code-block:: bash - - drevalpy report --run_id my_first_run --dataset_name TOYv1 - -This creates an index.html file which you can open in your browser to see the results of your run. - -We recommend the use of our nextflow pipeline for computational demanding runs and for improved reproducibility. No -knowledge of nextflow is required to run it. The nextflow pipeline is available on the `nf-core GitHub -`_, the documentation can be found `here `_. - -- Want to test if your own model outperforms the baselines? See `Run Your Model <./runyourmodel.html>`_. -- Discuss usage, development and issues on `GitHub `_. -- Check the `Contributor Guide <./contributing.html>`_ if you want to participate in developing. -- If you use drevalpy for your work, `please cite us <./reference.html>`_. - -.. - - Check our `tutorial notebook `_, the `usage principles <./usage.html>`_ or the `API <./API.html>`_. diff --git a/docs/readme.rst b/docs/readme.rst index 6b2b3ec68..47b43bd2c 100644 --- a/docs/readme.rst +++ b/docs/readme.rst @@ -1 +1,8 @@ -.. include:: ../README.rst \ No newline at end of file +DrEvalPy +======== + +Python cancer cell line drug response prediction suite. + +- `PyPI `_ +- `GitHub `_ +- `Paper `_ diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 39bb54e80..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -sphinx-autobuild==2025.8.25 ; python_version >= "3.11" and python_version <= "3.13" -sphinx-autodoc-typehints==3.5.2 ; python_version >= "3.11" and python_version <= "3.13" -sphinx-click==6.2.0 ; python_version >= "3.11" and python_version <= "3.13" -sphinx-rtd-theme==3.0.2 ; python_version >= "3.11" and python_version <= "3.13" diff --git a/docs/runyourmodel.rst b/docs/runyourmodel.rst deleted file mode 100644 index 15a0f50c1..000000000 --- a/docs/runyourmodel.rst +++ /dev/null @@ -1,377 +0,0 @@ -Run your own model -=================== - -DrEvalPy provides a standardized interface for running your own model. - -There are a few steps to follow so we can make sure the model evaluation is consistent and reproducible. -Feel free to contact us via GitHub if you experience any difficulties :-) - -First, make a new folder for your model at ``drevalpy/models/your_model_name``. -Create ``drevalpy/models/your_model_name/your_model.py``, in which you need to define the Python class for your model. -This class should inherit from the :ref:`DRPModel ` base class. -DrEvalPy is agnostic to the specific modeling strategy you use. However, you need to define the input views (a.k.a modalities), which represent different types of features that your model requires. -In this example, the model uses "gene_expression" and "methylation" features as cell line views, and "fingerprints" as drug views. -Additionally, you must define a unique model name to identify your model during evaluation. - -.. code-block:: Python - - from drevalpy.models.drp_model import DRPModel - from drevalpy.datasets.dataset import FeatureDataset, DrugResponseDataset - from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER - from drevalpy.models.utils import ( - load_and_select_gene_features, - load_drug_fingerprint_features, - scale_gene_expression, - ) - from typing import Any - import numpy as np - - class YourModel(DRPModel): - """A revolutionary new modeling strategy.""" - - is_single_drug_model = True / False # TODO: set to true if your model is a single drug model (i.e., it needs to be trained for each drug separately) - early_stopping = True / False # TODO: set to true if you want to use a part of the validation set for early stopping - cell_line_views = ["gene_expression", "methylation"] - drug_views = ["fingerprints"] - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the name of the model. - """ - return "YourModel" - -Next let's implement the feature loading. You have to return a DrEvalPy FeatureDataset object which contains the features for the cell lines and drugs. -If the features are different depending on the dataset, use the ``dataset_name`` parameter. -In this example, we load custom drug fingerprints and cell line gene expression and methylation features from CSV files. -The cell line ids of your gene expression and methylation csvs should match the ``CELL_LINE_IDENTIFIER`` ("cell_line_name"), -and the drug ids of your fingerprints csv should match the ``DRUG_IDENTIFIER`` ("pubchem_id"). -The model will use these identifiers to match the features with the drug response data. - -:download:`Example fingerprint file <_static/example_data/fingerprints_example.csv>`, :download:`Example gene expression file <_static/example_data/gex_example.csv>`. - -For our provided datasets, we have other loading methods implemented in the `drevalpy/models/utils.py` file, which you can also use: - -* ``def load_and_select_gene_features``: Loads a specified omic; enables selecting a specific gene list (e.g., landmark genes). -* ``def get_multiomics_feature_dataset``: Loads the specified omics (iteratively calls previous method). -* ``def load_drug_fingerprint_features``: Loads the provided drug fingerprints. -* ``def load_cl_ids_from_csv`` -* ``def load_drug_ids_from_csv`` -* ``load_tissues_from_csv`` - -.. code-block:: Python - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the drug features, in this case the drug ids. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the drug ids - """ - feature_dataset = FeatureDataset.from_csv( - path_to_csv=f"{data_path}/{dataset_name}/fingerprints.csv", - id_column=DRUG_IDENTIFIER, - view_name="fingerprints", - drop_columns=None - ) # make sure to adjust the path to your data. If you want to drop columns, specify them in a list. - - return feature_dataset - - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features, in this case the cell line ids. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the cell line ids - """ - feature_dataset = FeatureDataset.from_csv(f"{data_path}/{dataset_name}_gene_expression.csv", - id_column=CELL_LINE_IDENTIFIER, - view_name="gene_expression", - drop_columns=['cellosaurus_id'] - ) # make sure to adjust the path to your data - methylation = FeatureDataset.from_csv(f"{data_path}/{dataset_name}_methylation.csv", - id_column=CELL_LINE_IDENTIFIER, - view_name="methylation", - drop_columns=['cellosaurus_id'] - ) # make sure to adjust the path to your data - feature_dataset.add_features(methylation) - - return feature_dataset - -The build_model functions can be used if you want to use tunable hyperparameters. -The hyperparameters which get tested are defined in the ``drevalpy/models/your_model_name/hyperparameters.yaml``. - -.. code-block:: Python - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - """ - Builds the model, for models that use hyperparameters. - - :param hyperparameters: hyperparameters for the model - Example: - self.model = ElasticNet(alpha=hyperparameters["alpha"], l1_ratio=hyperparameters["l1_ratio"]) - """ - self.predictor = YourPredictor(hyperparameters) # Initialize your Predictor, this could be a sklearn model, a neural network, etc. - -Sometimes, the model design is dependent on your training data input. In this case, you can also consider implementing build_model like: - -.. code-block:: Python - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - self.hyperparameters = hyperparameters - -and then set the model design later in the train method when you have access to the training data. -(e.g., when you can access the feature dimensionalities) -The train method should handle model training, and saving any necessary information (e.g., learned parameters). -Here we use a simple predictor that just uses the concatenated features to predict the response. - -.. code-block:: Python - - def train(self, output: DrugResponseDataset, cell_line_input: FeatureDataset, drug_input: FeatureDataset | None = None, output_earlystopping: DrugResponseDataset | None = None, model_checkpoint_dir: str | None = None) -> None: - - inputs = self.get_feature_matrices( - cell_line_ids=output.cell_line_ids, - drug_ids=output.drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - self.predictor.fit(**inputs, output.response) - -In case you want to set some parameters dependent on the training data, your train function might look like this: - -.. code-block:: Python - - def train(self, output: DrugResponseDataset, cell_line_input: FeatureDataset, drug_input: FeatureDataset | None = None, output_earlystopping: DrugResponseDataset | None = None) -> None: - - cell_line_input = self._feature_selection(output, cell_line_input) - dim_gex, dim_mut, dim_cnv = get_dimensions_of_omics_data(cell_line_input) - - self.nn_model = YourModel( - input_size_gex=dim_gex, - input_size_mut=dim_mut, - input_size_cnv=dim_cnv, - hpams=self.hyperparameters, - ... - ) - self.nn_model.fit( - output_train=output, - output_early_stopping=output_earlystopping, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - -We also provide utility functions (drevalpy/models/utils.py) for data transformations that have to be computed on the training data only (e.g., scaling, feature selection) to avoid data leakage: - -* ``def scale_gene_expression`` -* ``class VarianceFeatureSelector`` -* ``def prepare_expression_and_methylation`` -* ``class ProteomicsMedianCenterAndImputeTransformer`` -* ``def prepare_proteomics`` - - -The predict method should handle model prediction, and return the predicted response values. - -.. code-block:: Python - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the response for the given input. - - :param drug_ids: list of drug ids, also used for single drug models, there it is just an array containing the - same drug id - :param cell_line_ids: list of cell line ids - :param cell_line_input: input associated with the cell line, required for all models - :param drug_input: input associated with the drug, optional because single drug models do not use drug features - :returns: predicted response - """ - - inputs = self.get_feature_matrices( - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - return self.predictor.predict(**inputs, output.response) - - -Finally, you need to register your model with the framework. This can be done by adding the following line to the ``__init__.py`` file in the ``drevalpy/models/__init__.py`` directory. -Update the ``MULTI_DRUG_MODEL_FACTORY`` if your model is a global model for multiple cancer drugs or to the ``SINGLE_DRUG_MODEL_FACTORY`` if your model is specific to a single drug and needs to be trained for each drug separately. - -.. code-block:: Python - - from .your_model_name.your_model import YourModel - MULTI_DRUG_MODEL_FACTORY.update("YourModel": YourModel) - -Now you can run your model using the DrEvalPy pipeline. Run the following command (after installing your cloned and edited DrEvalPy repository e.g. with ``pip install -e .``): - -.. code-block:: shell - drevalpy --model YourModel --dataset_name CTRPv2 --data_path data - - -To contribute the model, so that the community can build on it, please also write appropriate tests in ``tests/models`` and documentation in ``docs/`` -We are happy to help you with that, contact us via GitHub! - -Let's look at an example an example implementation of a model using the DrEvalPy framework: - - - -Example: TinyNN (Neural Network with PyTorch) ---------------------------------------------- - -In this example, we implement a simple feedforward neural network for drug response prediction using gene expression and drug fingerprint features. -We use and recommend PyTorch, but you can use any other framework like TensorFlow, JAX, etc. -Gene expression features are standardized using a ``StandardScaler``, while fingerprint features are used as-is. - -1. We define a minimal PyTorch model with CPU/GPU support. - -.. code-block:: Python - - import torch - import torch.nn as nn - import numpy as np - - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - - class FeedForwardNetwork(nn.Module): - def __init__(self, input_dim: int, hidden_dim: int): - super().__init__() - self.net = nn.Sequential( - nn.Linear(input_dim, hidden_dim), - nn.ReLU(), - nn.Linear(hidden_dim, 1) - ) - self.to(device) - - def fit(self, x: np.ndarray, y: np.ndarray, lr: float = 1e-3, epochs: int = 100): - self.train() - x_tensor = torch.tensor(x, dtype=torch.float32, device=device) - y_tensor = torch.tensor(y, dtype=torch.float32, device=device).unsqueeze(1) - - optimizer = torch.optim.Adam(self.parameters(), lr=lr) - loss_fn = nn.MSELoss() - - for _ in range(epochs): - optimizer.zero_grad() - loss = loss_fn(self(x_tensor), y_tensor) - loss.backward() - optimizer.step() - - def forward(self, x): - return self.net(x) - - def predict(self, x: np.ndarray) -> np.ndarray: - self.eval() - with torch.no_grad(): - x_tensor = torch.tensor(x, dtype=torch.float32, device=device) - preds = self(x_tensor).squeeze(1) - return preds.cpu().numpy() - -2. We create the ``TinyNN`` model class that inherits from ``DRPModel``. - -.. code-block:: Python - - from drevalpy.models.drp_model import DRPModel - from drevalpy.datasets.dataset import FeatureDataset - from sklearn.preprocessing import StandardScaler - from drevalpy.models.utils import load_and_select_gene_features, load_drug_fingerprint_features - - - class TinyNN(DRPModel): - cell_line_views = ["gene_expression"] - drug_views = ["fingerprints"] - early_stopping = True - - def __init__(self): - super().__init__() - self.model = None - self.hyperparameters = None - self.scaler_gex = StandardScaler() - - @classmethod - def get_model_name(cls) -> str: - return "TinyNN" - -3. We define how the features are loaded. Here, we use our presupplied datasets and the preimplemented functions. Loading features can be customized (Have a look at the FeatureDataset class for more details e.g. on how to load features from a CSV). - -.. code-block:: Python - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - - return load_and_select_gene_features(feature_type="gene_expression", - data_path=data_path, - dataset_name=dataset_name, - gene_list="landmark_genes_reduced") - - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - - return load_drug_fingerprint_features(data_path, dataset_name, fill_na=True) - -1. In the ``build_model`` we just store the hyperparameters. - -.. code-block:: Python - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - self.hyperparameters = hyperparameters - -5. In the train method we scale gene expression and train the model. - -.. code-block:: Python - - def train(self, output, cell_line_input, drug_input, output_earlystopping=None, model_checkpoint_dir=None): - gex = cell_line_input.get_feature_matrix("gene_expression", output.cell_line_ids) - fp = drug_input.get_feature_matrix("fingerprints", output.drug_ids) - - gex = self.scaler_gex.fit_transform(gex) - x = np.concatenate([gex, fp], axis=1) - y = output.response - - self.model = FeedForwardNetwork( - input_dim=x.shape[1], - hidden_dim=self.hyperparameters["hidden_dim"] - ) - self.model.fit(x, y) - -6. We apply scaling in ``predict`` and return model outputs. - -.. code-block:: Python - - def predict(self, cell_line_ids, drug_ids, cell_line_input, drug_input): - gex = cell_line_input.get_feature_matrix("gene_expression", cell_line_ids) - fp = drug_input.get_feature_matrix("fingerprints", drug_ids) - - gex = self.scaler_gex.transform(gex) - x = np.concatenate([gex, fp], axis=1) - - return self.model.predict(x) - -7. Add hyperparameters to your ``hyperparameters.yaml``. We add two values for the hidden layer size. DrEval will tune over this hyperparameter space. - -.. code-block:: YAML - - TinyNN: - hidden_dim: - - 32 - - 64 - -8. Register the model in ``models/__init__.py``. - -.. code-block:: Python - - from .your_model_folder.tinynn import TinyNN - MULTI_DRUG_MODEL_FACTORY.update({"TinyNN": TinyNN}) - - - diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index 54428e3a4..000000000 --- a/docs/usage.rst +++ /dev/null @@ -1,459 +0,0 @@ -How to use DrEvalPy -=================== - -Here, we document how to run DrEval with our implemented models and datasets. You can either do this with the standalone -supplied here or with the associated Nextflow pipeline ``drugresponseeval``. We recommend the use of our Nextflow pipeline for computational -demanding runs and for improved reproducibility. -No knowledge of Nextflow is required to run it. The Nextflow pipeline is available on the `nf-core GitHub -`_, the corresponding documentation can be found -`here `_. Documentation of the standalone is provided below. - -Run a drug response experiment results with ``drevalpy`` ----------------------------------------------------------- - -You can run it the drug response pipeline, which can test drug response models via: - -.. code-block:: bash - - drevalpy --help - -Example: - -.. code-block:: bash - - drevalpy --run_id my_first_run --models NaiveDrugMeanPredictor ElasticNet --dataset_name TOYv1 --test_mode LCO - -*Note*: You need at least 7 CV splits to get a meaningful critical difference diagram and the corresponding p-values. - -.. option:: --run_id TEXT - - Identifier to save the results. [default: ``my_run``] - -.. option:: --path_data TEXT - - Path to the data directory. [default: ``data``] All data files should be stored in this directory and will be downloaded into this directory. The location of the datasets are resolved by ``//.csv``. If providing raw viability data, the file needs to be named ``_raw.csv`` instead and ``--no_refitting`` needs to be unspecified for automated curve fitting (thats the default) (see ``--no_refitting`` for details and also check the :ref:`usage:Custom Datasets` section). - -.. option:: --models TEXT - - Model to evaluate or list of models to compare. For a list of available models, see the :ref:`usage:Available Models` section. - -.. option:: --baselines TEXT - - List of baselines to evaluate. If NaiveMeanEffectsPredictor is not part of them, we will add it. For a list of available baselines, see the :ref:`usage:Available Models` section. The baselines are also hyperparameter-tuned and compared to - the models, but no randomization or robustness tests are run. - ``NaiveMeanEffectsPredictor`` is always run as it is required for evaluation. - -.. option:: --test_mode TEXT - - Which tests to run: - - - ``LPO`` — Leave-random-Pairs-Out - - ``LCO`` — Leave-Cell-line-Out - - ``LTO`` — Leave-Tissue-Out - - ``LDO`` — Leave-Drug-Out - - Can be a list, e.g. ``'LPO LCO LTO LDO'`` to run all tests. [default: ``LPO``]. For more information, see the :ref:`usage:Available Settings` section. - -.. option:: --randomization_mode TEXT - - Which randomization tests to run in addition to the normal run. - ``None`` disables randomization tests. For more information, see the :ref:`usage:Available Randomization Tests` section. Available modes: - - - ``SVCC`` — Single View Constant (while others are perturbed) for Cell Lines - - ``SVRC`` — Single View Random (while others are held constant) for Cell Lines - - ``SVCD`` — Single View Constant (while others are perturbed) for Drugs - - ``SVRD`` — Single View Random (while others are held constant) for Drugs - - Can be a list, e.g. ``'SVCC SVCD'``. - -.. option:: --randomization_type TEXT - - Type of randomization to use: - - - ``permutation`` — shuffles features over instances while preserving feature distributions - - ``invariant`` — preserves a key characteristic such as matrix mean/standard deviation or network degree - - [default: ``permutation``] - -.. option:: --n_trials_robustness INTEGER - - Number of trials for the robustness test. The robustness test trains the model with varying - seeds multiple times to check stability. ``0`` disables the robustness test. [default: ``0``]. For more information, see the :ref:`usage:Robustness Test` section. - -.. option:: --dataset_name TEXT - - Name of the dataset to use. For a list of available datasets, see the :ref:`usage:Available Datasets` section. For information on how to use custom datasets, see the :ref:`usage:Custom Datasets` section. [default: ``GDSC1``] - -.. option:: --cross_study_datasets TEXT - - List of datasets to use for cross-study prediction evaluation. [default: ``[]``] - -.. option:: --path_out TEXT - - Path to the output directory. [default: ``results/``] - -.. option:: --no_refitting - - If not set, the measure is appended with '_curvecurator'. If a custom dataset_name was provided, this will invoke the fitting procedure of raw viability data, which is expected to exist at ``//_raw.csv``. The fitted dataset will be stored in the same folder, in a file called ``.csv``. Also check the :ref:`usage:Custom Datasets` section. Default is False, i.e., curvecurated drug response measures are utilized. - -.. option:: --curve_curator_cores INTEGER - - Maximum number of cores used to fit curves with CurveCurator, capped at the number of - curves to fit. Only used when ``--no_refitting`` is not set. [default: ``1``] - -.. option:: --curve_curator_normalize - - Normalize response values to ``[0, 1]`` for CurveCurator. [default: ``False``] - -.. option:: --measure TEXT - - Drug response measure used as prediction target. If using one of the available datasets (see ``--dataset_name``), this is restricted to one of ['LN_IC50', 'EC50', 'IC50', 'pEC50', 'AUC', 'response']. This corresponds to the names of the columns that contain theses measures in the provided input dataset. If providing a custom dataset, this may differ. If the option ``--no_refitting`` is not set, the prefix '_curvecurator' is automatically appended, e.g. 'LN_IC50_curvecurator', to allow using the refit measures instead of the ones originally published for the available datasets, allowing for better dataset comparability (refit measures are already provided in the available datasets or computed as part of the fitting procedure when providing custom raw viability datasets, see ``--no_refitting`` for details). - [default: ``LN_IC50``] - -.. option:: --overwrite - - Overwrite existing results with the same ``--path_out`` and ``--run_id``. - -.. option:: --optim_metric TEXT - - Metric for hyperparameter tuning. For more information, see the :ref:`usage:Available Metrics` section. One of ``MSE``, ``RMSE``, ``MAE``, ``R^2``, - ``Pearson``, ``Spearman``, ``Kendall``. [default: ``RMSE``] - -.. option:: --wandb_project TEXT - - Optional Weights & Biases project name. If provided, enables wandb logging for all - ``DRPModel`` instances. - -.. option:: --n_cv_splits INTEGER - - Number of cross-validation splits. [default: ``7``] - -.. option:: --response_transformation TEXT - - Transformation applied to the response variable during training and prediction; - retransformed after final predictions. For more information, see the :ref:`usage:Available Response Transformations` section. One of ``standard``, ``minmax``, ``robust``. - -.. option:: --multiprocessing - - If set, we will use raytune for fitting. Default is False. [default: ``False``] - -.. option:: --model_checkpoint_dir TEXT - - Directory to save model checkpoints. [default: ``TEMPORARY``] - -.. option:: --final_model_on_full_data - - Save a final model trained and tuned on the union of all folds after cross-validation. - -.. option:: --no_hyperparameter_tuning - - Disable hyperparameter tuning and use the first hyperparameter set. - - -Visualize and evaluate results with ``drevalpy-report`` ------------------------------------------------------------- - -Executing the main script ``drevalpy`` will generate a folder with the results which includes the predictions of all models -in all specified settings. The ``drevalpy report`` CLI will evaluate the results with all available metrics and create an -HTML report with many visualizations. You can run it with the following command: - -.. code-block:: bash - - drevalpy report [-h] --run_id RUN_ID --dataset_name DATASET [--path_data PATH_DATA] [--result_path RESULT_PATH] - -Options: - -* ``-h, --help``: Show help message and exit. -* ``--run_id RUN_ID``: Identifier for the run which was used when executing the ``drevalpy`` command. -* ``--dataset_name DATASET``: Name of the dataset which was used when executing the ``drevalpy`` command. -* ``--path_data PATH_DATA``: Path to the data directory, default: data. -* ``--result_path RESULT_PATH``: Path to the results directory, default: results. - -Example: - -.. code-block:: bash - - drevalpy report --run_id my_first_run --dataset_name TOYv1 - -The report will be stored in the ``results/RUN_ID`` folder. -You can open the ``index.html`` file in your browser to view the report. - -Available Settings ------------------- - -DrEval is designed to ensure that drug response prediction models are evaluated in a consistent and -reproducible manner. We offer four settings via the ``--test_mode`` parameter: - -.. image:: ../drevalpy/visualization/style_utils/LPO.png - :width: 24% - :alt: Image visualizing the Leave-Pair-Out setting - -.. image:: ../drevalpy/visualization/style_utils/LCO.png - :width: 24% - :alt: Image visualizing the Leave-Cell-Line-Out setting - -.. image:: ../drevalpy/visualization/style_utils/LTO.png - :width: 24% - :alt: Image visualizing the Leave-Tissue-Out setting - -.. image:: ../drevalpy/visualization/style_utils/LDO.png - :width: 24% - :alt: Image visualizing the Leave-Drug-Out setting - -* **Leave-Pair-Out (LPO)**: Random pairs of cell lines and drugs are left out for validation/testing but both the drug and the - cell line might already be present in the training set. This is the **easiest setting** for your model but also the - most uninformative one. The only application scenario for this setting is when you want to test whether your model - can **complete the missing values in the training set**. -* **Leave-Cell-Line-Out (LCO)**: Random cell lines are left out for validation/testing but the drugs might already be present in - the training set. This setting is **more challenging** than LPO but still relatively easy. The application scenario - for this setting is when you want to test whether your model can **predict the response of a new cell line**. This - is very relevant for **personalized medicine**. -* **Leave-Tissue-Out (LTO)**: Random tissues are left out for validation/testing but the drugs might already be present in - the training set. This setting is **more challenging** than LCO. The application scenario - for this setting is when you want to test whether your model can **predict the response of a new tissue**. This - is very relevant for **drug repurposing**. -* **Leave-Drug-Out (LDO)**: Random drugs are left out for validation/testing but the cell lines might already be present in the - training set. This setting is the **most challenging** one. The application scenario for this setting is when you - want to test whether your model can **predict the response of a new drug**. This is very relevant for **drug - development**. - -An underlying issue is that drugs have a rather unique IC50 range. That means that by just predicting the mean IC50 -that a drug has in the training set (aggregated over all cell lines), you can already achieve a seemingly good -prediction (as evaluated by naive R^2 or correlation metrics). This is why we also offer the possibility to compare your model to a **NaivePredictor** that predicts -the mean IC50 of all drugs in the training set. We also offer several less naive predictors: -**NaiveCellLineMeanPredictor**, **NaiveDrugMeanPredictor**, **NaiveTissueMeanPredictor**, and **NaiveTissueDrugMeanPredictor**. -The **NaiveCellLineMeanPredictor** predicts the mean IC50 of a cell line in the training set, -the **NaiveDrugMeanPredictor** predicts the mean IC50 of a drug in the training set, -the **NaiveTissueMeanPredictor** predicts the mean IC50 of a tissue in the training set, -and the **NaiveTissueDrugMeanPredictor** predicts the mean IC50 per tissue-drug combination (aggregated across all cell lines with that tissue-drug pair). -The **NaiveMeanEffectPredictor** combines the effects of cell lines and drugs. -It is equivalent to the **NaiveCellLineMeanPredictor** and **NaiveDrugMeanPredictor** for the LDO and LCO settings, respectively, -as test cell line effects and drug effects are unknown in these settings. - -In LCO, **NaiveTissueDrugMeanPredictor** is the strongest baseline, while in all other settings, **NaiveMeanEffectPredictor** is the strongest. - -Available Models ------------------- -In addition to the Naive Predictors, we offer a variety of more advanced **baseline models** and -some **state-of-the-art models** to compare your model against. You can either set them as baselines or as models via the -``--models`` and ``--baselines`` parameters. -We first identify the best hyperparameters for all models and baselines in a cross-validation setting. Then, we -train the models on the whole training set and evaluate them on the test set. -For ``--models``, you can also perform randomization and robustness tests. The ``--baselines`` are skipped for these tests. - -The sklearn baseline models (AdaBoostDecisionTree, ElasticNet, GradientBoosting, KNNRegressor, Lasso, RandomForest, SVR, SingleDrugRandomForest, SingleDrugElasticNet), -MultiViewXGBoost, and the machine learning baselines (SimpleNeuralNetwork, MultiViewNeuralNetwork) support -**flexible inputs**: the input types can be configured via ``cell_line_views`` and ``drug_views`` in ``hyperparameters.yaml`` without -needing separate model classes. By default they use gene expression and fingerprints. -See the sklearn model :ref:`flexible-inputs` or the SimpleNeuralNetwork :ref:`flexible-inputs-simplenn` for details. - -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| Model Name | Baseline / Published Model | Multi-Drug Model / Single-Drug Model | Description | -+=================================+============================+======================================+============================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================================+ -| NaivePredictor | Baseline Method | Multi-Drug Model | Most simple method. Predicts the mean response of all drugs in the training set. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| NaiveCellLineMeanPredictor | Baseline Method | Multi-Drug Model | Predicts the mean response of the cell line in the training set. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| NaiveDrugMeanPredictor | Baseline Method | Multi-Drug Model | Predicts the mean response of the drug in the training set. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| NaiveMeanEffectsPredictor | Baseline Method | Multi-Drug Model | Predicts using ANOVA-like mean effect model of cell lines and drugs | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| NaiveTissueMeanPredictor | Baseline Method | Multi-Drug Model | Predicts the mean response of the tissue in the training set. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| NaiveTissueDrugMeanPredictor | Baseline Method | Multi-Drug Model | Predicts the mean response per tissue-drug combination in the training set (aggregated across all cell lines with that tissue-drug pair). Falls back to the overall dataset mean for unseen combinations. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| AdaBoostDecisionTree | Baseline Method | Multi-Drug Model | Fits an `Sklearn AdaBoost Regressor `_ with Decision Tree base estimators. Supports flexible inputs (default: gene expression or proteomics + fingerprints). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| ElasticNet | Baseline Method | Multi-Drug Model | Fits an `Sklearn Elastic Net `_, `Lasso `_, or `Ridge `_ model. Supports flexible inputs (default: gene expression or proteomics + fingerprints). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| Lasso | Baseline Method | Multi-Drug Model | Explicitly fits an `Sklearn Lasso `_ model. Supports flexible inputs (default: gene expression or proteomics + fingerprints). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| SingleDrugElasticNet | Baseline Method | Single-Drug Model | Fits an Elastic Net model for each drug separately. Supports flexible inputs (default: gene expression). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| GradientBoosting | Baseline Method | Multi-Drug Model | Fits an `Sklearn Histogram-based Gradient Boosting Regression Tree `_. Supports flexible inputs (default: gene expression or proteomics + fingerprints). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| MultiViewXGBoost | Baseline Method | Multi-Drug Model | Fits an `XGBoost XGBRegressor `_ on a single or multiple cell line views. Supports flexible inputs (default: gene expression or proteomics or [gene expression + methylation + mutations + copy number variation] + fingerprints). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| KNNRegressor | Baseline Method | Multi-Drug Model | Fits an `Sklearn KNNRegressor `_. Supports flexible inputs (default: gene expression or proteomics + fingerprints). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| RandomForest | Baseline Method | Multi-Drug Model | Fits an `Sklearn Random Forest Regressor `_. Supports flexible inputs (default: gene expression or proteomics + fingerprints). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| MultiViewRandomForest | Baseline Method | Multi-Drug Model | Fits an `Sklearn Random Forest Regressor `_ on multiple cell line views (default: gene expression, methylation, mutations, copy number variation) and drug fingerprints. Methylation dimensionality is reduced with PCA. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| SingleDrugRandomForest | Baseline Method | Single-Drug Model | Fits an `Sklearn Random Forest Regressor `_ for each drug separately. Supports flexible inputs (default: gene expression). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| SVR | Baseline Method | Multi-Drug Model | Fits an `Sklearn Support Vector Regressor `_. Supports flexible inputs (default: gene expression or proteomics + fingerprints). | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| SimpleNeuralNetwork | Baseline Method | Multi-Drug Model | Fits a simple feedforward neural network (implemented with `Pytorch Lightning `_) on flexible cell line and drug input (concatenated input) with 3 layers of varying dimensions and Dropout layers. Default: gene expression + fingerprints or drug_chemberta_embeddings. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| MultiViewNeuralNetwork | Baseline Method | Multi-Drug Model | Fits a simple feedforward neural network (implemented with `Pytorch Lightning `_) on flexible omic inputs (default: gene expression, methylation, mutation, copy number variation data), and drug fingerprints (concatenated input) with 3 layers of varying dimensions and Dropout layers. The dimensionality of the methylation data, if supplied, is reduced with a PCA to the first 100 components before it is fed to the model. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| DrugGNN | Baseline Method | Multi-Drug Model | Represents drugs as graph, encodes their structure with a 3-layer GNN. Uses a 2-layer MLP for encoding gene expression. Concatenates the representations and feeds them through 2 more MLP layers. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| PharmaFormer | Published Model | Multi-Drug Model | Transformer-based model using byte-pair encoded drug SMILES and gene expression features for drug response prediction. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| SRMF | Published Model | Multi-Drug Model | `Similarity Regularization Matrix Factorization `_ model by Wang et al. on gene expression data and drug fingerprints. Re-implemented Matlab code into Python. The basic idea is to represent each drug and each cell line by their respective similarities to all other drugs/cell lines. Those similarities are mapped into a shared latent low-dimensional space from which responses are predicted. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| MOLIR | Published Model | Single-Drug Model | Regression extension of `MOLI: multi-omics late integration deep neural network. `_ by Sharifi-Noghabi et al. Takes somatic mutation, copy number variation and gene expression data as input. MOLI reduces the dimensionality of each omics type with a hidden layer, concatenates them into one representation and optimizes this representation via a combined cost function consisting of a triplet loss and a binary cross-entropy loss. We implemented a regression adaption with MSE loss and an adapted triplet loss for regression.| -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| SuperFELTR | Published Model | Single-Drug Model | Regression extension of `SuperFELT: supervised feature extraction learning using triplet loss for drug response `_ by Park et al. Very similar to MOLI(R). In MOLI(R), encoders and the classifier were trained jointly. Super.FELT(R) trains them independently. MOLI(R) was trained without feature selection (except for the Variance Threshold on the gene expression). Super.FELT(R) uses feature selection for all omics data. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| DIPK | Published Model | Multi-Drug Model | `Deep neural network Integrating Prior Knowledge `_ from Li et al. Uses gene interaction relationships (encoded by a graph auto-encoder), gene expression profiles (encoded by a denoising auto-encoder), and molecular topologies (encoded by MolGNet). Those features are integrated using multi-head attention layers. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| Precily | Published Model | Multi-Drug Model | `Precily `_ from Chawla et al. Uses GSVA pathway-activity scores with SMILESVec drug embeddings. Features are concatenated and passed through multiple linear layers with ReLU and Dropout. | -+---------------------------------+----------------------------+--------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ - - -Available Datasets ------------------- -We provide commonly used datasets to evaluate your model on (GDSC1, GDSC2, CCLE, CTRPv2) via the ``--dataset_name`` parameter. -Further, we provide 2 datasets with more clinical relevance: BeatAML2 and PDX\_Bruna. - -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ -| Dataset Name | Number of DRP Curves | Number of Drugs | Number of Cell Lines| Description | -+===================+======================+=================+=====================+====================================================================================================================+ -| GDSC1 | 316,506 | 378 | 970 | The Genomics of Drug Sensitivity in Cancer (GDSC) dataset version 1. | -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ -| GDSC2 | 234,437 | 287 | 969 | The Genomics of Drug Sensitivity in Cancer (GDSC) dataset version 2. | -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ -| CCLE | 11,670 | 24 | 503 | The Cancer Cell Line Encyclopedia (CCLE) dataset. | -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ -| CTRPv1 | 60,758 | 354 | 243 | The Cancer Therapeutics Response Portal (CTRP) dataset version 1. | -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ -| CTRPv2 | 395,025 | 546 | 886 | The Cancer Therapeutics Response Portal (CTRP) dataset version 2. | -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ -| TOYv1 | 2,711 | 36 | 90 | A toy dataset for testing purposes subsetted from CTRPv2. | -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ -| TOYv2 | 2,784 | 36 | 90 | A second toy dataset for cross study testing purposes. 80 cell lines and 32 drugs overlap TOYv2. | -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ -| BeatAML2 | 62,487 | 166 | 569 (patients) | Ex vivo drug sensitivity screening for a cohort of acute myeloid leukemia (AML) patients. | -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ -| PDX\_Bruna | 2,559 | 104 | 37 (mouse passages) | Ex vivo drug sensitivity screening for short-term cultures of PDTX-derived tumor cells from breast cancer patients | -+-------------------+----------------------+-----------------+---------------------+--------------------------------------------------------------------------------------------------------------------+ - - -If not specifying ``--no_refitting`` option with these datasets (default: false), the desired measure provided with the ``--measure`` option is appended with "_curvecurator", e.g. "IC50_curvecurator". -In the provided datasets, these are the measures calculated with the same fitting procedure using CurveCurator. To use the measures reported from the original publications of the -dataset, use the ``--no_refitting`` option, which will use the original measures as provided in the datasets. - -This however makes it hard to do cross-study comparisons, since the measures may not be directly comparable due to differences in the fitting procedures used by the original authors. -It is therefore recommended to always use DrEvalPy without the ``--no_refitting`` option, which will lead to the use of the refitted measures that are calculated with the same procedure for all datasets. - -Corresponding feature data ---------------------------- -The datasets have corresponding cell-line and drug feature data. The sources are as follows: - -* GDSC1 & 2: - * Gene expression: RMA-normalized microarray expression data from the `GDSC Data Portal `_ (raw data). - * Methylation: Preprocessed Beta Values for all CpG islands, IlluminaHumanMethylation450 BeadChip `GDSC Data Portal `_. -* CCLE, CTRPv1, CTRPv2: - * Gene expression: reprocessed RNA-seq data PRJNA523380 - * Methylation: DepMap Beta Values for RRBS clusters ``CCLE_RRBS_TSS_CpG_clusters_20180614.txt`` -* Used by GDSC1, 2, CCLE, CTRPv1 and v2: - * Mutation & CNV data: `Sanger Cell Model Passports `_. - * Proteomics: Raw data at PRIDE: PXD030304 -* BeatAML2: - * Gene expression: RNA-seq but not re-processed because of missing FASTQ files. Taken from `the corresponding website `_ - * Mutation data would have been available but is measured too shallow, so we chose not to include it -* PDX\_Bruna: - * Retrieved from `the corresponding figshare `_ - * Gene expression: Microarray expression data - * Copy number variation: Reprocessed with GISTIC2.0 - * Mutation data would have been available but is measured too shallow, so we chose not to include it - * Methylation data would have been available but only Promoter methylation data which is incompatible with the CpG methylation data we have for the other screens. -* Drug features - * Morgan Fingerprints were generated with RDKit from SMILES either downloaded from PubChem or provided by GDSC. - * `DIPK associated drive `_ - * MolGNet features were generated from SMILES - * BIONIC features were generated from top expressed genes -* Gene lists - * The 978 landmark genes are from the L1000 assay - * The drug target genes are the genes targeted by the drugs used in GDSC, extractable from the `GDSC Data Portal `_ (compounds annotation). - * The intersection lists are features occurring in all datasets for the respective OMICs to ensure that cross-study predictions can easily be done because the features are shared. - * Reduced versions of the lists only containing genes occurring in all datasets - -For more information on the preprocessing, please refer to `the corresponding GitHub Repo `_. - -Custom Datasets ---------------- -You can also provide your own custom dataset via the ``--dataset_name`` parameter by specifying a name that is not in the list of the available datasets. -This can be prefit data (not recommended for comparability reasons) or raw viability data that is automatically fit with the exact same procedure that was used to refit -the available datasets in the previous section. - -**Raw viability data** - -* DrEvalPy expects a csv-formatted file in the location ``//_raw.csv`` (corresponding to the ``--path_data`` and ``--dataset_name`` options), which contains the raw viability data in long format with the columns ["dose", "response", "sample", "drug"] and an optional "replicate" column. If replicates are provided, the procedure will fit one curve per sample / drug pair using all replicates. -* **All dosages have to be provided in µM!** Drevalpy will compute the following response measures: - * pEC50_curvecurator: computed internally by CurveCurator. Is computed as -log10(EC50_curvecurator[M]). - * EC50_curvecurator: given in µM - * IC50_curvecurator: given in µM - * LN_IC50_curvecurator: computed from IC50_curvecurator - * AUC_curvecurator -* The option ``--curve_curator_cores`` must be set. ``--no_refitting`` must not be set. -* DrEvalPy provides all results of the fitting in the same folder including the fitted curves in a file folder ``//.csv`` - -**Prefit viability data** - -* DrEvalPy expects a csv-formatted file in the location ``//.csv`` (corresponding to the ``--path_data`` and ``--dataset_name`` options), - with at least the columns ["cell_line_id", "drug_id", "] where is replaced with the name of the measure you provide. -* For LTO, you must also provide a "tissue" column with tissue information -* Available measures depend on the column names and can be provided using the `--measure` option. -* It is required that you use measure names that are also working with the available datasets if you use the ``--cross_study_datasets`` option -* Your dataset will be read in with the DrugResponseDataset.from_csv method (drevalpy.datasets.dataset); :download:`Example response file <_static/example_data/response_example.csv>` would support the measure AUC. - -Available Randomization Tests ------------------------------ - -We offer the possibility to test how much the performance of your model deteriorates when you randomize the input training data. -We have several randomization modes and types available. - -The modes are supplied via ``--randomization_mode`` and the types via ``--randomization_type``.: - -* **SVCC: Single View Constant for Cell Lines:** A single cell line view (e.g., gene expression) is held unperturbed - while the others are randomized. -* **SVCD: Single View Constant for Drugs:** A single drug view (e.g., drug fingerprints) is held unperturbed while the - others are randomized. -* **SVRC: Single View Random for Cell Lines:** A single cell line view (e.g., gene expression) is randomized while the - others are held unperturbed. -* **SVRD: Single View Random for Drugs:** A single drug view (e.g., drug fingerprints) is randomized while the others - are held unperturbed. - -Currently, we support two ways of randomizing the data. The default is permututation. - -* **Permutation**: Permutes the features over the instances, keeping the distribution of the features the same but - dissolving the relationship to the target. -* **Invariant**: The randomization is done in a way that a key characteristic of the feature is preserved. In case - of matrices, this is the mean and standard deviation of the feature view for this instance, for networks it is the - degree distribution. - -Robustness Test ---------------- - -The robustness test is a test where the model is trained with varying seeds. This is done multiple times to see how -stable the model is. Via ``--n_trials_robustness``, you can specify the number of trials for the robustness tests. - -Available Metrics ------------------ - -We offer a variety of metrics to evaluate your model on. The default is the R^2 score. You can change the metric via -the ``--optim_metric`` parameter. The following metrics are available: - -* **R^2**: The coefficient of determination. The higher the better. -* **MSE**: The mean squared error. The lower the better. -* **RMSE**: The root mean squared error. The lower the better. -* **MAE**: The mean absolute error. The lower the better. -* **Pearson**: The Pearson correlation coefficient. The higher the better. -* **Spearman**: The Spearman correlation coefficient. The higher the better. -* **Kendall**: The Kendall correlation coefficient. The higher the better. -* **Normalized [R^2, Pearson, Spearman, Kendall]**: A version of the metric where the true and predicted response values are normalized by the predictions of the NaiveMeanEffectsPredictor. - -Available Response Transformations ----------------------------------- - -We offer the possibility to transform the response data before training the model. This can be done via the -``--response_transformation`` parameter. The following transformations are available: - -* **None**: No transformation is applied. -* **standard**: The `sklearn StandardScaler `_ is applied. -* **minmax**: The `sklearn MinMaxScaler `_ is applied. -* **robust**: The `sklearn RobustScaler `_ is applied. diff --git a/dreval_colab_demo.ipynb b/dreval_colab_demo.ipynb deleted file mode 100644 index b75dd68d3..000000000 --- a/dreval_colab_demo.ipynb +++ /dev/null @@ -1,2394 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "id": "QfvWIKUf0V5V" - }, - "source": [ - "# DrEvalPy Demo\n", - "You can execute the DrEval Framework either via Nextflow as nf-core pipeline or as Python standalone.\n", - "\n", - "Approximate runtime standalone demo: 38 minutes, Nextflow demo: 5 minutes" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": "!pip install drevalpy" - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T10:30:00.154463Z", - "start_time": "2025-11-20T10:30:00.134179Z" - } - }, - "cell_type": "code", - "source": [ - "import drevalpy\n", - "drevalpy.__version__" - ], - "outputs": [ - { - "data": { - "text/plain": [ - "'1.4.0'" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 1 - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "First let us see which dataset and models are already implemented in drevalpy.\n", - "You can test your own model on all the datasets and comapre your model to all.the implemented ones:" - ] - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T10:30:13.771977Z", - "start_time": "2025-11-20T10:30:02.952269Z" - } - }, - "source": [ - "from drevalpy.models import MODEL_FACTORY\n", - "from drevalpy.datasets import AVAILABLE_DATASETS\n", - "print(f\"Models: {list(MODEL_FACTORY.keys())}\")\n", - "print(f\"Dataset: {list(AVAILABLE_DATASETS.keys())}\")" - ], - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/judithbernett/miniforge3/envs/drevalpy/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", - " from .autonotebook import tqdm as notebook_tqdm\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Models: ['NaivePredictor', 'NaiveDrugMeanPredictor', 'NaiveCellLineMeanPredictor', 'NaiveMeanEffectsPredictor', 'NaiveTissueMeanPredictor', 'ElasticNet', 'RandomForest', 'SVR', 'SimpleNeuralNetwork', 'MultiOmicsNeuralNetwork', 'MultiOmicsRandomForest', 'GradientBoosting', 'SRMF', 'DIPK', 'ProteomicsRandomForest', 'ProteomicsElasticNet', 'DrugGNN', 'ChemBERTaNeuralNetwork', 'SingleDrugRandomForest', 'MOLIR', 'SuperFELTR', 'SingleDrugElasticNet', 'SingleDrugProteomicsElasticNet', 'SingleDrugProteomicsRandomForest']\n", - "Dataset: ['GDSC1', 'GDSC2', 'CCLE', 'TOYv1', 'TOYv2', 'CTRPv1', 'CTRPv2', 'BeatAML2', 'PDX_Bruna']\n" - ] - } - ], - "execution_count": 2 - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:06:56.387483Z", - "start_time": "2025-11-20T10:30:23.203782Z" - } - }, - "source": [ - "# let us first train a model on the toy dataset. It will download the dataset for you.\n", - "from drevalpy.experiment import drug_response_experiment\n", - "\n", - "naive_mean = MODEL_FACTORY[\"NaivePredictor\"] # a naive model that just predicts the training mean\n", - "enet = MODEL_FACTORY[\"ElasticNet\"] # An Elastic Net based on drug fingerprints and gene expression of 1000 landmark genes\n", - "simple_nn = MODEL_FACTORY[\"SimpleNeuralNetwork\"] # A neural network based on drug fingerprints and gene expression of 1000 landmark genes\n", - "\n", - "toyv2 = AVAILABLE_DATASETS[\"TOYv1\"](path_data=\"data\")\n", - "\n", - "drug_response_experiment(\n", - " models=[enet, simple_nn],\n", - " baselines=[naive_mean], # Ablation studies and robustness tests are not done for baselines.\n", - " response_data=toyv2,\n", - " n_cv_splits=2, # the number of cross validation splits. Should be higher in practice :)\n", - " test_mode=\"LCO\", # LCO means Leave-Cell-Line out. This means that the test and validation splits only contain unseed cell lines.\n", - " run_id=\"my_first_run\",\n", - " path_data=\"data\", # where the downloaded drug response and feature data is stored\n", - " path_out=\"results\", # results are stored here :)\n", - " hyperparameter_tuning=False) # if True (default), hyperparameters of the models and baselines are tuned." - ], - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2025-11-20 11:30:25,057\tINFO util.py:154 -- Missing packages: ['ipywidgets']. Run `pip install -U ipywidgets`, then restart the notebook server for rich notebook output.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Downloading TOYv1 from https://zenodo.org/api/records/17611663/files/TOYv1.zip/content...\n", - "TOYv1 data downloaded and extracted to data\n", - "Downloading meta from https://zenodo.org/api/records/17611663/files/meta.zip/content...\n", - "meta data downloaded and extracted to data\n", - "Creating cv splits at results/my_first_run/TOYv1/LCO/splits\n", - "Running ElasticNet\n", - "- Full Test -\n", - "\n", - "################# FOLD 1/2 #################\n", - "\n", - "Best hyperparameters: {'alpha': 1, 'l1_ratio': 0}\n", - "Training model on full train and validation set to predict test set\n", - "Loading cell line features ...\n", - "Loading drug features ...\n", - "Number of cell lines in features: 88\n", - "Number of drugs in features: 36\n", - "Number of cell lines in train dataset: 45\n", - "Number of drugs in train dataset: 36\n", - "Reduced training dataset from 889 to 858, due to missing features\n", - "Reduced prediction dataset from 887 to 871, due to missing features\n", - "Training model ...\n", - "Using temporary directory: /var/folders/3x/f8j9tddj7flfxt9zx1gkws1m0000gn/T/tmptnn7wkxp for model checkpoints\n", - "\n", - "################# FOLD 2/2 #################\n", - "\n", - "Best hyperparameters: {'alpha': 1, 'l1_ratio': 0}\n", - "Training model on full train and validation set to predict test set\n", - "Loading cell line features ...\n", - "Loading drug features ...\n", - "Number of cell lines in features: 88\n", - "Number of drugs in features: 36\n", - "Number of cell lines in train dataset: 45\n", - "Number of drugs in train dataset: 36\n", - "Reduced training dataset from 887 to 871, due to missing features\n", - "Reduced prediction dataset from 889 to 858, due to missing features\n", - "Training model ...\n", - "Using temporary directory: /var/folders/3x/f8j9tddj7flfxt9zx1gkws1m0000gn/T/tmp84_bfkh8 for model checkpoints\n", - "Running SimpleNeuralNetwork\n", - "- Full Test -\n", - "\n", - "################# FOLD 1/2 #################\n", - "\n", - "Best hyperparameters: {'dropout_prob': 0.3, 'max_epochs': 100, 'units_per_layer': [32, 16, 8, 4]}\n", - "Training model on full train and validation set to predict test set\n", - "Loading cell line features ...\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "GPU available: True (mps), used: True\n", - "TPU available: False, using: 0 TPU cores\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Loading drug features ...\n", - "Number of cell lines in features: 88\n", - "Number of drugs in features: 36\n", - "Number of cell lines in train dataset: 44\n", - "Number of drugs in train dataset: 36\n", - "Reduced early stopping dataset from 31 to 0\n", - "Training model ...\n", - "Using temporary directory: /var/folders/3x/f8j9tddj7flfxt9zx1gkws1m0000gn/T/tmp2k2op5hb for model checkpoints\n", - "SimpleNeuralNetwork: Early stopping dataset empty. Using training data for early stopping\n", - "Probably, your training dataset is small.\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "\n", - " | Name | Type | Params | Mode \n", - "--------------------------------------------------------------\n", - "0 | loss | MSELoss | 0 | train\n", - "1 | fully_connected_layers | ModuleList | 13.5 K | train\n", - "2 | batch_norm_layers | ModuleList | 120 | train\n", - "3 | dropout_layer | Dropout | 0 | train\n", - "--------------------------------------------------------------\n", - "13.6 K Trainable params\n", - "0 Non-trainable params\n", - "13.6 K Total params\n", - "0.054 Total estimated model params size (MB)\n", - "13 Modules in train mode\n", - "0 Modules in eval mode\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Epoch 0: 100%|██████████| 58/58 [00:01<00:00, 32.78it/s, v_num=0, train_loss_step=12.20]\n", - "Validation: | | 0/? [00:00\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
cell_line_namepubchem_idresponsepredictionstissue
0DU44751236311.6391122.327340Breast
1SK-MEL-311236313.3420292.933131Skin
2SK-MEL-246378582.0909052.584991Skin
3LNCaP clone FGC111526670.3595400.096984Prostate
4NCI-H21961236312.9121423.123437Lung
..................
866HCC114330623160.306898-0.228287Breast
867Karpas-299248210943.1245502.441677Lymph
868Namalwa24771867-1.506492-2.305591Lymph
869TE-10363141.665760-3.401662Esophagus
870Karpas-29911152667-4.530059-0.745779Lymph
\n", - "

871 rows × 5 columns

\n", - "" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 4 - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:31:54.051948Z", - "start_time": "2025-11-20T11:31:48.574322Z" - } - }, - "source": [ - "# you can generate your own evaluations from these predictions.\n", - "# However, we recommend using our evaluation pipeline, which calculates meaningful metrics, creates figures and prepares an HTML report:\n", - "from drevalpy.visualization.create_report import create_report\n", - "create_report(run_id=\"my_first_run\", dataset=\"TOYv1\")\n", - "\n", - "# this will create a report in the results/my_first_run/index.html which you can open in your browser." - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Generating result tables ...\n", - "Evaluating file: \"TOYv1/LCO/ElasticNet/predictions/predictions_split_0.csv\" ...\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_first_run/TOYv1/LCO/ElasticNet/predictions/predictions_split_0.csv\n", - "Calculating cell_line-wise evaluation measures …\n", - "Evaluating file: \"TOYv1/LCO/ElasticNet/predictions/predictions_split_1.csv\" ...\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_first_run/TOYv1/LCO/ElasticNet/predictions/predictions_split_1.csv\n", - "Calculating cell_line-wise evaluation measures …\n", - "Evaluating file: \"TOYv1/LCO/NaivePredictor/predictions/predictions_split_0.csv\" ...\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_first_run/TOYv1/LCO/NaivePredictor/predictions/predictions_split_0.csv\n", - "Calculating cell_line-wise evaluation measures …\n", - "Evaluating file: \"TOYv1/LCO/NaivePredictor/predictions/predictions_split_1.csv\" ...\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_first_run/TOYv1/LCO/NaivePredictor/predictions/predictions_split_1.csv\n", - "Calculating cell_line-wise evaluation measures …\n", - "Evaluating file: \"TOYv1/LCO/SimpleNeuralNetwork/predictions/predictions_split_0.csv\" ...\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_first_run/TOYv1/LCO/SimpleNeuralNetwork/predictions/predictions_split_0.csv\n", - "Calculating cell_line-wise evaluation measures …\n", - "Evaluating file: \"TOYv1/LCO/SimpleNeuralNetwork/predictions/predictions_split_1.csv\" ...\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_first_run/TOYv1/LCO/SimpleNeuralNetwork/predictions/predictions_split_1.csv\n", - "Calculating cell_line-wise evaluation measures …\n", - "Evaluating file: \"TOYv1/LCO/NaiveMeanEffectsPredictor/predictions/predictions_split_0.csv\" ...\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_first_run/TOYv1/LCO/NaiveMeanEffectsPredictor/predictions/predictions_split_0.csv\n", - "Calculating cell_line-wise evaluation measures …\n", - "Evaluating file: \"TOYv1/LCO/NaiveMeanEffectsPredictor/predictions/predictions_split_1.csv\" ...\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_first_run/TOYv1/LCO/NaiveMeanEffectsPredictor/predictions/predictions_split_1.csv\n", - "Calculating cell_line-wise evaluation measures …\n", - "Getting information about drugs and cell lines ...\n", - "Reformatting the evaluation results ...\n", - "Reformatting the evaluation results per cell line ...\n", - "Reformatting the true vs. predicted values ...\n", - "Generating report for LCO ...\n", - "Drawing Violin plots ...\n", - "Drawing Violin plots ...\n", - "Drawing heatmaps ...\n", - "Drawing heatmaps ...\n", - "Drawing scatterplots ...\n", - "Generating regression plots for cell_line_name, normalize=False, algorithm=SimpleNeuralNetwork...\n", - "Generating regression plots for cell_line_name, normalize=True, algorithm=SimpleNeuralNetwork...\n", - "Generating regression plots for cell_line_name, normalize=False, algorithm=ElasticNet...\n", - "Generating regression plots for cell_line_name, normalize=True, algorithm=ElasticNet...\n" - ] - } - ], - "execution_count": 5 - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:33:07.216005Z", - "start_time": "2025-11-20T11:32:45.310888Z" - } - }, - "source": [ - "# We prefer running this in the console:\n", - "!drevalpy --models RandomForest --dataset_name TOYv1 --n_cv_splits 2 --test_mode LPO --run_id my_second_run --no_hyperparameter_tuning\n", - "!drevalpy report --run_id my_second_run --dataset_name TOYv1" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Creating cv splits at results/my_second_run/TOYv1/LPO/splits\r\n", - "Running RandomForest\r\n", - "- Full Test -\r\n", - "\r\n", - "################# FOLD 1/2 #################\r\n", - "\r\n", - "Best hyperparameters: {'criterion': 'squared_error', 'max_depth': 5, 'max_samples': 0.2, 'n_estimators': 100, 'n_jobs': -1}\r\n", - "Training model on full train and validation set to predict test set\r\n", - "Loading cell line features ...\r\n", - "Loading drug features ...\r\n", - "Number of cell lines in features: 88\r\n", - "Number of drugs in features: 36\r\n", - "Number of cell lines in train dataset: 90\r\n", - "Number of drugs in train dataset: 36\r\n", - "Reduced training dataset from 888 to 865, due to missing features\r\n", - "Reduced prediction dataset from 888 to 864, due to missing features\r\n", - "Training model ...\r\n", - "Using temporary directory: /var/folders/3x/f8j9tddj7flfxt9zx1gkws1m0000gn/T/tmpmv442vyb for model checkpoints\r\n", - "\r\n", - "################# FOLD 2/2 #################\r\n", - "\r\n", - "Best hyperparameters: {'criterion': 'squared_error', 'max_depth': 5, 'max_samples': 0.2, 'n_estimators': 100, 'n_jobs': -1}\r\n", - "Training model on full train and validation set to predict test set\r\n", - "Loading cell line features ...\r\n", - "Loading drug features ...\r\n", - "Number of cell lines in features: 88\r\n", - "Number of drugs in features: 36\r\n", - "Number of cell lines in train dataset: 89\r\n", - "Number of drugs in train dataset: 36\r\n", - "Reduced training dataset from 888 to 864, due to missing features\r\n", - "Reduced prediction dataset from 888 to 865, due to missing features\r\n", - "Training model ...\r\n", - "Using temporary directory: /var/folders/3x/f8j9tddj7flfxt9zx1gkws1m0000gn/T/tmpppmlxz_8 for model checkpoints\r\n", - "Running NaiveMeanEffectsPredictor\r\n", - "- Only Baseline Tests -\r\n", - "\r\n", - "################# FOLD 1/2 #################\r\n", - "\r\n", - "Best hyperparameters: {}\r\n", - "Training model on full train and validation set to predict test set\r\n", - "Loading cell line features ...\r\n", - "Loading drug features ...\r\n", - "Number of cell lines in features: 90\r\n", - "Number of drugs in features: 36\r\n", - "Number of cell lines in train dataset: 89\r\n", - "Number of drugs in train dataset: 36\r\n", - "Training model ...\r\n", - "Using temporary directory: /var/folders/3x/f8j9tddj7flfxt9zx1gkws1m0000gn/T/tmp1ycyb7df for model checkpoints\r\n", - "\r\n", - "################# FOLD 2/2 #################\r\n", - "\r\n", - "Best hyperparameters: {}\r\n", - "Training model on full train and validation set to predict test set\r\n", - "Loading cell line features ...\r\n", - "Loading drug features ...\r\n", - "Number of cell lines in features: 90\r\n", - "Number of drugs in features: 36\r\n", - "Number of cell lines in train dataset: 89\r\n", - "Number of drugs in train dataset: 36\r\n", - "Training model ...\r\n", - "Using temporary directory: /var/folders/3x/f8j9tddj7flfxt9zx1gkws1m0000gn/T/tmp3lhqth94 for model checkpoints\r\n", - "Done!\r\n", - "Generating result tables ...\r\n", - "Evaluating file: \"TOYv1/LPO/RandomForest/predictions/predictions_split_0.csv\" ...\r\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_second_run/TOYv1/LPO/RandomForest/predictions/predictions_split_0.csv\r\n", - "Calculating drug-wise evaluation measures …\r\n", - "Calculating cell_line-wise evaluation measures …\r\n", - "Evaluating file: \"TOYv1/LPO/RandomForest/predictions/predictions_split_1.csv\" ...\r\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_second_run/TOYv1/LPO/RandomForest/predictions/predictions_split_1.csv\r\n", - "Calculating drug-wise evaluation measures …\r\n", - "Calculating cell_line-wise evaluation measures …\r\n", - "Evaluating file: \"TOYv1/LPO/NaiveMeanEffectsPredictor/predictions/predictions_split_0.csv\" ...\r\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_second_run/TOYv1/LPO/NaiveMeanEffectsPredictor/predictions/predictions_split_0.csv\r\n", - "Calculating drug-wise evaluation measures …\r\n", - "Calculating cell_line-wise evaluation measures …\r\n", - "Evaluating file: \"TOYv1/LPO/NaiveMeanEffectsPredictor/predictions/predictions_split_1.csv\" ...\r\n", - "Parsing file: /Users/judithbernett/PycharmProjects/drevalpy/results/my_second_run/TOYv1/LPO/NaiveMeanEffectsPredictor/predictions/predictions_split_1.csv\r\n", - "Calculating drug-wise evaluation measures …\r\n", - "Calculating cell_line-wise evaluation measures …\r\n", - "Getting information about drugs and cell lines ...\r\n", - "Reformatting the evaluation results ...\r\n", - "Reformatting the evaluation results per drug ...\r\n", - "Reformatting the evaluation results per cell line ...\r\n", - "Reformatting the true vs. predicted values ...\r\n", - "Generating report for LPO ...\r\n", - "Error in drawing critical difference plot: At least 3 sets of samples must be given for Friedman test, got 2.\r\n", - "Drawing Violin plots ...\r\n", - "Drawing Violin plots ...\r\n", - "Drawing heatmaps ...\r\n", - "Drawing heatmaps ...\r\n", - "Drawing scatterplots ...\r\n", - "Drawing scatterplots ...\r\n", - "Generating regression plots for drug_name, normalize=False, algorithm=RandomForest...\r\n", - "Generating regression plots for drug_name, normalize=True, algorithm=RandomForest...\r\n", - "Generating regression plots for cell_line_name, normalize=False, algorithm=RandomForest...\r\n", - "Generating regression plots for cell_line_name, normalize=True, algorithm=RandomForest...\r\n" - ] - } - ], - "execution_count": 6 - }, - { - "cell_type": "markdown", - "metadata": { - "id": "qWbDZA4X17Tj" - }, - "source": [ - "## Using the drevalpy nextflow pipeline for highly optimized runs:" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "7145560s6U-K" - }, - "source": [ - "You should use DrEval with Nextflow on high-performance clusters or clouds. Nextflow supports various systems like Slurm, AWS, Azure, Kubernetes, or SGE. On a local machine, you can also use the pipeline but probably, the overhang from spawning processes is not worth it so you might prefer the standalone. Nextflow needs a java version >=17, so we need to install that, too." - ] - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:34:36.534414Z", - "start_time": "2025-11-20T11:34:32.713036Z" - } - }, - "source": [ - "!pip install nextflow\n", - "!apt-get install openjdk-17-jre-headless -qq > /dev/null\n", - "!java --version" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Collecting nextflow\r\n", - " Downloading nextflow-25.10.0.tar.gz (7.7 kB)\r\n", - " Installing build dependencies ... \u001B[?25ldone\r\n", - "\u001B[?25h Getting requirements to build wheel ... \u001B[?25ldone\r\n", - "\u001B[?25h Preparing metadata (pyproject.toml) ... \u001B[?25ldone\r\n", - "\u001B[?25hBuilding wheels for collected packages: nextflow\r\n", - " Building wheel for nextflow (pyproject.toml) ... \u001B[?25ldone\r\n", - "\u001B[?25h Created wheel for nextflow: filename=nextflow-25.10.0-py3-none-any.whl size=7871 sha256=fa4251705ec9b8ea100573727ee2b922e26121c1c645159d17e46f469d74d953\r\n", - " Stored in directory: /Users/judithbernett/Library/Caches/pip/wheels/d9/3d/9f/f98531f3e6826cd9e58951157b2588a55a3426ecdb9b9b20dd\r\n", - "Successfully built nextflow\r\n", - "Installing collected packages: nextflow\r\n", - "Successfully installed nextflow-25.10.0\r\n", - "zsh:1: command not found: apt-get\r\n", - "openjdk 23.0.2 2025-01-21\r\n", - "OpenJDK Runtime Environment Homebrew (build 23.0.2)\r\n", - "OpenJDK 64-Bit Server VM Homebrew (build 23.0.2, mixed mode, sharing)\r\n" - ] - } - ], - "execution_count": 7 - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:34:43.437328Z", - "start_time": "2025-11-20T11:34:43.416328Z" - } - }, - "source": [ - "# we need a demo config for nextflow because on colab, we only have two CPUs available:\n", - "with open('demo.config', 'w') as f:\n", - " f.write('process {\\n')\n", - " f.write('\\tresourceLimits = [\\n')\n", - " f.write('\\t\\tcpus: 2,\\n')\n", - " f.write('\\t\\tmemory: \"3.GB\",\\n')\n", - " f.write('\\t\\ttime: \"1.h\",\\n')\n", - " f.write('\\t]\\n')\n", - " f.write('}')" - ], - "outputs": [], - "execution_count": 8 - }, - { - "cell_type": "markdown", - "metadata": { - "id": "wvc80Ahz6jj4" - }, - "source": [ - "We run the pipeline with the TOYv1 dataset which was subset from CTRPv2. For the demo, we don't do hyperparameter tuning and we just do 2 CV splits. We want to inspect the final model which is why we train a final model on the full dataset. This should take about 10 minutes.\n", - "If you were on a compute cluster, you could now decide if you want to run the pipeline inside conda, docker, singularity, ... via the -profile option (-profile singularity, e.g.). If you want the executor to be slurm/..., you can write this in your config. You can find plenty of config examples online, e.g., the one for our group: [daisybio](https://github.com/nf-core/configs/blob/master/conf/daisybio.config)\n" - ] - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:39:03.729669Z", - "start_time": "2025-11-20T11:34:56.894351Z" - } - }, - "source": [ - "!nextflow run nf-core/drugresponseeval -r dev -c demo.config --dataset_name TOYv1 --models ElasticNet --baselines NaiveMeanEffectsPredictor --n_cv_splits 2 --no_hyperparameter_tuning --final_model_on_full_data" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u001B[Knloading nextflow dependencies. It may require a few seconds, please wait .. \r\n", - "\u001B[1m\u001B[38;5;232m\u001B[48;5;43m N E X T F L O W \u001B[0;2m ~ \u001B[mversion 25.10.0\u001B[m\r\n", - "\u001B[K\r\n", - "Pulling nf-core/drugresponseeval ...\r\n", - " downloaded from https://github.com/nf-core/drugresponseeval.git\r\n", - "Launching\u001B[35m `https://github.com/nf-core/drugresponseeval` \u001B[0;2m[\u001B[0;1;36mgrave_mclean\u001B[0;2m] DSL2 - \u001B[36mrevision: \u001B[0;36mae31d78d85 [dev]\u001B[m\r\n", - "\u001B[K\r\n", - "\r\n", - "------------------------------------------------------\r\n", - " ,--./,-.\r\n", - " ___ __ __ __ ___ /,-._.--~'\r\n", - " |\\ | |__ __ / ` / \\ |__) |__ } {\r\n", - " | \\| | \\__, \\__/ | \\ |___ \\`-._,-`-,\r\n", - " `._,._,'\r\n", - " nf-core/drugresponseeval 1.1.1dev\r\n", - "------------------------------------------------------\r\n", - "\u001B[1mModel options\u001B[0m\r\n", - " \u001B[0;34mmodels : \u001B[0;32mElasticNet\u001B[0m\r\n", - "\r\n", - "\u001B[1mInput/output options\u001B[0m\r\n", - " \u001B[0;34mdataset_name : \u001B[0;32mTOYv1\u001B[0m\r\n", - "\r\n", - "\u001B[1mAdditional options\u001B[0m\r\n", - " \u001B[0;34mn_cv_splits : \u001B[0;32m2\u001B[0m\r\n", - " \u001B[0;34mno_hyperparameter_tuning: \u001B[0;32mtrue\u001B[0m\r\n", - " \u001B[0;34mfinal_model_on_full_data: \u001B[0;32mtrue\u001B[0m\r\n", - "\r\n", - "\u001B[1mGeneric options\u001B[0m\r\n", - " \u001B[0;34mtrace_report_suffix : \u001B[0;32m2025-11-20_12-35-05\u001B[0m\r\n", - "\r\n", - "\u001B[1mCore Nextflow options\u001B[0m\r\n", - " \u001B[0;34mrevision : \u001B[0;32mdev\u001B[0m\r\n", - " \u001B[0;34mrunName : \u001B[0;32mgrave_mclean\u001B[0m\r\n", - " \u001B[0;34mcontainer : \u001B[0;32mghcr.io/daisybio/drevalpy:v1.3.5\u001B[0m\r\n", - " \u001B[0;34mlaunchDir : \u001B[0;32m/Users/judithbernett/PycharmProjects/drevalpy\u001B[0m\r\n", - " \u001B[0;34mworkDir : \u001B[0;32m/Users/judithbernett/PycharmProjects/drevalpy/work\u001B[0m\r\n", - " \u001B[0;34mprojectDir : \u001B[0;32m/Users/judithbernett/.nextflow/assets/nf-core/drugresponseeval\u001B[0m\r\n", - " \u001B[0;34muserName : \u001B[0;32mjudithbernett\u001B[0m\r\n", - " \u001B[0;34mprofile : \u001B[0;32mstandard\u001B[0m\r\n", - " \u001B[0;34mconfigFiles : \u001B[0;32m/Users/judithbernett/.nextflow/assets/nf-core/drugresponseeval/nextflow.config, /Users/judithbernett/PycharmProjects/drevalpy/demo.config\u001B[0m\r\n", - "\r\n", - "!! Only displaying parameters that differ from the pipeline defaults !!\r\n", - "-\u001B[2m----------------------------------------------------\u001B[0m-\r\n", - "* The nf-core framework\r\n", - " https://doi.org/10.1038/s41587-020-0439-x\r\n", - "\r\n", - "* Software dependencies\r\n", - " https://github.com/nf-core/drugresponseeval/blob/main/CITATIONS.md\r\n", - "\r\n", - "Using existing response dataset TOYv1 from data/TOYv1/TOYv1.csv\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EEVAL:RUN_CV:\u001B[mLOAD_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…SPONSEEVAL:RUN_CV:\u001B[mCV_SPLIT -\u001B[K\r\n", - "\u001B[4A\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EEVAL:RUN_CV:\u001B[mLOAD_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…SPONSEEVAL:RUN_CV:\u001B[mCV_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NSEEVAL:RUN_CV:\u001B[mMAKE_MODELS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mMAKE_BASELINES -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ONSEEVAL:RUN_CV:\u001B[mHPAM_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…UN_CV:\u001B[mTRAIN_AND_PREDICT_CV -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…:MODEL_TESTING:\u001B[mFINAL_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[18A\r\n", - "\u001B[2mexecutor > local (2)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EEVAL:RUN_CV:\u001B[mLOAD_RESPONSE\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…SPONSEEVAL:RUN_CV:\u001B[mCV_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NSEEVAL:RUN_CV:\u001B[mMAKE_MODELS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mMAKE_BASELINES -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…UN_CV:\u001B[mTRAIN_AND_PREDICT_CV -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…:MODEL_TESTING:\u001B[mFINAL_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (3)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…SPONSEEVAL:RUN_CV:\u001B[mCV_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NSEEVAL:RUN_CV:\u001B[mMAKE_MODELS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mMAKE_BASELINES -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…UN_CV:\u001B[mTRAIN_AND_PREDICT_CV -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…:MODEL_TESTING:\u001B[mFINAL_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (3)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…SPONSEEVAL:RUN_CV:\u001B[mCV_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NSEEVAL:RUN_CV:\u001B[mMAKE_MODELS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mMAKE_BASELINES -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…UN_CV:\u001B[mTRAIN_AND_PREDICT_CV -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…:MODEL_TESTING:\u001B[mFINAL_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (5)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NSEEVAL:RUN_CV:\u001B[mMAKE_MODELS\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…UN_CV:\u001B[mTRAIN_AND_PREDICT_CV -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…:MODEL_TESTING:\u001B[mFINAL_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (6)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…UN_CV:\u001B[mTRAIN_AND_PREDICT_CV -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…:MODEL_TESTING:\u001B[mFINAL_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (6)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…UN_CV:\u001B[mTRAIN_AND_PREDICT_CV -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…:MODEL_TESTING:\u001B[mFINAL_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (6)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…UN_CV:\u001B[mTRAIN_AND_PREDICT_CV -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…:MODEL_TESTING:\u001B[mFINAL_SPLIT -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (8)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mf1/bb510f\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (10)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/a2a98f\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (10)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/a2a98f\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L_TESTING:\u001B[mTUNE_FINAL_MODEL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (12)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 3 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[19A\r\n", - "\u001B[2mexecutor > local (12)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 3 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…L:RUN_CV:\u001B[mEVALUATE_FIND_MAX -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[19A\r\n", - "\u001B[2mexecutor > local (14)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m69/df82e0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_0)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (14)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m69/df82e0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_0)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (16)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m92/07ac22\u001B[0;2m] \u001B[0;2m\u001B[mNFC…X\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_split_1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 2 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…MODEL_TESTING:\u001B[mPREDICT_FULL\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…NG:\u001B[mEVALUATE_FIND_MAX_FINAL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (18)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc8/0c3828\u001B[0;2m] \u001B[0;2mNFC…edictor_split_0_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (18)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc8/0c3828\u001B[0;2m] \u001B[0;2mNFC…edictor_split_0_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (19)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc6/52c44e\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_0_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (19)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc6/52c44e\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_0_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (20)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m19/5fbc2f\u001B[0;2m] \u001B[0;2mNFC…edictor_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (20)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc8/0c3828\u001B[0;2m] \u001B[0;2mNFC…edictor_split_0_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (21)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc6/52c44e\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_0_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 2 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (21)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc6/52c44e\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_0_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 2 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mTRAIN_FINAL_MODEL\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (22)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc6/52c44e\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_0_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 2 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (22)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m19/5fbc2f\u001B[0;2m] \u001B[0;2mNFC…edictor_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 3 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…DEL_TESTING:\u001B[mEVALUATE_FINAL\u001B[2m |\u001B[m 0 of 3\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (23)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m19/5fbc2f\u001B[0;2m] \u001B[0;2mNFC…edictor_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 3 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/19bdb7\u001B[0;2m] \u001B[0;2m\u001B[mNFC…r_predictions_split_0.csv)\u001B[2m |\u001B[m 0 of 3\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[19A\r\n", - "\u001B[2mexecutor > local (23)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/19bdb7\u001B[0;2m] \u001B[0;2m\u001B[mNFC…r_predictions_split_0.csv)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[19A\r\n", - "\u001B[2mexecutor > local (23)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/19bdb7\u001B[0;2m] \u001B[0;2m\u001B[mNFC…r_predictions_split_0.csv)\u001B[2m |\u001B[m 0 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[19A\r\n", - "\u001B[2mexecutor > local (24)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mbe/d1f525\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_0.csv)\u001B[2m |\u001B[m 1 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[19A\r\n", - "\u001B[2mexecutor > local (25)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc2/e7af6e\u001B[0;2m] \u001B[0;2m\u001B[mNFC…r_predictions_split_1.csv)\u001B[2m |\u001B[m 1 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (25)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mc2/e7af6e\u001B[0;2m] \u001B[0;2m\u001B[mNFC…r_predictions_split_1.csv)\u001B[2m |\u001B[m 1 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (26)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCONSOLIDATE_RESULTS\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7b/3228c0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_1.csv)\u001B[2m |\u001B[m 2 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (27)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mfd/c5eab4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7b/3228c0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_1.csv)\u001B[2m |\u001B[m 4 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (28)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma6/b52106\u001B[0;2m] \u001B[0;2m\u001B[mNFC…IDATE_RESULTS\u001B[33;2m (\u001B[0;33mElasticNet\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 2\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7b/3228c0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_1.csv)\u001B[2m |\u001B[m 4 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (28)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mfd/c5eab4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7b/3228c0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_1.csv)\u001B[2m |\u001B[m 4 of 4\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…EL_TESTING:\u001B[mCOLLECT_RESULTS -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (29)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mfd/c5eab4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7b/3228c0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_1.csv)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m2f/e0494d\u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCOLLECT_RESULTS\u001B[33;2m (\u001B[0;33m1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (29)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mfd/c5eab4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7b/3228c0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_1.csv)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m2f/e0494d\u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCOLLECT_RESULTS\u001B[33;2m (\u001B[0;33m1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…_TESTING:\u001B[mVISUALIZE_RESULTS -\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (30)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mfd/c5eab4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7b/3228c0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_1.csv)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m2f/e0494d\u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCOLLECT_RESULTS\u001B[33;2m (\u001B[0;33m1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m57/ee097a\u001B[0;2m] \u001B[0;2mNFC…TING:\u001B[mVISUALIZE_RESULTS\u001B[33;2m (\u001B[0;33m1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 0 of 1\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (30)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mfd/c5eab4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7b/3228c0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_1.csv)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m2f/e0494d\u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCOLLECT_RESULTS\u001B[33;2m (\u001B[0;33m1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m57/ee097a\u001B[0;2m] \u001B[0;2mNFC…TING:\u001B[mVISUALIZE_RESULTS\u001B[33;2m (\u001B[0;33m1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[20A\r\n", - "\u001B[2mexecutor > local (30)\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m27/079cb7\u001B[0;2m] \u001B[0;2mNFC…N_CV:\u001B[mLOAD_RESPONSE\u001B[33;2m (\u001B[0;33mTOYv1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m- \u001B[0;2m] \u001B[0;2mNFC…AL:RUN_CV:\u001B[mLOAD_CS_RESPONSE -\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/913635\u001B[0;2m] \u001B[0;2mNFC…EVAL:RUN_CV:\u001B[mCV_SPLIT\u001B[33;2m (\u001B[0;33mLCO\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/5338f4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…ODELS\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mb2/7bcc8d\u001B[0;2m] \u001B[0;2m\u001B[mNFC…LINES\u001B[33;2m (\u001B[0;33mMake model channel\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m63/47686a\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7a/2322ea\u001B[0;2m] \u001B[0;2mNFC…tsPredictor_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m4f/fdff95\u001B[0;2m] \u001B[0;2m\u001B[mNFC…nEffectsPredictor_split_1)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m60/2f90c4\u001B[0;2m] \u001B[0;2mNFC…sticNet_split_1_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m23/e49514\u001B[0;2m] \u001B[0;2m\u001B[mNFC…\u001B[33;2m (\u001B[0;33mElasticNet_LCO_gpu:null\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34ma1/0aef18\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m85/69a140\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NAL\u001B[33;2m (\u001B[0;33mLCO_ElasticNet_final\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mad/df8c4a\u001B[0;2m] \u001B[0;2mNFC…(ElasticNet_LCO_gpu:\u001B[mfalse)\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34mfd/c5eab4\u001B[0;2m] \u001B[0;2m\u001B[mNFC…NaiveMeanEffectsPredictor)\u001B[2m |\u001B[m 2 of 2\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m7b/3228c0\u001B[0;2m] \u001B[0;2m\u001B[mNFC…t_predictions_split_1.csv)\u001B[2m |\u001B[m 4 of 4\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m2f/e0494d\u001B[0;2m] \u001B[0;2mNFC…ESTING:\u001B[mCOLLECT_RESULTS\u001B[33;2m (\u001B[0;33m1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "\u001B[2m[\u001B[0;34m57/ee097a\u001B[0;2m] \u001B[0;2mNFC…TING:\u001B[mVISUALIZE_RESULTS\u001B[33;2m (\u001B[0;33m1\u001B[2m)\u001B[m\u001B[2m |\u001B[m 1 of 1\u001B[32m ✔\u001B[m\u001B[K\r\n", - "-\u001B[0;35m[nf-core/drugresponseeval]\u001B[0;32m Pipeline completed successfully\u001B[0m-\u001B[K\r\n", - "\u001B[33mWARN: Task runtime metrics are not reported when using macOS without a container engine\u001B[39m\u001B[K\r\n", - "\u001B[32;1mCompleted at: 20-Nov-2025 12:39:01\r\n", - "Duration : 3m 55s\r\n", - "CPU hours : 0.2\r\n", - "Succeeded : 30\r\n", - "\u001B[22;39m\u001B[K\r\n", - "\r\n" - ] - } - ], - "execution_count": 9 - }, - { - "cell_type": "markdown", - "metadata": { - "id": "hZ6AcGPDA-yo" - }, - "source": [ - "The results will be stored in `results/my_run`. You can inspect pipeline information like runtime or memory in `results/pipeline_info`. In `my_run/report`, you can find the html report where you can look at your results interactively. The underlying data is in `my_run/evaluation_results.csv` or `true_vs_pred.csv`.\n", - "\n", - "We now inspect the final model saved in `results/my_run/LCO/ElasticNet/final_model` with `drevalpy` functions." - ] - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:40:16.860364Z", - "start_time": "2025-11-20T11:40:16.674489Z" - } - }, - "source": [ - "from drevalpy.models import MODEL_FACTORY\n", - "enet_class = MODEL_FACTORY[\"ElasticNet\"]\n", - "enet = enet_class.load(\"results/my_run/LCO/ElasticNet/final_model\")\n", - "enet" - ], - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 10 - }, - { - "cell_type": "markdown", - "metadata": { - "id": "WT4P7OBsDgWq" - }, - "source": [ - "We now want to extract the top scoring features." - ] - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:40:20.227563Z", - "start_time": "2025-11-20T11:40:19.744577Z" - } - }, - "source": [ - "# get the top features\n", - "cell_line_input = enet.load_cell_line_features(data_path=\"data\", dataset_name=\"TOYv1\")\n", - "drug_input = enet.load_drug_features(data_path=\"data\", dataset_name=\"TOYv1\")\n", - "all_features = list(cell_line_input.meta_info['gene_expression'])+[f'fingerprint_{i}' for i in range(128)]" - ], - "outputs": [], - "execution_count": 11 - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:40:21.849934Z", - "start_time": "2025-11-20T11:40:21.776366Z" - } - }, - "source": [ - "import pandas as pd\n", - "df = pd.DataFrame({'feature': all_features, 'coef': enet.model.coef_})\n", - "df.sort_values(by=\"coef\", ascending=False)" - ], - "outputs": [ - { - "data": { - "text/plain": [ - " feature coef\n", - "303 fingerprint_33 0.948429\n", - "345 fingerprint_75 0.657288\n", - "386 fingerprint_116 0.581950\n", - "314 fingerprint_44 0.462468\n", - "293 fingerprint_23 0.446101\n", - ".. ... ...\n", - "335 fingerprint_65 -0.507304\n", - "342 fingerprint_72 -0.613356\n", - "393 fingerprint_123 -0.638303\n", - "298 fingerprint_28 -0.668780\n", - "344 fingerprint_74 -0.775386\n", - "\n", - "[398 rows x 2 columns]" - ], - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
featurecoef
303fingerprint_330.948429
345fingerprint_750.657288
386fingerprint_1160.581950
314fingerprint_440.462468
293fingerprint_230.446101
.........
335fingerprint_65-0.507304
342fingerprint_72-0.613356
393fingerprint_123-0.638303
298fingerprint_28-0.668780
344fingerprint_74-0.775386
\n", - "

398 rows × 2 columns

\n", - "
" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 12 - }, - { - "cell_type": "code", - "metadata": { - "ExecuteTime": { - "end_time": "2025-11-20T11:40:22.899208Z", - "start_time": "2025-11-20T11:40:22.881780Z" - } - }, - "source": [ - "print(\"Top 50 features:\")\n", - "list(df.sort_values(by=\"coef\", ascending=False)[\"feature\"][:50])" - ], - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Top 50 features:\n" - ] - }, - { - "data": { - "text/plain": [ - "['fingerprint_33',\n", - " 'fingerprint_75',\n", - " 'fingerprint_116',\n", - " 'fingerprint_44',\n", - " 'fingerprint_23',\n", - " 'fingerprint_4',\n", - " 'fingerprint_120',\n", - " 'fingerprint_61',\n", - " 'fingerprint_57',\n", - " 'fingerprint_112',\n", - " 'fingerprint_69',\n", - " 'fingerprint_109',\n", - " 'fingerprint_126',\n", - " 'fingerprint_31',\n", - " 'fingerprint_14',\n", - " 'fingerprint_50',\n", - " 'fingerprint_43',\n", - " 'fingerprint_121',\n", - " 'fingerprint_20',\n", - " 'fingerprint_47',\n", - " 'fingerprint_107',\n", - " 'fingerprint_110',\n", - " 'fingerprint_85',\n", - " 'fingerprint_24',\n", - " 'fingerprint_122',\n", - " 'fingerprint_63',\n", - " 'fingerprint_55',\n", - " 'fingerprint_91',\n", - " 'fingerprint_53',\n", - " 'fingerprint_30',\n", - " 'fingerprint_37',\n", - " 'fingerprint_62',\n", - " 'fingerprint_38',\n", - " 'fingerprint_78',\n", - " np.str_('CLPX'),\n", - " 'fingerprint_76',\n", - " 'fingerprint_92',\n", - " 'fingerprint_82',\n", - " 'fingerprint_64',\n", - " 'fingerprint_83',\n", - " 'fingerprint_87',\n", - " 'fingerprint_66',\n", - " 'fingerprint_3',\n", - " 'fingerprint_56',\n", - " 'fingerprint_118',\n", - " 'fingerprint_52',\n", - " np.str_('CPNE3'),\n", - " 'fingerprint_115',\n", - " np.str_('LIG1'),\n", - " np.str_('CAPN1')]" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "execution_count": 13 - }, - { - "cell_type": "markdown", - "metadata": { - "id": "yErEV4pCK6-B" - }, - "source": [ - "The fingerprints are the most important features as the drug identity is responsible for the most variation between responses." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "drp2", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.13.3" - } - }, - "nbformat": 4, - "nbformat_minor": 0 -} diff --git a/drevalpy/__init__.py b/drevalpy/__init__.py index 233d8a5a0..67cadc164 100644 --- a/drevalpy/__init__.py +++ b/drevalpy/__init__.py @@ -2,4 +2,13 @@ from importlib.metadata import version +from . import registry as registry +from ._run import run as run +from ._single import single as single +from .data import split as split +from .data.datasets import load as load +from .experiment import randomization as randomization +from .experiment import robustness as robustness +from .models import construct_model as construct_model + __version__ = version("drevalpy") diff --git a/drevalpy/_run.py b/drevalpy/_run.py new file mode 100644 index 000000000..baa8eecd7 --- /dev/null +++ b/drevalpy/_run.py @@ -0,0 +1,71 @@ +"""Top-level pipeline orchestrating models x folds x randomization.""" + +from __future__ import annotations + +from itertools import product + +from drevalpy._single import single +from drevalpy.data import load, split +from drevalpy.experiment._randomization import randomization +from drevalpy.experiment._robustness import robustness +from drevalpy.models.drp_model import DRPModel +from drevalpy.types import Dataset +from drevalpy.types.results import ExperimentResult, RunResult + + +def run( + models: list[type[DRPModel]], + dataset: Dataset | str, + split_mode: str, + randomization_modes: list[str] | None = None, + randomization_type: str = "permutation", + hyperparameter_tuning: bool = True, + hpo_metric: str = "RMSE", + hpo_num_samples: int = 16, + hpo_random_state: int = 42, + robustness_trials: int = 0, + precomputed_only: bool = False, +) -> ExperimentResult: + """Run the full experiment pipeline. + + :param models: Model classes to evaluate. + :param dataset: Dataset object or name to load. + :param split_mode: Split mode (LPO, LCO, LDO, LTO). + :param randomization_modes: Optional randomization modes (SVRC, SVCC, SVRD, SVCD). + :param randomization_type: "permutation" or "invariant". + :param hyperparameter_tuning: Whether to run HPO. + :param hpo_metric: Metric to optimize during HPO. + :param hpo_num_samples: Number of HPO trials. + :param hpo_random_state: Random seed for HPO. + :param robustness_trials: Number of robustness permutations (0 = disabled). + :param precomputed_only: Restrict HPO to pre-computed featurizer variants. + :returns: ExperimentResult grouping all model results. + """ + ds = load(dataset) if isinstance(dataset, str) else dataset + folds = split(ds, split_mode) + + if robustness_trials > 0: + folds = [s for fold in folds for s in robustness(fold, robustness_trials)] + + run_results: list[RunResult] = [] + + for model, split_masks in product(models, folds): + run_datasets: list[Dataset] = [ds] + + if randomization_modes: + run_datasets.extend(randomization(model, ds, randomization_modes, randomization_type=randomization_type)) + + for run_ds in run_datasets: + result = single( + model, + run_ds, + split_masks, + hyperparameter_tuning=hyperparameter_tuning, + hpo_metric=hpo_metric, + hpo_num_samples=hpo_num_samples, + hpo_random_state=hpo_random_state, + precomputed_only=precomputed_only, + ) + run_results.append(result) + + return ExperimentResult(run_results) diff --git a/drevalpy/_single.py b/drevalpy/_single.py new file mode 100644 index 000000000..57c0ebf81 --- /dev/null +++ b/drevalpy/_single.py @@ -0,0 +1,143 @@ +"""Single model + single fold execution unit.""" + +from __future__ import annotations + +import tempfile +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.evaluation import AVAILABLE_METRICS, _compute_metric_value +from drevalpy.log import get_logger +from drevalpy.models.drp_model import DRPModel +from drevalpy.models.tuning.config import build_experiment_hpo_config +from drevalpy.models.tuning.hpo import hpam_tune +from drevalpy.types import SplitMask, SplitMasks +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.results.run import RunResult +from drevalpy.types.results.trial import TrialResult +from drevalpy.utils.response_transform import fit_response_transformation + +if TYPE_CHECKING: + from sklearn.base import TransformerMixin + +logger = get_logger(__name__) + + +def single( + model_class: type[DRPModel], + mudataset: Dataset, + split_masks: SplitMasks, + *, + hyperparameter_tuning: bool = True, + response_transformation: TransformerMixin | None = None, + hpo_metric: str = "RMSE", + hpo_num_samples: int = 16, + hpo_random_state: int = 42, + precomputed_only: bool = False, +) -> RunResult: + """Train a single model on a single fold and predict on the test set. + + :param model_class: DRPModel subclass to train. + :param mudataset: Full dataset with all features. + :param split_masks: Single fold's train/test/val boolean masks. + :param hyperparameter_tuning: Whether to run HPO. + :param response_transformation: Optional unfitted sklearn transformer prototype; a clone is + fitted per scope and the caller's instance is left untouched. + :param hpo_metric: Metric to optimize during HPO. + :param hpo_num_samples: Number of HPO trials. + :param hpo_random_state: Random seed for HPO. + :param precomputed_only: Restrict HPO to pre-computed featurizer variants. + :returns: RunResult with predictions, ground truth, and metrics. + """ + model_name = model_class.get_model_name() + logger.info("Run: %s, fold %d", model_name, split_masks.metadata.get("fold_index", 0)) + + early_stopping_scope: SplitMask | None = None + val_scope = split_masks.val + if model_class.supports_early_stopping() and len(split_masks.val) > 1: + early_stopping_scope, val_scope = split_masks.early_stopping_mask() + + trials: list[TrialResult] | None = None + if hyperparameter_tuning: + hpo_cfg = build_experiment_hpo_config( + hpo_metric, + n_trials=hpo_num_samples, + random_state=hpo_random_state, + ) + best_hpams, raw_trials = hpam_tune( + model_class=model_class, + mudataset=mudataset, + train_scope=split_masks.train, + val_scope=val_scope, + early_stopping_scope=early_stopping_scope, + response_transformation=response_transformation, + metric=hpo_metric, + model_checkpoint_dir=None, + hpo_config=hpo_cfg, + precomputed_only=precomputed_only, + ) + trials = [ + TrialResult( + hyperparameters=params, + metrics=trial_metrics, + optimization_metric=hpo_metric, + predictions=preds, + ) + for params, trial_metrics, preds in raw_trials + ] + else: + best_hpams = model_class.get_default_hyperparameters() + + logger.info("Best hyperparameters: %s", best_hpams) + + model = model_class(best_hpams) + + train_scope = split_masks.train_val + fold_transform = fit_response_transformation(response_transformation, mudataset, train_scope) + + with tempfile.TemporaryDirectory() as checkpoint_dir: + model.train( + mudataset=mudataset, + scope=train_scope, + early_stopping_scope=early_stopping_scope, + model_checkpoint_dir=checkpoint_dir, + response_transformation=fold_transform, + ) + + predictions = model.predict(mudataset=mudataset, scope=split_masks.test) + + if fold_transform is not None: + predictions = fold_transform.inverse_transform(predictions.reshape(-1, 1)).ravel() + + response_matrix = mudataset.response_matrix + test_pairs = split_masks.test.pairs + ground_truth = response_matrix[test_pairs[:, 0], test_pairs[:, 1]] + + cl_ids = mudataset.cell_line_ids[test_pairs[:, 0]] + dr_ids = mudataset.drug_ids[test_pairs[:, 1]] + + valid = ~np.isnan(predictions) & ~np.isnan(ground_truth) + metrics: dict[str, float] = {} + if valid.any(): + for metric_name in AVAILABLE_METRICS: + metrics[metric_name] = _compute_metric_value(metric_name, predictions[valid], ground_truth[valid]) + + return RunResult( + model_name=model_name, + dataset_name=mudataset.name, + split_mode=split_masks.metadata.get("split_mode", ""), + fold_index=split_masks.metadata.get("fold_index", 0), + fold_id=split_masks.metadata.get("fold_id", ""), + predictions=predictions, + ground_truth=ground_truth, + cell_line_ids=cl_ids, + drug_ids=dr_ids, + best_hyperparameters=best_hpams, + metrics=metrics, + # Copied: the caller's SplitMasks.metadata is reused across models and + # folds by run(), so the result must not alias it. + fold_metadata=dict(split_masks.metadata), + trials=trials, + randomization=mudataset.randomization, + ) diff --git a/drevalpy/cli/__init__.py b/drevalpy/cli/__init__.py index d2752e4df..50de6baed 100644 --- a/drevalpy/cli/__init__.py +++ b/drevalpy/cli/__init__.py @@ -1,5 +1,7 @@ """Typer-based CLI for drevalpy.""" -from drevalpy.cli.main import app, cli_main +from __future__ import annotations + +from .main import app, cli_main __all__ = ["app", "cli_main"] diff --git a/drevalpy/cli/_helpers.py b/drevalpy/cli/_helpers.py deleted file mode 100644 index bc0008ac2..000000000 --- a/drevalpy/cli/_helpers.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Small helpers shared by Typer command modules.""" - -from __future__ import annotations - -from argparse import Namespace -from typing import Any - -ROOT_LIST_OPTIONS = frozenset( - { - "--models", - "--baselines", - "--test_mode", - "--randomization_mode", - "--cross_study_datasets", - } -) - -SUBCOMMAND_LIST_OPTIONS: dict[str, frozenset[str]] = { - "evaluate-hpams": frozenset({"--hpam_yamls", "--pred_datas"}), - "collect-results": frozenset({"--outfiles"}), - "make-pipeline-report": frozenset({"--test_modes"}), - "test-cv": frozenset({"--cross_study_datasets"}), - "consolidate-single-drug": frozenset({"--cross_study_datasets"}), -} - -KNOWN_SUBCOMMANDS = frozenset(SUBCOMMAND_LIST_OPTIONS) | frozenset( - { - "viability-preprocess", - "viability-postprocess", - "load-response", - "make-cv-pkls", - "make-hpam-yamls", - "train-cv", - "make-randomization-yamls", - "make-final-split-pkls", - "tune-final-model", - "train-final-model", - "evaluate-test", - "report", - } -) - -# Backward-compatible alias used in tests and callers that override list options explicitly. -LIST_OPTIONS = ROOT_LIST_OPTIONS | frozenset().union(*SUBCOMMAND_LIST_OPTIONS.values()) - - -def _is_option_token(token: str) -> bool: - """Return whether *token* begins a new CLI option.""" - return token.startswith("-") and token not in {"-", "--"} - - -def _active_list_options(argv: list[str]) -> frozenset[str]: - """Return list-option names valid for the command invoked by *argv*.""" - for token in argv: - if not _is_option_token(token) and token in KNOWN_SUBCOMMANDS: - return SUBCOMMAND_LIST_OPTIONS.get(token, frozenset()) - return ROOT_LIST_OPTIONS - - -def normalize_list_argv(argv: list[str], list_options: frozenset[str] | None = None) -> list[str]: - """Expand argparse-style space-separated list options for Typer/Click. - - Converts ``--models A B`` into ``--models A --models B`` while leaving - repeated-flag syntax unchanged. List-option names depend on whether the - root pipeline or a subcommand is being invoked, so scalar flags such as - ``--test_mode`` on ``test-cv`` are not expanded. - - :param argv: Command-line tokens without the program name. - :param list_options: Optional override for tests; defaults to context-aware options. - :return: Normalized argv suitable for Typer/Click list options. - """ - active_options = list_options if list_options is not None else _active_list_options(argv) - normalized: list[str] = [] - index = 0 - while index < len(argv): - token = argv[index] - if token not in active_options: - normalized.append(token) - index += 1 - continue - - index += 1 - values: list[str] = [] - while index < len(argv) and not _is_option_token(argv[index]): - values.append(argv[index]) - index += 1 - - if not values: - normalized.append(token) - continue - - for value in values: - normalized.extend([token, value]) - - return normalized - - -def as_list(value: list[str] | tuple[str, ...] | None) -> list[str]: - """Normalize Typer list options to a plain list. - - :param value: Optional sequence from a Typer multi-value option. - :return: A plain list (empty when *value* is ``None``). - """ - if value is None: - return [] - return list(value) - - -def pipeline_namespace(**kwargs: Any) -> Namespace: - """Build an ``argparse.Namespace`` for the full-pipeline entry point. - - :param kwargs: Pipeline option names and values. - :return: Namespace consumed by ``drevalpy.utils.main``. - """ - return Namespace(**kwargs) diff --git a/drevalpy/cli/_legacy.py b/drevalpy/cli/_legacy.py deleted file mode 100644 index 2d922f332..000000000 --- a/drevalpy/cli/_legacy.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Deprecation helpers for legacy console script entry points.""" - -from __future__ import annotations - -import warnings - - -def warn_deprecated(*, legacy_script: str, replacement: str) -> None: - """Emit a deprecation warning for a legacy ``drevalpy-*`` console script. - - :param legacy_script: Former console script name (e.g. ``drevalpy-train-cv``). - :param replacement: Suggested replacement command (e.g. ``drevalpy train-cv``). - """ - warnings.warn( - f"{legacy_script} is deprecated; use `{replacement}` instead.", - FutureWarning, - stacklevel=3, - ) diff --git a/drevalpy/cli/aggregate.py b/drevalpy/cli/aggregate.py new file mode 100644 index 000000000..c43bfc4a5 --- /dev/null +++ b/drevalpy/cli/aggregate.py @@ -0,0 +1,28 @@ +"""``drevalpy aggregate`` command.""" + +from __future__ import annotations + +from typing import Annotated + +import typer +from upath import UPath + + +def aggregate_cmd( + results: Annotated[list[str], typer.Argument(help="Paths to RunResult .npz files.")], + output_dir: Annotated[ + str, typer.Option("--output-dir", "-o", help="Output directory for the ExperimentResult.") + ] = "experiment_results", +) -> None: + """Aggregate parallel RunResult files into an ExperimentResult.""" + from drevalpy.types.results import ExperimentResult, RunResult + + out = UPath(output_dir) + out.mkdir(parents=True, exist_ok=True) + + run_results = [RunResult.load(path) for path in results] + experiment = ExperimentResult(run_results) + experiment.save(str(out)) + + typer.echo(f"Aggregated {len(run_results)} runs into ExperimentResult at {out}") + typer.echo(repr(experiment)) diff --git a/drevalpy/cli/catalog/__init__.py b/drevalpy/cli/catalog/__init__.py new file mode 100644 index 000000000..8190288dd --- /dev/null +++ b/drevalpy/cli/catalog/__init__.py @@ -0,0 +1,29 @@ +"""``drevalpy list`` command group: what is registered in this environment.""" + +from __future__ import annotations + +import typer + +from .plugins import list_plugins +from .registries import ( + list_cell_line_featurizers, + list_drug_featurizers, + list_predictors, + list_splitters, + list_visualizations, +) + +list_app = typer.Typer( + name="list", + help="List registered components, splitters, visualizations and plugins.", + no_args_is_help=True, +) + +list_app.command("predictors")(list_predictors) +list_app.command("cell-line-featurizers")(list_cell_line_featurizers) +list_app.command("drug-featurizers")(list_drug_featurizers) +list_app.command("splitters")(list_splitters) +list_app.command("visualizations")(list_visualizations) +list_app.command("plugins")(list_plugins) + +__all__ = ["list_app"] diff --git a/drevalpy/cli/catalog/_render.py b/drevalpy/cli/catalog/_render.py new file mode 100644 index 000000000..e3ea88117 --- /dev/null +++ b/drevalpy/cli/catalog/_render.py @@ -0,0 +1,186 @@ +"""Rich rendering helpers shared by the ``drevalpy list`` commands. + +Every cell is handed to rich as a :class:`~rich.text.Text` instance rather than a +markup string: registry descriptions are free-form author text and a stray +``[...]`` in one of them would otherwise be swallowed as a style tag. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from enum import Enum +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - import-time only for type checkers + import pandas as pd + from rich.console import Console + from rich.table import Table + from rich.text import Text + +#: Placeholder for an empty cell, so a blank column reads as "nothing here" +#: rather than as a rendering bug. +EMPTY_CELL = "-" + + +def console() -> Console: + """Build a console for one render pass. + + Constructed per call rather than at import time because click's + ``CliRunner`` swaps ``sys.stdout`` for each invocation, and because terminal + width is then measured when the output is actually produced. + + Returns: + A fresh :class:`~rich.console.Console`. + """ + from rich.console import Console + + return Console() + + +def format_value(value: Any) -> str: + """Render one registry value as display text. + + Args: + value: Value taken from a registry table cell or metadata mapping. + + Returns: + Human-readable text: collections become comma-separated, enums become + their member name, booleans become ``yes``/``no``, and anything empty + becomes :data:`EMPTY_CELL`. + """ + if value is None: + return EMPTY_CELL + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, Enum): + return str(value.name) + if isinstance(value, frozenset | set): + return ", ".join(sorted(format_value(item) for item in value)) or EMPTY_CELL + if isinstance(value, list | tuple): + return ", ".join(format_value(item) for item in value) or EMPTY_CELL + return str(value).strip() or EMPTY_CELL + + +def _text(cell: str | Text) -> Text: + """Coerce a cell to rich text with markup disabled. + + Args: + cell: Either display text or an already-styled ``Text``. + + Returns: + The ``Text`` to hand to rich. + """ + from rich.text import Text + + return cell if isinstance(cell, Text) else Text(cell) + + +def _table(title: str, columns: Iterable[str], caption: str | None = None) -> Table: + """Build an empty table with folding columns. + + Args: + title: Heading printed above the table. + columns: Column headers, in order. + caption: Optional line printed below the table. + + Returns: + A table ready for :meth:`~rich.table.Table.add_row`. + """ + from rich import box + from rich.table import Table + + table = Table( + title=title, + caption=caption, + box=box.SIMPLE_HEAD, + title_justify="left", + caption_justify="left", + header_style="bold", + pad_edge=False, + ) + for column in columns: + # Long descriptions wrap inside their column instead of being truncated + # or pushing the table past the terminal width. + table.add_column(column, overflow="fold") + return table + + +def render_empty(hint: str) -> None: + """Print the stand-in shown when there is nothing to list. + + Args: + hint: Sentence explaining what was empty and, ideally, why. + """ + from rich.text import Text + + console().print(Text(hint, style="yellow")) + + +def render_rows( + rows: Sequence[Sequence[str | Text]], + *, + columns: Sequence[str], + title: str, + empty_hint: str, + show_count: bool = True, +) -> None: + """Print pre-formatted rows as a table. + + Args: + rows: One sequence of cells per row, matching ``columns`` in length. + columns: Column headers, in order. + title: Heading printed above the table. + empty_hint: Printed instead of the table when ``rows`` is empty. + show_count: Add a row-count caption below the table. + """ + if not rows: + render_empty(empty_hint) + return + table = _table(title, columns, caption=_count_caption(len(rows)) if show_count else None) + for row in rows: + table.add_row(*(_text(cell) for cell in row)) + console().print(table) + + +def render_frame(frame: pd.DataFrame, *, title: str, empty_hint: str) -> None: + """Print a registry ``table()`` DataFrame. + + Args: + frame: DataFrame as returned by any registry's ``table()``. + title: Heading printed above the table. + empty_hint: Printed instead of the table when the frame has no rows. + """ + if frame.empty: + render_empty(empty_hint) + return + rows = [[format_value(value) for value in row] for row in frame.itertuples(index=False)] + render_rows(rows, columns=[str(column) for column in frame.columns], title=title, empty_hint=empty_hint) + + +def render_mapping(data: Mapping[str, Any], *, title: str) -> None: + """Print a metadata mapping as a two-column field/value table. + + Args: + data: Mapping as returned by any registry's ``metadata()``. + title: Heading printed above the table, normally the entry's name. + """ + rows = [[str(key), format_value(value)] for key, value in data.items()] + render_rows( + rows, + columns=["Field", "Value"], + title=title, + empty_hint=f"No metadata recorded for {title}.", + show_count=False, + ) + + +def _count_caption(count: int) -> str: + """Return the row-count caption for a table. + + Args: + count: Number of rows rendered. + + Returns: + Caption text with the count correctly pluralised. + """ + return f"{count} entry" if count == 1 else f"{count} entries" diff --git a/drevalpy/cli/catalog/plugins.py b/drevalpy/cli/catalog/plugins.py new file mode 100644 index 000000000..418fb113a --- /dev/null +++ b/drevalpy/cli/catalog/plugins.py @@ -0,0 +1,229 @@ +"""``drevalpy list plugins`` command: which installed plugins loaded, and why not. + +A plugin that raises on import silently removes every component it would have +registered, which otherwise resurfaces much later as an "unknown predictor" +error. :func:`drevalpy.registry.get_failed_plugins` records those failures; this +command is where they become visible, so it doubles as the smoke test for a +plugin repository's CI. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import ModuleType +from typing import TYPE_CHECKING, Annotated + +import typer + +if TYPE_CHECKING: # pragma: no cover - import-time only for type checkers + from rich.text import Text + +STATUS_LOADED = "loaded" +STATUS_FAILED = "failed" +STATUS_NOT_LOADED = "not loaded" + +_STATUS_STYLES = { + STATUS_LOADED: "green", + STATUS_FAILED: "bold red", + STATUS_NOT_LOADED: "yellow", +} + +_NO_PLUGINS_HINT = ( + "No packages declare a drevalpy.plugins entry point in this environment. " + "If you expected one, check that it is installed into this interpreter." +) + + +def _registry_module() -> ModuleType: + """Import :mod:`drevalpy.registry`, reporting a strict-mode failure cleanly. + + With ``DREVALPY_STRICT_PLUGINS`` set, plugin discovery re-raises, and that + happens on import of the registry package. Diagnosing a broken plugin is + exactly what this command is for, so the traceback is printed as output + instead of escaping as an unhandled crash. + + Returns: + The imported :mod:`drevalpy.registry` module. + + Raises: + typer.Exit: With code 1 when the import failed. + """ + import importlib + + from ._render import console + + try: + return importlib.import_module("drevalpy.registry") + except Exception as error: # noqa: BLE001 - strict mode surfaces the plugin's own exception + import traceback + + from rich.text import Text + + out = console() + out.print(Text("Importing drevalpy.registry failed while loading plugins.", style="bold red")) + out.print(Text(traceback.format_exc().rstrip())) + raise typer.Exit(1) from error + + +def _declared_entry_points() -> dict[str, str]: + """Return the ``drevalpy.plugins`` entry points declared in this environment. + + Returns: + Mapping of entry-point name to the dotted object it points at. Includes + entry points that failed to load, which is why it is read from + ``importlib.metadata`` rather than from the loaded-plugins registry. + """ + import importlib.metadata + + # The group name lives with the loader that consumes it; duplicating the + # string here would let the two drift apart silently. + from drevalpy.registry._plugins import ENTRY_POINT_GROUP + + return {ep.name: ep.value for ep in importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)} + + +def failure_reason(traceback_text: str) -> str: + """Reduce a formatted traceback to its actionable last line. + + Args: + traceback_text: Traceback as recorded by + :func:`drevalpy.registry.get_failed_plugins`. + + Returns: + The exception line (type and message), or a placeholder when the + traceback is empty. + """ + lines = [line.strip() for line in traceback_text.strip().splitlines() if line.strip()] + return lines[-1] if lines else "unknown error" + + +def _status(name: str, loaded: Mapping[str, str], failed: Mapping[str, str]) -> str: + """Classify one entry point. + + Args: + name: Entry-point name. + loaded: Plugins that imported cleanly. + failed: Plugins that raised while importing. + + Returns: + One of :data:`STATUS_FAILED`, :data:`STATUS_LOADED` or + :data:`STATUS_NOT_LOADED`. + """ + if name in failed: + return STATUS_FAILED + if name in loaded: + return STATUS_LOADED + return STATUS_NOT_LOADED + + +def _status_rows( + declared: Mapping[str, str], + loaded: Mapping[str, str], + failed: Mapping[str, str], +) -> list[list[str | Text]]: + """Build the plugin status table rows. + + Args: + declared: Entry points found in installed distribution metadata. + loaded: Plugins that imported cleanly, mapped to their entry-point value. + failed: Plugins that raised while importing, mapped to their traceback. + + Returns: + One ``[name, status, entry point]`` row per plugin, sorted by name. + """ + from rich.text import Text + + rows: list[list[str | Text]] = [] + for name in sorted(set(declared) | set(loaded) | set(failed)): + status = _status(name, loaded, failed) + target = declared.get(name) or loaded.get(name) or "" + rows.append([name, Text(status, style=_STATUS_STYLES[status]), target]) + return rows + + +def _report_failures(failed: Mapping[str, str], *, show_traceback: bool) -> None: + """Print the reason each failed plugin failed. + + Args: + failed: Mapping of plugin name to the recorded traceback. + show_traceback: Print the whole traceback rather than its last line. + """ + if not failed: + return + from rich.text import Text + + from ._render import console + + out = console() + out.print() + out.print(Text(f"{len(failed)} plugin(s) failed to load:", style="bold red")) + for name, recorded in sorted(failed.items()): + line = Text(" ") + line.append(name, style="bold red") + line.append(": ") + line.append(failure_reason(recorded)) + out.print(line) + if show_traceback: + out.print(Text(recorded.rstrip(), style="dim")) + if not show_traceback: + out.print(Text("Re-run with --traceback for the full stack traces.", style="dim")) + + +def _report_skipped_builtins(skipped: Mapping[str, str]) -> None: + """Print built-in modules that were skipped during registration. + + Builtins are not plugins, but a missing built-in component looks identical + from the outside, so it belongs in the same answer. + + Args: + skipped: Mapping of module name to the recorded traceback. + """ + if not skipped: + return + from rich.text import Text + + from ._render import console + + out = console() + out.print() + out.print(Text(f"{len(skipped)} built-in module(s) were skipped:", style="yellow")) + for module_name, recorded in sorted(skipped.items()): + line = Text(" ") + line.append(module_name, style="yellow") + line.append(": ") + line.append(failure_reason(recorded)) + out.print(line) + + +def list_plugins( + show_traceback: Annotated[ + bool, + typer.Option("--traceback", "-t", help="Print the full traceback of every failed plugin."), + ] = False, + strict: Annotated[ + bool, + typer.Option("--strict", help="Exit with code 1 when any declared plugin failed to load."), + ] = False, +) -> None: + """Show installed drevalpy plugins and whether they loaded. + + Every package declaring a ``drevalpy.plugins`` entry point is listed with its + load status. Failures are reported with the exception that caused them; pass + ``--strict`` to turn any failure into a non-zero exit status for CI. + """ + from ._render import render_rows + + registry = _registry_module() + failed = registry.get_failed_plugins() + + render_rows( + _status_rows(_declared_entry_points(), registry.get_loaded_plugins(), failed), + columns=["Plugin", "Status", "Entry point"], + title="Plugins", + empty_hint=_NO_PLUGINS_HINT, + ) + _report_failures(failed, show_traceback=show_traceback) + _report_skipped_builtins(registry.get_skipped_builtin_modules()) + + if strict and failed: + raise typer.Exit(1) diff --git a/drevalpy/cli/catalog/registries.py b/drevalpy/cli/catalog/registries.py new file mode 100644 index 000000000..a85030f3e --- /dev/null +++ b/drevalpy/cli/catalog/registries.py @@ -0,0 +1,80 @@ +"""``drevalpy list`` commands over the five component registries. + +All five registries expose the same surface (``list``, ``get``, ``metadata``, +``table``), so every command here is a two-line wrapper around :func:`_show`. +""" + +from __future__ import annotations + +from typing import Annotated + +import typer + +#: Optional entry name. Given, the command prints that entry's metadata instead +#: of the whole registry table. +EntryName = Annotated[ + str | None, + typer.Argument( + metavar="[NAME]", + help="Show the metadata of a single entry instead of the whole table.", + ), +] + + +def _show(registry_name: str, title: str, name: str | None) -> None: + """Render one registry, or the metadata of a single entry in it. + + Args: + registry_name: Attribute of :mod:`drevalpy.registry` holding the registry + module, e.g. ``"predictor"``. + title: Plural heading for the table, e.g. ``"Predictors"``. + name: Entry to describe, or ``None`` to render the whole table. + + Raises: + typer.Exit: With code 1 when ``name`` is not registered. The registry's + own error message, which lists the registered names, is written to + stderr first. + """ + from drevalpy import registry + + from ._render import render_frame, render_mapping + + module = getattr(registry, registry_name) + if name is None: + render_frame( + module.table(), + title=title, + empty_hint=f"No {title.lower()} are registered.", + ) + return + try: + metadata = module.metadata(name) + except ValueError as error: + typer.echo(str(error), err=True) + raise typer.Exit(1) from error + render_mapping(metadata, title=name) + + +def list_predictors(name: EntryName = None) -> None: + """List registered predictors.""" + _show("predictor", "Predictors", name) + + +def list_cell_line_featurizers(name: EntryName = None) -> None: + """List registered cell-line featurizers.""" + _show("cell_line_featurizer", "Cell-line featurizers", name) + + +def list_drug_featurizers(name: EntryName = None) -> None: + """List registered drug featurizers.""" + _show("drug_featurizer", "Drug featurizers", name) + + +def list_splitters(name: EntryName = None) -> None: + """List registered split modes.""" + _show("splitter", "Splitters", name) + + +def list_visualizations(name: EntryName = None) -> None: + """List registered visualizations.""" + _show("visualization", "Visualizations", name) diff --git a/drevalpy/cli/collect_results.py b/drevalpy/cli/collect_results.py deleted file mode 100644 index 80b6baee5..000000000 --- a/drevalpy/cli/collect_results.py +++ /dev/null @@ -1,27 +0,0 @@ -"""``drevalpy collect-results`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli._helpers import as_list -from drevalpy.cli_model_testing import run_collect_results - - -def register(app: typer.Typer) -> None: - @app.command("collect-results") - def collect_results( - outfiles: Annotated[ - list[str], - typer.Option( - "--outfiles", - help="Output files containing results (evaluation_results*csv + true_vs_pred.csv). " - "Pass multiple values separated by spaces.", - ), - ], - path_data: Annotated[str, typer.Option("--path_data", help="Data directory path. Default: data.")] = "data", - ) -> None: - """Collect results and write to single files.""" - run_collect_results(outfiles=as_list(outfiles), path_data=path_data) diff --git a/drevalpy/cli/consolidate_single_drug.py b/drevalpy/cli/consolidate_single_drug.py deleted file mode 100644 index a781de43b..000000000 --- a/drevalpy/cli/consolidate_single_drug.py +++ /dev/null @@ -1,39 +0,0 @@ -"""``drevalpy consolidate-single-drug`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli._helpers import as_list -from drevalpy.cli_model_testing import run_consolidate_results - - -def register(app: typer.Typer) -> None: - @app.command("consolidate-single-drug") - def consolidate_single_drug( - run_id: Annotated[str, typer.Option("--run_id", help="Run ID")], - model_name: Annotated[str, typer.Option("--model_name", help="All Model names")], - outdir_path: Annotated[str, typer.Option("--outdir_path", help="Output directory path")], - n_cv_splits: Annotated[int, typer.Option("--n_cv_splits", help="Number of CV splits")], - test_mode: Annotated[str, typer.Option("--test_mode", help="Test mode (LPO, LCO, LTO, LDO)")] = "LPO", - cross_study_datasets: Annotated[ - list[str] | None, typer.Option("--cross_study_datasets", help="Cross-study datasets (space-separated).") - ] = None, - randomization_modes: Annotated[ - str, typer.Option("--randomization_modes", help="All randomizations") - ] = "[None]", - n_trials_robustness: Annotated[int, typer.Option("--n_trials_robustness", help="Number of trials")] = 0, - ) -> None: - """Consolidate results for SingleDrugModels.""" - run_consolidate_results( - run_id=run_id, - test_mode=test_mode, - model_name=model_name, - outdir_path=outdir_path, - n_cv_splits=n_cv_splits, - cross_study_datasets=as_list(cross_study_datasets) if cross_study_datasets else None, - randomization_modes=randomization_modes, - n_trials_robustness=n_trials_robustness, - ) diff --git a/drevalpy/cli/curate.py b/drevalpy/cli/curate.py new file mode 100644 index 000000000..6a01b047d --- /dev/null +++ b/drevalpy/cli/curate.py @@ -0,0 +1,59 @@ +"""CLI command for dose-response curve curation. + +``curate`` reads a long-form dose-response table and writes the AnnData a full +curation produces - see :mod:`drevalpy.curation`. The ``.h5ad`` is the only +output: because :func:`drevalpy.curation.curate` keys ``obs_names``/``var_names`` +from the ``cell_line``/``drug`` columns it was given, a pipeline can curate on +native identifiers and remap the indices in a later, cheap stage. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated + +import typer + +if TYPE_CHECKING: + import pandas as pd + +_TABLE_SUFFIXES = (".parquet", ".pq") + + +def _read_frame(input_path: str) -> pd.DataFrame: + """Read a long-form dose-response table from CSV or Parquet. + + :param input_path: Path to a ``.csv``, ``.parquet`` or ``.pq`` file. + :returns: The parsed frame. + :raises typer.BadParameter: If the suffix is not a supported format. + """ + import pandas as pd + from upath import UPath + + path = UPath(input_path) + suffix = path.suffix.lower() + if suffix == ".csv": + return pd.read_csv(path) + if suffix in _TABLE_SUFFIXES: + return pd.read_parquet(path) + msg = f"Unsupported file format: {suffix}. Use .csv or .parquet." + raise typer.BadParameter(msg) + + +def curate_cmd( + input_path: Annotated[str, typer.Argument(help="Path to a CSV or Parquet file with dose-response data.")], + output: Annotated[str, typer.Argument(help="Output .h5ad file path.")], + cores: Annotated[int, typer.Option("--cores", "-c", help="Number of CPU cores.")] = 4, + normalize: Annotated[bool, typer.Option("--normalize", help="Apply normalization before fitting.")] = False, + fit_type: Annotated[str, typer.Option("--fit-type", help="Curve fitting method (OLS only).")] = "OLS", + fit_speed: Annotated[ + str, typer.Option("--fit-speed", help="fast/standard/exhaustive/basinhopping.") + ] = "exhaustive", +) -> None: + """Fit dose-response curves from a CSV/Parquet file and write AnnData to .h5ad.""" + from upath import UPath + + from drevalpy.curation import curate + + df = _read_frame(input_path) + adata = curate(df, cores=cores, normalize=normalize, fit_type=fit_type, fit_speed=fit_speed) + adata.write_h5ad(UPath(output)) diff --git a/drevalpy/cli/data/__init__.py b/drevalpy/cli/data/__init__.py new file mode 100644 index 000000000..48f73c717 --- /dev/null +++ b/drevalpy/cli/data/__init__.py @@ -0,0 +1,17 @@ +"""``drevalpy data`` command group.""" + +from __future__ import annotations + +import typer + +from .load import load_dataset +from .split import split_dataset + +data_app = typer.Typer( + name="data", + help="Data management commands.", + no_args_is_help=True, +) + +data_app.command("load")(load_dataset) +data_app.command("split")(split_dataset) diff --git a/drevalpy/cli/data/load.py b/drevalpy/cli/data/load.py new file mode 100644 index 000000000..c55e1dcef --- /dev/null +++ b/drevalpy/cli/data/load.py @@ -0,0 +1,25 @@ +"""``drevalpy data load`` command.""" + +from __future__ import annotations + +from typing import Annotated + +import typer +from upath import UPath + + +def load_dataset( + name: Annotated[str, typer.Argument(help="Registered dataset name or path to a .h5mu file.")], + output: Annotated[str, typer.Argument(help="Output .h5mu file path.")], +) -> None: + """Load a dataset and write it to an output file. + + Resolves the dataset by name (downloading if needed) and writes it as .h5mu. + """ + from drevalpy.data import load + + path = UPath(output) + dataset = load(name) + path.parent.mkdir(parents=True, exist_ok=True) + dataset.mdata.write(str(path)) + typer.echo(f"Wrote {dataset.name} to {path}") diff --git a/drevalpy/cli/data/split.py b/drevalpy/cli/data/split.py new file mode 100644 index 000000000..d86ede839 --- /dev/null +++ b/drevalpy/cli/data/split.py @@ -0,0 +1,42 @@ +"""``drevalpy data split`` command.""" + +from __future__ import annotations + +from typing import Annotated + +import typer +from upath import UPath + + +def split_dataset( + dataset: Annotated[str, typer.Argument(help="Registered dataset name or path to a .h5mu file.")], + output_dir: Annotated[str, typer.Argument(help="Output directory for split .npz files.")], + mode: Annotated[str, typer.Option("--mode", "-m", help="Split mode: LPO, LCO, LDO, or LTO.")] = "LPO", + n_splits: Annotated[int, typer.Option("--n-splits", "-n", help="Number of CV folds.")] = 5, + validation_ratio: Annotated[ + float, typer.Option("--validation-ratio", help="Fraction of training data for validation.") + ] = 0.1, + random_state: Annotated[int, typer.Option("--random-state", help="Random seed.")] = 42, +) -> None: + """Split a dataset into cross-validation folds. + + Writes one .npz file per fold to the output directory. + """ + from rich.progress import Progress + + from drevalpy.data import Dataset, split + + ds = Dataset.load(dataset) + out = UPath(output_dir) + out.mkdir(parents=True, exist_ok=True) + + folds = split(ds, mode=mode, n_splits=n_splits, validation_ratio=validation_ratio, random_state=random_state) + + with Progress() as progress: + task = progress.add_task("Writing folds", total=len(folds)) + for i, fold in enumerate(folds): + fold_path = out / f"fold_{i}.npz" + fold.save(str(fold_path)) + progress.advance(task) + + typer.echo(f"Wrote {len(folds)} folds to {out}") diff --git a/drevalpy/cli/evaluate_hpams.py b/drevalpy/cli/evaluate_hpams.py deleted file mode 100644 index 8cc1a4a10..000000000 --- a/drevalpy/cli/evaluate_hpams.py +++ /dev/null @@ -1,43 +0,0 @@ -"""``drevalpy evaluate-hpams`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli._helpers import as_list -from drevalpy.cli_run_cv import run_evaluate_and_find_max - - -def register(app: typer.Typer) -> None: - @app.command("evaluate-hpams") - def evaluate_hpams( - model_name: Annotated[str, typer.Option("--model_name", help="Model name, used for naming the output file.")], - split_id: Annotated[str, typer.Option("--split_id", help="Split id, used for naming the output file.")], - hpam_yamls: Annotated[ - list[str], - typer.Option( - "--hpam_yamls", - help="Paths to hyperparameter configuration yaml files. Pass multiple values separated by spaces.", - ), - ], - pred_datas: Annotated[ - list[str], - typer.Option( - "--pred_datas", - help="Paths to pickled predictions. Pass multiple values separated by spaces.", - ), - ], - optim_metric: Annotated[ - str, typer.Option("--optim_metric", help="Optimization metric, default: RMSE.") - ] = "RMSE", - ) -> None: - """Evaluate predictions and save the best hyperparameter combination.""" - run_evaluate_and_find_max( - model_name=model_name, - split_id=split_id, - hpam_yamls=as_list(hpam_yamls), - pred_datas=as_list(pred_datas), - optim_metric=optim_metric, - ) diff --git a/drevalpy/cli/evaluate_test.py b/drevalpy/cli/evaluate_test.py deleted file mode 100644 index 75dbec3c9..000000000 --- a/drevalpy/cli/evaluate_test.py +++ /dev/null @@ -1,20 +0,0 @@ -"""``drevalpy evaluate-test`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_model_testing import run_evaluate_test_results - - -def register(app: typer.Typer) -> None: - @app.command("evaluate-test") - def evaluate_test( - model_name: Annotated[str, typer.Option("--model_name", help="Model name.")], - pred_file: Annotated[str, typer.Option("--pred_file", help="Path to predictions.")], - test_mode: Annotated[str, typer.Option("--test_mode", help="Test mode (LPO, LCO, LDO, LTO).")] = "LPO", - ) -> None: - """Evaluate the predictions.""" - run_evaluate_test_results(test_mode=test_mode, model_name=model_name, pred_file=pred_file) diff --git a/drevalpy/cli/experiments/__init__.py b/drevalpy/cli/experiments/__init__.py new file mode 100644 index 000000000..83f6fb7bd --- /dev/null +++ b/drevalpy/cli/experiments/__init__.py @@ -0,0 +1,17 @@ +"""``drevalpy experiments`` command group.""" + +from __future__ import annotations + +import typer + +from .randomization import randomization_cmd +from .robustness import robustness_cmd + +experiments_app = typer.Typer( + name="experiments", + help="Experiment workflow commands.", + no_args_is_help=True, +) + +experiments_app.command("robustness")(robustness_cmd) +experiments_app.command("randomization")(randomization_cmd) diff --git a/drevalpy/cli/experiments/randomization.py b/drevalpy/cli/experiments/randomization.py new file mode 100644 index 000000000..8aff0afa6 --- /dev/null +++ b/drevalpy/cli/experiments/randomization.py @@ -0,0 +1,53 @@ +"""``drevalpy experiments randomization`` command.""" + +from __future__ import annotations + +from typing import Annotated + +import typer +from upath import UPath + + +def randomization_cmd( + model: Annotated[str, typer.Argument(help="Model name (e.g. ElasticNet, SimpleNeuralNetwork).")], + dataset: Annotated[str, typer.Argument(help="Registered dataset name or path to a .h5mu file.")], + output_dir: Annotated[str, typer.Argument(help="Output directory for randomized .h5mu files.")], + modes: Annotated[ + list[str] | None, + typer.Option("--mode", "-m", help="Randomization mode(s): SVRC, SVCC, SVRD, SVCD."), + ] = None, + randomization_type: Annotated[ + str, typer.Option("--randomization-type", "-t", help="Randomization type: permutation or invariant.") + ] = "permutation", + random_state: Annotated[int, typer.Option("--random-state", help="Random seed.")] = 42, +) -> None: + """Generate randomized datasets for feature importance testing. + + Produces copies of the dataset with views shuffled according to the + specified randomization modes, based on the model's configured views. + """ + from rich.progress import Progress + + from drevalpy.data import Dataset + from drevalpy.experiment._randomization import randomization + from drevalpy.models import construct_model + + out = UPath(output_dir) + out.mkdir(parents=True, exist_ok=True) + + effective_modes = modes if modes else ["SVRC"] + model_class = construct_model(model) + ds = Dataset.load(dataset) + randomized = randomization( + model_class, ds, effective_modes, randomization_type=randomization_type, random_state=random_state + ) + + with Progress() as progress: + task = progress.add_task("Writing randomized datasets", total=len(randomized)) + for rds in randomized: + mode_tag, view_tag = rds.randomization or ("unknown", "0") + out_path = out / f"{mode_tag}:{view_tag}.h5mu" + rds.mdata.write(str(out_path)) + progress.advance(task) + + typer.echo(f"Wrote {len(randomized)} randomized datasets to {out}") diff --git a/drevalpy/cli/experiments/robustness.py b/drevalpy/cli/experiments/robustness.py new file mode 100644 index 000000000..a3b16458d --- /dev/null +++ b/drevalpy/cli/experiments/robustness.py @@ -0,0 +1,49 @@ +"""``drevalpy experiments robustness`` command.""" + +from __future__ import annotations + +from typing import Annotated + +import typer +from upath import UPath + + +def robustness_cmd( + splits_dir: Annotated[str, typer.Argument(help="Directory containing fold .npz files.")], + output_dir: Annotated[str, typer.Argument(help="Output directory for shuffled split files.")], + n_permutations: Annotated[ + int, typer.Option("--n-permutations", "-n", help="Number of shuffled variants per fold.") + ] = 5, +) -> None: + """Generate robustness test splits by shuffling pair ordering. + + Reads each fold .npz from the input directory, produces shuffled variants, + and writes them to the output directory. + """ + from rich.progress import Progress + + from drevalpy.experiment._robustness import robustness + from drevalpy.types import SplitMasks + + inp = UPath(splits_dir) + out = UPath(output_dir) + out.mkdir(parents=True, exist_ok=True) + + fold_files = sorted(inp.glob("*.npz")) + if not fold_files: + typer.echo(f"No .npz files found in {inp}", err=True) + raise typer.Exit(code=1) + + total = 0 + with Progress() as progress: + task = progress.add_task("Processing folds", total=len(fold_files)) + for fold_file in fold_files: + fold = SplitMasks.load(str(fold_file)) + variants = robustness(fold, n_permutations) + for trial, variant in enumerate(variants): + out_path = out / f"{fold_file.stem}_trial_{trial}.npz" + variant.save(str(out_path)) + total += 1 + progress.advance(task) + + typer.echo(f"Wrote {total} robustness splits to {out}") diff --git a/drevalpy/cli/legacy.py b/drevalpy/cli/legacy.py deleted file mode 100644 index 5d95ef375..000000000 --- a/drevalpy/cli/legacy.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Legacy ``drevalpy-*`` console scripts as aliases to Typer subcommands.""" - -from __future__ import annotations - -import sys -from collections.abc import Callable - -from drevalpy.cli._helpers import normalize_list_argv -from drevalpy.cli._legacy import warn_deprecated - - -def _legacy_alias(legacy_script: str, subcommand: str) -> Callable[[], None]: - """Return a Poetry entry point that forwards to ``drevalpy ``.""" - - def entrypoint() -> None: - warn_deprecated(legacy_script=legacy_script, replacement=f"drevalpy {subcommand}") - from drevalpy.cli.main import app - - app(normalize_list_argv([subcommand, *sys.argv[1:]]), prog_name=legacy_script) - - entrypoint.__doc__ = f"Legacy alias for ``drevalpy {subcommand}``." - return entrypoint - - -preprocess_raw_viability = _legacy_alias("drevalpy-viability-preprocess", "viability-preprocess") -postprocess_viability = _legacy_alias("drevalpy-viability-postprocess", "viability-postprocess") -load_response = _legacy_alias("drevalpy-load-response", "load-response") -cv_split = _legacy_alias("drevalpy-make-cv-pkls", "make-cv-pkls") -hpam_split = _legacy_alias("drevalpy-make-hpam-yamls", "make-hpam-yamls") -train_and_predict_cv = _legacy_alias("drevalpy-train-cv", "train-cv") -evaluate_and_find_max = _legacy_alias("drevalpy-evaluate-hpams", "evaluate-hpams") -train_and_predict_final = _legacy_alias("drevalpy-test-cv", "test-cv") -randomization_split = _legacy_alias("drevalpy-make-randomization-yamls", "make-randomization-yamls") -final_split = _legacy_alias("drevalpy-make-final-split-pkls", "make-final-split-pkls") -tune_final_model = _legacy_alias("drevalpy-tune-final-model", "tune-final-model") -train_final_model = _legacy_alias("drevalpy-train-final-model", "train-final-model") -consolidate_results = _legacy_alias("drevalpy-consolidate-single-drug", "consolidate-single-drug") -evaluate_test_results = _legacy_alias("drevalpy-evaluate-test", "evaluate-test") -collect_results = _legacy_alias("drevalpy-collect-results", "collect-results") -main = _legacy_alias("drevalpy-report", "report") -pipeline_report = _legacy_alias("drevalpy-make-pipeline-report", "make-pipeline-report") diff --git a/drevalpy/cli/load_response.py b/drevalpy/cli/load_response.py deleted file mode 100644 index a77ce5304..000000000 --- a/drevalpy/cli/load_response.py +++ /dev/null @@ -1,37 +0,0 @@ -"""``drevalpy load-response`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_run_cv import run_load_response - - -def register(app: typer.Typer) -> None: - @app.command("load-response") - def load_response( - response_dataset: Annotated[ - str, - typer.Option("--response_dataset", help="Path to the drug response file dataset_name.csv."), - ], - cross_study_dataset: Annotated[ - bool, - typer.Option("--cross_study_dataset", help="Whether to load cross-study datasets, default: False."), - ] = False, - measure: Annotated[ - str, - typer.Option( - "--measure", - help="Name of the column in the dataset containing the drug response measures, " - "default: LN_IC50_curvecurator.", - ), - ] = "LN_IC50_curvecurator", - ) -> None: - """Load drug response data for drug response prediction as pickle.""" - run_load_response( - response_dataset=response_dataset, - cross_study_dataset=cross_study_dataset, - measure=measure, - ) diff --git a/drevalpy/cli/main.py b/drevalpy/cli/main.py index 749e1b5e0..cd6633fa1 100644 --- a/drevalpy/cli/main.py +++ b/drevalpy/cli/main.py @@ -2,62 +2,64 @@ from __future__ import annotations -import sys +from typing import Annotated import typer -from drevalpy.cli import ( - collect_results, - consolidate_single_drug, - evaluate_hpams, - evaluate_test, - load_response, - make_cv_pkls, - make_final_split_pkls, - make_hpam_yamls, - make_pipeline_report, - make_randomization_yamls, - pipeline, - report, - test_cv, - train_cv, - train_final_model_cmd, - tune_final_model, - viability_postprocess, - viability_preprocess, -) -from drevalpy.cli._helpers import normalize_list_argv +from drevalpy.cli.aggregate import aggregate_cmd +from drevalpy.cli.catalog import list_app +from drevalpy.cli.curate import curate_cmd +from drevalpy.cli.data import data_app +from drevalpy.cli.experiments import experiments_app +from drevalpy.cli.report import report_cmd +from drevalpy.cli.run import run_cmd +from drevalpy.cli.single import single_cmd app = typer.Typer( name="drevalpy", - help="Drug response evaluation of cancer cell line drug response models in a fair setting.", - no_args_is_help=False, + help="Drug response evaluation framework.", + no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}, ) -pipeline.register_pipeline_callback(app) -viability_preprocess.register(app) -viability_postprocess.register(app) -load_response.register(app) -make_cv_pkls.register(app) -make_hpam_yamls.register(app) -train_cv.register(app) -evaluate_hpams.register(app) -test_cv.register(app) -make_randomization_yamls.register(app) -make_final_split_pkls.register(app) -tune_final_model.register(app) -train_final_model_cmd.register(app) -consolidate_single_drug.register(app) -evaluate_test.register(app) -collect_results.register(app) -report.register(app) -make_pipeline_report.register(app) + +@app.callback() +def main_callback( + extensions_dir: Annotated[ + list[str] | None, + typer.Option("--extensions-dir", "-e", help="Directory with .py/.yaml extension files."), + ] = None, +) -> None: + """Global options applied before any subcommand.""" + import os + + from drevalpy.registry import load_extension_dir + + env_dir = os.environ.get("DREVALPY_EXTENSIONS_DIR") + if env_dir: + load_extension_dir(env_dir) + + for d in extensions_dir or []: + load_extension_dir(d) + + +app.add_typer(data_app, name="data") +app.add_typer(experiments_app, name="experiments") +app.add_typer(list_app, name="list") +app.command("run")(run_cmd) +app.command("single")(single_cmd) +app.command("aggregate")(aggregate_cmd) +app.command("curate")(curate_cmd) +app.command("report")(report_cmd) def cli_main() -> None: - """Poetry console script entry point.""" - app(normalize_list_argv(sys.argv[1:])) + """Console script entry point.""" + try: + app() + except KeyboardInterrupt: + typer.echo("\nInterrupted.", err=True) + raise SystemExit(130) from None if __name__ == "__main__": diff --git a/drevalpy/cli/make_cv_pkls.py b/drevalpy/cli/make_cv_pkls.py deleted file mode 100644 index 2c2fa8d60..000000000 --- a/drevalpy/cli/make_cv_pkls.py +++ /dev/null @@ -1,40 +0,0 @@ -"""``drevalpy make-cv-pkls`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_run_cv import run_cv_split - - -def register(app: typer.Typer) -> None: - @app.command("make-cv-pkls") - def make_cv_pkls( - response: Annotated[str, typer.Option("--response", help="Path to the pickled response data file.")], - n_cv_splits: Annotated[int, typer.Option("--n_cv_splits", help="Number of CV splits")], - test_mode: Annotated[ - str, typer.Option("--test_mode", help="Test mode (LPO, LCO, LTO, LDO), default: LPO.") - ] = "LPO", - validation_ratio: Annotated[ - float, typer.Option("--validation_ratio", help="Ratio of validation data, default: 0.1") - ] = 0.1, - seed: Annotated[int, typer.Option("--seed", help="Random seed for splitting the data, default: 42.")] = 42, - custom_splitter_path: Annotated[ - str | None, - typer.Option( - "--custom_splitter_path", - help="Path to a Python script defining create_splits(response_data, params).", - ), - ] = None, - ) -> None: - """Split data into CV splits: split_0.pkl, split_1.pkl, ...""" - run_cv_split( - response=response, - n_cv_splits=n_cv_splits, - test_mode=test_mode, - validation_ratio=validation_ratio, - seed=seed, - custom_splitter_path=custom_splitter_path, - ) diff --git a/drevalpy/cli/make_final_split_pkls.py b/drevalpy/cli/make_final_split_pkls.py deleted file mode 100644 index aa70e3ab4..000000000 --- a/drevalpy/cli/make_final_split_pkls.py +++ /dev/null @@ -1,35 +0,0 @@ -"""``drevalpy make-final-split-pkls`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_model_testing import run_final_split - - -def register(app: typer.Typer) -> None: - @app.command("make-final-split-pkls") - def make_final_split_pkls( - response: Annotated[ - str, typer.Option("--response", help="Drug response data, pickled (output of load_response).") - ], - model_name: Annotated[ - str, - typer.Option("--model_name", help="Model class name, e.g., RandomForest, SingleDrugRandomForest."), - ], - path_data: Annotated[str, typer.Option("--path_data", help="Path to data. Default: data.")] = "data", - test_mode: Annotated[ - str, typer.Option("--test_mode", help="Test mode (LPO, LCO, LTO, LDO). Default: LPO.") - ] = "LPO", - val_ratio: Annotated[float, typer.Option("--val_ratio", help="Validation ratio.")] = 0.1, - ) -> None: - """Create train/validation/early-stopping pickles for a final production model.""" - run_final_split( - response=response, - model_name=model_name, - path_data=path_data, - test_mode=test_mode, - val_ratio=val_ratio, - ) diff --git a/drevalpy/cli/make_hpam_yamls.py b/drevalpy/cli/make_hpam_yamls.py deleted file mode 100644 index ec8d5cd96..000000000 --- a/drevalpy/cli/make_hpam_yamls.py +++ /dev/null @@ -1,25 +0,0 @@ -"""``drevalpy make-hpam-yamls`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_run_cv import run_hpam_split - - -def register(app: typer.Typer) -> None: - @app.command("make-hpam-yamls") - def make_hpam_yamls( - model_name: Annotated[str, typer.Option("--model_name", help="Model name")], - hyperparameter_tuning: Annotated[ - bool, - typer.Option( - "--hyperparameter_tuning", - help="If set, hyperparameter tuning is performed, otherwise only the first combination is used", - ), - ] = False, - ) -> None: - """Create one yaml for each unique hyperparameter combination (hpam_0.yaml, hpam_1.yaml, ...).""" - run_hpam_split(model_name=model_name, hyperparameter_tuning=hyperparameter_tuning) diff --git a/drevalpy/cli/make_pipeline_report.py b/drevalpy/cli/make_pipeline_report.py deleted file mode 100644 index 63c7a5914..000000000 --- a/drevalpy/cli/make_pipeline_report.py +++ /dev/null @@ -1,43 +0,0 @@ -"""``drevalpy make-pipeline-report`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli._helpers import as_list -from drevalpy.visualization.create_report import run_pipeline_report - - -def register(app: typer.Typer) -> None: - @app.command("make-pipeline-report") - def make_pipeline_report( - test_modes: Annotated[ - list[str], - typer.Option( - "--test_modes", - help="LPO, LDO, LCO, or LTO. Pass multiple values separated by spaces.", - ), - ], - eval_results: Annotated[str, typer.Option("--eval_results", help="Path to the evaluation results.")], - eval_results_per_drug: Annotated[ - str, typer.Option("--eval_results_per_drug", help="Path to the evaluation results per drug.") - ], - eval_results_per_cl: Annotated[ - str, typer.Option("--eval_results_per_cl", help="Path to the evaluation results per cell line.") - ], - true_vs_predicted: Annotated[ - str, typer.Option("--true_vs_predicted", help="Path to the true vs predicted results.") - ], - path_data: Annotated[str, typer.Option("--path_data", help="Path to the data.")], - ) -> None: - """Make the HTML report for the pipeline.""" - run_pipeline_report( - test_modes=as_list(test_modes), - eval_results=eval_results, - eval_results_per_drug=eval_results_per_drug, - eval_results_per_cl=eval_results_per_cl, - true_vs_predicted=true_vs_predicted, - path_data=path_data, - ) diff --git a/drevalpy/cli/make_randomization_yamls.py b/drevalpy/cli/make_randomization_yamls.py deleted file mode 100644 index cc1115228..000000000 --- a/drevalpy/cli/make_randomization_yamls.py +++ /dev/null @@ -1,19 +0,0 @@ -"""``drevalpy make-randomization-yamls`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_model_testing import run_randomization_split - - -def register(app: typer.Typer) -> None: - @app.command("make-randomization-yamls") - def make_randomization_yamls( - model_name: Annotated[str, typer.Option("--model_name", help="Name of the model to use.")], - randomization_mode: Annotated[str, typer.Option("--randomization_mode", help="Randomization mode to use.")], - ) -> None: - """Create randomization test views and save them as yamls.""" - run_randomization_split(model_name=model_name, randomization_mode=randomization_mode) diff --git a/drevalpy/cli/pipeline.py b/drevalpy/cli/pipeline.py deleted file mode 100644 index a33fcbc77..000000000 --- a/drevalpy/cli/pipeline.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Root ``drevalpy`` command (full experiment pipeline).""" - -from __future__ import annotations - -from importlib.metadata import version as pkg_version -from typing import Annotated - -import typer -from typer import _click - -from drevalpy.cli._helpers import as_list, pipeline_namespace -from drevalpy.evaluation import AVAILABLE_METRICS -from drevalpy.utils import check_arguments, main - - -def _version_callback(value: bool) -> None: - if value: - typer.echo(f"drevalpy {pkg_version('drevalpy')}") - raise typer.Exit() - - -BASELINES_HELP = ( - "List of baselines to evaluate. If NaiveMeanEffectsPredictor is not part of them, we will add it." - "The baselines are also hyperparameter-tuned and compared to the models, but no randomization or robustness tests " - "are run. NaiveMeanEffectsPredictor is always run as it is required for evaluation." -) -TEST_MODE_HELP = ( - "Which tests to run (LPO=Leave-random-Pairs-Out, LCO=Leave-Cell-line-Out, LTO=Leave-Tissue-Out, LDO=Leave-Drug-Out)" - ". Can be a list, e.g. 'LPO LCO LTO LDO' to run all tests. Default is LPO." -) -RANDOMIZATION_MODE_HELP = ( - "Which randomization tests to run, additionally to the normal run. None disables randomization tests. " - "Available modes: SVCC, SVRC, SVCD, SVRD. Can be a list of randomization tests, " - "e.g. 'SVCC SVCD'. SVCC - Single View Constant (while others are perturbed) for Cell Lines, " - "SVRC - Single View Random (while others are held constant) for Cell Lines, " - "SVCD - Single View Constant for Drugs, SVRD - Single View Random for Drugs." -) -RANDOMIZATION_TYPE_HELP = ( - 'Type of randomization to use. Choose from "permutation" or "invariant". Default is "permutation". ' - "Permutation shuffles features over instances while preserving feature distributions. Invariant " - "randomization preserves a key characteristic such as matrix mean and standard deviation or network degree." -) -ROBUSTNESS_HELP = ( - "Number of trials to run for the robustness test. Default is 0, which means no robustness test is run. " - "The robustness test trains the model with varying seeds multiple times to check stability." -) -NO_REFITTING_HELP = ( - "If not set, the measure is appended with '_curvecurator'. If a custom dataset_name was provided, this will invoke " - "the fitting procedure of raw viability data, which is expected to exist at " - "``//_raw.csv``. The fitted dataset will be stored in the same folder, " - "in a file called ``.csv``. Default is False, i.e., curvecurated drug response measures are utilized." -) - -MEASURE_HELP = ( - "Drug response measure used as prediction target. If using one of the available datasets, this is restricted to " - "one of ['LN_IC50', 'EC50', 'IC50', 'pEC50', 'AUC', 'response']. This corresponds to the names of the columns that " - "contain theses measures in the provided input dataset. If providing a custom dataset, this may differ. " - "If the option ``--no_refitting`` is not set, the prefix '_curvecurator' is automatically appended, " - "e.g., 'LN_IC50_curvecurator', to allow using the refit measures instead of the ones originally published for the " - "available datasets, allowing for better dataset comparability (refit measures are already provided in the " - "available datasets or computed as part of the fitting procedure when providing custom raw viability datasets, " - "see ``--no_refitting`` for details). Default: ``LN_IC50``" -) - -RESPONSE_TRANSFORMATION_HELP = ( - "Transformation to apply to the response variable during training and prediction. Will be retransformed " - "after the final predictions. Possible values: standard, minmax, robust." -) - - -def register_pipeline_callback(app: typer.Typer) -> None: - """Register the default callback that runs the full pipeline when no subcommand is given.""" - - @app.callback(invoke_without_command=True) - def pipeline_root( - ctx: _click.Context, - show_version: Annotated[ - bool, - typer.Option( - "--version", - "-v", - callback=_version_callback, - is_eager=True, - help="Show version and exit.", - expose_value=False, - ), - ] = False, - run_id: Annotated[str, typer.Option("--run_id", help="Identifier to save the results.")] = "my_run", - path_data: Annotated[str, typer.Option("--path_data", help="Path to the data directory.")] = "data", - models: Annotated[ - list[str] | None, typer.Option("--models", help="Model to evaluate or list of models to compare.") - ] = None, - baselines: Annotated[list[str] | None, typer.Option("--baselines", help=BASELINES_HELP)] = None, - test_mode: Annotated[list[str] | None, typer.Option("--test_mode", help=TEST_MODE_HELP)] = None, - randomization_mode: Annotated[ - list[str] | None, typer.Option("--randomization_mode", help=RANDOMIZATION_MODE_HELP) - ] = None, - randomization_type: Annotated[str, typer.Option("--randomization_type", help=RANDOMIZATION_TYPE_HELP)] = ( - "permutation" - ), - n_trials_robustness: Annotated[int, typer.Option("--n_trials_robustness", help=ROBUSTNESS_HELP)] = 0, - dataset_name: Annotated[str, typer.Option("--dataset_name", help="Name of the dataset to use.")] = ("GDSC1"), - cross_study_datasets: Annotated[ - list[str] | None, - typer.Option( - "--cross_study_datasets", - help="List of datasets to use for cross-study prediction evaluation. Default is empty list.", - ), - ] = None, - path_out: Annotated[str, typer.Option("--path_out", help="Path to the output directory.")] = "results/", - no_refitting: Annotated[bool, typer.Option("--no_refitting", help=NO_REFITTING_HELP)] = False, - curve_curator_cores: Annotated[ - int, - typer.Option( - "--curve_curator_cores", - help="Max. number of cores used to fit curves with CurveCurator following min(cores, #curves to fit).", - ), - ] = 1, - curve_curator_normalize: Annotated[ - bool, - typer.Option( - "--curve_curator_normalize", - help="Whether to normalize the response values to [0, 1] for CurveCurator. Default is False.", - ), - ] = False, - measure: Annotated[ - str, - typer.Option( - "--measure", - help=MEASURE_HELP, - ), - ] = "LN_IC50", - overwrite: Annotated[ - bool, typer.Option("--overwrite", help="Overwrite existing results with the same path out and run_id?") - ] = False, - optim_metric: Annotated[ - str, - typer.Option( - "--optim_metric", - help=f"Metric for hyperparameter tuning choose from {list(AVAILABLE_METRICS.keys())}. Default is RMSE.", - ), - ] = "RMSE", - wandb_project: Annotated[ - str | None, - typer.Option( - "--wandb_project", - help=( - "Optional Weights & Biases project name. If provided, enables wandb logging for all DRPModel " - "instances." - ), - ), - ] = None, - n_cv_splits: Annotated[ - int, typer.Option("--n_cv_splits", help="Number of cross-validation splits to use for the evaluation.") - ] = 7, - response_transformation: Annotated[ - str, typer.Option("--response_transformation", help=RESPONSE_TRANSFORMATION_HELP) - ] = "None", - multiprocessing: Annotated[ - bool, - typer.Option("--multiprocessing", help="If set, we will use raytune for fitting. Default is False."), - ] = False, - model_checkpoint_dir: Annotated[ - str, typer.Option("--model_checkpoint_dir", help="Directory to save model checkpoints.") - ] = "TEMPORARY", - final_model_on_full_data: Annotated[ - bool, - typer.Option( - "--final_model_on_full_data", - help="Save a final model trained and tuned on the union of all folds after cross-validation.", - ), - ] = False, - no_hyperparameter_tuning: Annotated[ - bool, - typer.Option( - "--no_hyperparameter_tuning", help="Disable hyperparameter tuning and use first hyperparameter set." - ), - ] = False, - custom_splitter_path: Annotated[ - str | None, - typer.Option( - "--custom_splitter_path", - help="Path to a Python script defining create_splits(response_data, params). " - "When set, built-in CV splitting is skipped and test_mode selects validation checks.", - ), - ] = None, - custom_split_name: Annotated[ - str | None, - typer.Option( - "--custom_split_name", - help="Optional result-directory label when using an external split script. Defaults to test_mode.", - ), - ] = None, - ) -> None: - """Run the drug response prediction model test suite.""" - if ctx.invoked_subcommand is not None: - return - args = pipeline_namespace( - run_id=run_id, - path_data=path_data, - models=as_list(models), - baselines=as_list(baselines) if baselines is not None else None, - test_mode=as_list(test_mode) if test_mode is not None else ["LPO"], - randomization_mode=as_list(randomization_mode) if randomization_mode is not None else ["None"], - randomization_type=randomization_type, - n_trials_robustness=n_trials_robustness, - dataset_name=dataset_name, - cross_study_datasets=as_list(cross_study_datasets), - path_out=path_out, - no_refitting=no_refitting, - curve_curator_cores=curve_curator_cores, - curve_curator_normalize=curve_curator_normalize, - measure=measure, - overwrite=overwrite, - optim_metric=optim_metric, - wandb_project=wandb_project, - n_cv_splits=n_cv_splits, - response_transformation=response_transformation, - multiprocessing=multiprocessing, - model_checkpoint_dir=model_checkpoint_dir, - final_model_on_full_data=final_model_on_full_data, - no_hyperparameter_tuning=no_hyperparameter_tuning, - custom_splitter_path=custom_splitter_path, - custom_split_name=custom_split_name, - ) - check_arguments(args) - main(args) diff --git a/drevalpy/cli/report.py b/drevalpy/cli/report.py index fc58e2518..505e7de0d 100644 --- a/drevalpy/cli/report.py +++ b/drevalpy/cli/report.py @@ -5,19 +5,45 @@ from typing import Annotated import typer +from upath import UPath -from drevalpy.visualization.create_report import run_report - - -def register(app: typer.Typer) -> None: - @app.command("report") - def report( - run_id: Annotated[str, typer.Option("--run_id", help="Run ID for the current execution")], - dataset_name: Annotated[ - str, typer.Option("--dataset_name", help="Name of the dataset for which to render the result file") - ], - path_data: Annotated[str, typer.Option("--path_data", help="Path to the data")] = "data", - result_path: Annotated[str, typer.Option("--result_path", help="Path to the results")] = "results", - ) -> None: - """Generate reports from evaluation results.""" - run_report(run_id=run_id, dataset=dataset_name, path_data=path_data, result_path=result_path) +from drevalpy.log import get_logger + +logger = get_logger(__name__) + + +def report_cmd( + experiment_dir: Annotated[str, typer.Argument(help="Path to a saved ExperimentResult directory.")], + output_dir: Annotated[str, typer.Option("--output-dir", "-o", help="Output directory for the report.")] = "report", + title: Annotated[str, typer.Option("--title", "-t", help="Report title.")] = "Drug Response Evaluation", + reference_model: Annotated[ + str | None, typer.Option("--reference-model", "-r", help="Normalize metrics against this model.") + ] = None, + dataset_path: Annotated[ + str | None, typer.Option("--dataset", "-d", help="Path to dataset .h5mu for metadata enrichment.") + ] = None, +) -> None: + """Generate a MultiQC report from an ExperimentResult.""" + from drevalpy.types.results import ExperimentResult + from drevalpy.visualization.report import create_report + + exp_path = UPath(experiment_dir) + + if dataset_path: + # Accepted for pipeline compatibility but deliberately not read: every + # visualization takes `dataset` and ignores it, and the .h5mu is large enough that + # loading it eats a meaningful fraction of the report container's memory. + logger.info("Ignoring --dataset %s: no visualization consumes dataset metadata", dataset_path) + + # No visualization reads HPO trial predictions, which outweigh the fold predictions + # they belong to, so the report path is the one caller that opts out of loading them. + # The experiment is passed inline rather than through a local so ``create_report`` + # holds the only reference and can release the pre-normalization copy. + create_report( + ExperimentResult.load(str(exp_path), with_trials=False), + output_dir, + title=title, + reference_model=reference_model, + dataset=None, + ) + typer.echo(f"Report generated at {output_dir}") diff --git a/drevalpy/cli/run.py b/drevalpy/cli/run.py new file mode 100644 index 000000000..60ab98229 --- /dev/null +++ b/drevalpy/cli/run.py @@ -0,0 +1,58 @@ +"""``drevalpy run`` command.""" + +from __future__ import annotations + +from typing import Annotated + +import typer +from upath import UPath + + +def run_cmd( + models: Annotated[list[str], typer.Argument(help="Model name(s) to evaluate.")], + dataset: Annotated[str, typer.Option("--dataset", "-d", help="Dataset name or .h5mu path.")], + split_mode: Annotated[str, typer.Option("--split-mode", "-s", help="Split mode: LPO, LCO, LDO, LTO.")] = "LPO", + output_dir: Annotated[str, typer.Option("--output-dir", "-o", help="Output directory for results.")] = "results", + hpo: Annotated[bool, typer.Option("--hpo/--no-hpo", help="Enable hyperparameter tuning.")] = True, + hpo_metric: Annotated[str, typer.Option("--hpo-metric", help="Metric to optimize.")] = "RMSE", + hpo_num_samples: Annotated[int, typer.Option("--hpo-num-samples", help="Number of HPO trials.")] = 16, + hpo_random_state: Annotated[int, typer.Option("--hpo-random-state", help="HPO random seed.")] = 42, + randomization_mode: Annotated[ + list[str] | None, + typer.Option("--randomization-mode", "-r", help="Randomization mode(s): SVRC, SVCC, SVRD, SVCD."), + ] = None, + randomization_type: Annotated[ + str, typer.Option("--randomization-type", help="Randomization type: permutation or invariant.") + ] = "permutation", + robustness_trials: Annotated[ + int, typer.Option("--robustness-trials", help="Number of robustness permutations (0=disabled).") + ] = 0, + precomputed_only: Annotated[ + bool, typer.Option("--precomputed-only", help="Restrict HPO to pre-computed featurizer variants.") + ] = False, +) -> None: + """Run the full evaluation pipeline.""" + from drevalpy._run import run + from drevalpy.models import construct_model + + model_classes = [construct_model(m) for m in models] + out = UPath(output_dir) + out.mkdir(parents=True, exist_ok=True) + + result = run( + models=model_classes, + dataset=dataset, + split_mode=split_mode, + randomization_modes=randomization_mode, + randomization_type=randomization_type, + hyperparameter_tuning=hpo, + hpo_metric=hpo_metric, + hpo_num_samples=hpo_num_samples, + hpo_random_state=hpo_random_state, + robustness_trials=robustness_trials, + precomputed_only=precomputed_only, + ) + + result.save(str(out)) + typer.echo(f"Wrote experiment results to {out}") + typer.echo(repr(result)) diff --git a/drevalpy/cli/single.py b/drevalpy/cli/single.py new file mode 100644 index 000000000..67d4dd8f7 --- /dev/null +++ b/drevalpy/cli/single.py @@ -0,0 +1,57 @@ +"""``drevalpy single`` command.""" + +from __future__ import annotations + +from typing import Annotated + +import typer +from upath import UPath + + +def single_cmd( + model: Annotated[str, typer.Argument(help="Model name (e.g. ElasticNet, RandomForest).")], + dataset: Annotated[str, typer.Argument(help="Path to a .h5mu dataset file.")], + split: Annotated[str, typer.Argument(help="Path to a fold .npz split file.")], + output: Annotated[str, typer.Argument(help="Output path for the result .npz file.")], + hpo: Annotated[bool, typer.Option("--hpo/--no-hpo", help="Enable hyperparameter tuning.")] = True, + hpo_metric: Annotated[str, typer.Option("--hpo-metric", help="Metric to optimize.")] = "RMSE", + hpo_num_samples: Annotated[int, typer.Option("--hpo-num-samples", help="Number of HPO trials.")] = 16, + hpo_random_state: Annotated[int, typer.Option("--hpo-random-state", help="HPO random seed.")] = 42, + response_transformation: Annotated[ + str, + typer.Option( + "--response-transformation", + help="Response scaling fitted on the training scope: None, standard, minmax, or robust.", + ), + ] = "standard", + split_mode: Annotated[str, typer.Option("--split-mode", help="Split mode fallback (e.g. LPO, LCO, LDO).")] = "LPO", +) -> None: + """Train a model on one fold, predict on test set, and save results.""" + from drevalpy._single import single as run_single + from drevalpy.models import construct_model + from drevalpy.types import SplitMasks + from drevalpy.types.data.dataset import Dataset + from drevalpy.utils import get_response_transformation + + out = UPath(output) + out.parent.mkdir(parents=True, exist_ok=True) + + model_class = construct_model(model) + ds = Dataset.load(dataset) + split_masks = SplitMasks.load(split) + if "split_mode" not in split_masks.metadata: + split_masks.metadata["split_mode"] = split_mode + + result = run_single( + model_class, + ds, + split_masks, + hyperparameter_tuning=hpo, + response_transformation=get_response_transformation(response_transformation), + hpo_metric=hpo_metric, + hpo_num_samples=hpo_num_samples, + hpo_random_state=hpo_random_state, + ) + + result.save(str(out)) + typer.echo(f"Result: {result.model_name} on {result.dataset_name} (fold {result.fold_index}) -> {out}") diff --git a/drevalpy/cli/test_cv.py b/drevalpy/cli/test_cv.py deleted file mode 100644 index 5b82ff690..000000000 --- a/drevalpy/cli/test_cv.py +++ /dev/null @@ -1,88 +0,0 @@ -"""``drevalpy test-cv`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli._helpers import as_list -from drevalpy.cli_model_testing import run_train_and_predict_final - - -def register(app: typer.Typer) -> None: - @app.command("test-cv") - def test_cv( - model_name: Annotated[ - str, - typer.Option( - "--model_name", - help="Model name for global models, . for single-drug models.", - ), - ], - split_id: Annotated[str, typer.Option("--split_id", help="Split id.")], - split_dataset_path: Annotated[ - str, typer.Option("--split_dataset_path", help="Path to the pickled CV split dataset.") - ], - hyperparameters_path: Annotated[ - str, - typer.Option("--hyperparameters_path", help="Path to yaml file containing the optimal hyperparameters."), - ], - path_data: Annotated[str, typer.Option("--path_data", help="Path to data. Default: data")] = "data", - mode: Annotated[ - str, typer.Option("--mode", help="Mode: full, randomization, or robustness. Default: full.") - ] = "full", - response_transformation: Annotated[ - str, typer.Option("--response_transformation", help="Response transformation. Default: None.") - ] = "None", - test_mode: Annotated[ - str, typer.Option("--test_mode", help="Test mode (LPO, LCO, LTO, LDO). Default: LPO.") - ] = "LPO", - randomization_views_path: Annotated[ - str | None, - typer.Option( - "--randomization_views_path", - help="Path to the yaml file containing the randomization configuration " - "(only relevant if mode=randomization).", - ), - ] = None, - randomization_type: Annotated[ - str, - typer.Option( - "--randomization_type", - help="Randomization type (permutation, invariant). Default: permutation. " - "Only relevant if mode=randomization.", - ), - ] = "permutation", - robustness_trial: Annotated[ - int | None, - typer.Option("--robustness_trial", help="Robustness trial index. Only relevant if mode=robustness."), - ] = None, - cross_study_datasets: Annotated[ - list[str] | None, - typer.Option("--cross_study_datasets", help="Paths to pickled cross study datasets (space-separated)."), - ] = None, - model_checkpoint_dir: Annotated[ - str, - typer.Option( - "--model_checkpoint_dir", - help="model checkpoint directory, if not provided: temporary directory is used", - ), - ] = "TEMPORARY", - ) -> None: - """Train and predict on the CV test set (full, randomization, or robustness mode).""" - run_train_and_predict_final( - mode=mode, - model_name=model_name, - split_id=split_id, - split_dataset_path=split_dataset_path, - hyperparameters_path=hyperparameters_path, - response_transformation=response_transformation, - test_mode=test_mode, - path_data=path_data, - randomization_views_path=randomization_views_path, - randomization_type=randomization_type, - robustness_trial=robustness_trial, - cross_study_datasets=as_list(cross_study_datasets) if cross_study_datasets else None, - model_checkpoint_dir=model_checkpoint_dir, - ) diff --git a/drevalpy/cli/train_cv.py b/drevalpy/cli/train_cv.py deleted file mode 100644 index 614e3261a..000000000 --- a/drevalpy/cli/train_cv.py +++ /dev/null @@ -1,58 +0,0 @@ -"""``drevalpy train-cv`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_run_cv import run_train_and_predict_cv - - -def register(app: typer.Typer) -> None: - @app.command("train-cv") - def train_cv( - model_name: Annotated[ - str, - typer.Option( - "--model_name", - help="Model name (model_name for global models, model_name.drug_name for single-drug models).", - ), - ], - hyperparameters: Annotated[ - str, - typer.Option( - "--hyperparameters", - help="Path to the yaml file containing the hyperparameter configuration for this run.", - ), - ], - cv_data: Annotated[str, typer.Option("--cv_data", help="Path to the pickled cv data split.")], - path_data: Annotated[str, typer.Option("--path_data", help="Data directory path, default: data.")] = "data", - test_mode: Annotated[ - str, typer.Option("--test_mode", help="Test mode (LPO, LCO, LTO, LDO), default: LPO.") - ] = "LPO", - response_transformation: Annotated[ - str, - typer.Option( - "--response_transformation", - help="Response transformation to apply to the dataset, default: None.", - ), - ] = "None", - model_checkpoint_dir: Annotated[ - str, - typer.Option( - "--model_checkpoint_dir", - help="model checkpoint directory, if not provided: temporary directory is used", - ), - ] = "TEMPORARY", - ) -> None: - """Train on a CV split and save validation predictions as pickle.""" - run_train_and_predict_cv( - model_name=model_name, - path_data=path_data, - test_mode=test_mode, - hyperparameters=hyperparameters, - cv_data=cv_data, - response_transformation=response_transformation, - model_checkpoint_dir=model_checkpoint_dir, - ) diff --git a/drevalpy/cli/train_final_model_cmd.py b/drevalpy/cli/train_final_model_cmd.py deleted file mode 100644 index ab8f075c7..000000000 --- a/drevalpy/cli/train_final_model_cmd.py +++ /dev/null @@ -1,52 +0,0 @@ -"""``drevalpy train-final-model`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_model_testing import run_train_final_model - - -def register(app: typer.Typer) -> None: - @app.command("train-final-model") - def train_final_model_cmd( - train_data: Annotated[str, typer.Option("--train_data", help="Train data, pickled.")], - val_data: Annotated[str, typer.Option("--val_data", help="Validation data, pickled.")], - early_stopping_data: Annotated[ - str, typer.Option("--early_stopping_data", help="Early stopping data, pickled.") - ], - model_name: Annotated[ - str, - typer.Option( - "--model_name", - help="Model name (model_name for global models, model_name.drug_name for single-drug models).", - ), - ], - best_hpam_combi: Annotated[ - str, typer.Option("--best_hpam_combi", help="Best hyperparameter combination file, yaml format.") - ], - path_data: Annotated[str, typer.Option("--path_data", help="Path to data. Default: data.")] = "data", - response_transformation: Annotated[ - str, typer.Option("--response_transformation", help="Response transformation.") - ] = "None", - model_checkpoint_dir: Annotated[ - str, - typer.Option( - "--model_checkpoint_dir", - help="model checkpoint directory, if not provided: temporary directory is used", - ), - ] = "TEMPORARY", - ) -> None: - """Train a final model on the full dataset using the best hyperparameters.""" - run_train_final_model( - train_data=train_data, - val_data=val_data, - early_stopping_data=early_stopping_data, - response_transformation=response_transformation, - model_name=model_name, - path_data=path_data, - model_checkpoint_dir=model_checkpoint_dir, - best_hpam_combi=best_hpam_combi, - ) diff --git a/drevalpy/cli/tune_final_model.py b/drevalpy/cli/tune_final_model.py deleted file mode 100644 index d63722acb..000000000 --- a/drevalpy/cli/tune_final_model.py +++ /dev/null @@ -1,52 +0,0 @@ -"""``drevalpy tune-final-model`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_model_testing import run_tune_final_model - - -def register(app: typer.Typer) -> None: - @app.command("tune-final-model") - def tune_final_model( - train_data: Annotated[str, typer.Option("--train_data", help="Train dataset, pickled.")], - val_data: Annotated[str, typer.Option("--val_data", help="Validation dataset, pickled.")], - early_stopping_data: Annotated[ - str, typer.Option("--early_stopping_data", help="Early stopping dataset, pickled.") - ], - model_name: Annotated[ - str, - typer.Option( - "--model_name", - help="Model name (model_name for global models, model_name.drug_name for single-drug models).", - ), - ], - hpam_combi: Annotated[ - str, typer.Option("--hpam_combi", help="Path to hyperparameter combination file, yaml format.") - ], - path_data: Annotated[str, typer.Option("--path_data", help="Path to data. Default: data.")] = "data", - response_transformation: Annotated[ - str, typer.Option("--response_transformation", help="Response transformation. Default: None.") - ] = "None", - model_checkpoint_dir: Annotated[ - str, - typer.Option( - "--model_checkpoint_dir", - help="model checkpoint directory, if not provided: temporary directory is used", - ), - ] = "TEMPORARY", - ) -> None: - """Find optimal hyperparameters for the final model on full data.""" - run_tune_final_model( - train_data=train_data, - val_data=val_data, - early_stopping_data=early_stopping_data, - model_name=model_name, - hpam_combi=hpam_combi, - response_transformation=response_transformation, - path_data=path_data, - model_checkpoint_dir=model_checkpoint_dir, - ) diff --git a/drevalpy/cli/viability_postprocess.py b/drevalpy/cli/viability_postprocess.py deleted file mode 100644 index 3cc09cb76..000000000 --- a/drevalpy/cli/viability_postprocess.py +++ /dev/null @@ -1,25 +0,0 @@ -"""``drevalpy viability-postprocess`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_preprocess_custom import run_postprocess_viability - - -def register(app: typer.Typer) -> None: - @app.command("viability-postprocess") - def viability_postprocess( - dataset_name: Annotated[str, typer.Option("--dataset_name", help="Dataset name, e.g., MyCustomDataset.")], - path_data: Annotated[ - str, - typer.Option( - "--path_data", - help="Path to output folder of CurveCurator containing the curves.txt file, default: './'.", - ), - ] = "./", - ) -> None: - """Postprocess CurveCurator viability data into one CSV.""" - run_postprocess_viability(dataset_name=dataset_name, path_data=path_data) diff --git a/drevalpy/cli/viability_preprocess.py b/drevalpy/cli/viability_preprocess.py deleted file mode 100644 index fe5ce48e7..000000000 --- a/drevalpy/cli/viability_preprocess.py +++ /dev/null @@ -1,30 +0,0 @@ -"""``drevalpy viability-preprocess`` command.""" - -from __future__ import annotations - -from typing import Annotated - -import typer - -from drevalpy.cli_preprocess_custom import run_preprocess_raw_viability - - -def register(app: typer.Typer) -> None: - @app.command("viability-preprocess") - def viability_preprocess( - dataset_name: Annotated[str, typer.Option("--dataset_name", help="Dataset name, e.g., MyCustomDataset.")], - path_data: Annotated[ - str, - typer.Option( - "--path_data", - help="Path to base folder containing datasets, in particular dataset_name/dataset_name_raw.csv, " - "default: ./data.", - ), - ] = "./data", - cores: Annotated[ - int, - typer.Option("--cores", help="The number of cores used for CurveCurator fitting, default: 4."), - ] = 4, - ) -> None: - """Preprocess CurveCurator viability data.""" - run_preprocess_raw_viability(path_data=path_data, dataset_name=dataset_name, cores=cores) diff --git a/drevalpy/cli_model_testing.py b/drevalpy/cli_model_testing.py deleted file mode 100644 index 256dcffc9..000000000 --- a/drevalpy/cli_model_testing.py +++ /dev/null @@ -1,447 +0,0 @@ -"""For the nf-core/drugresponseeval subworkflow model_testing.""" - -import json -import pathlib -import pickle -from argparse import Namespace -from typing import Any - -import pandas as pd -import yaml - - -def _prep_data_for_final_prediction(arguments: Namespace) -> tuple[Any, Any, Any, Any, Any, Any, Any]: - """Load data and prepare it for final CV-fold training and prediction.""" - from drevalpy.experiment import get_datasets_from_cv_split, get_model_name_and_drug_id - from drevalpy.models import MODEL_FACTORY - from drevalpy.utils import get_response_transformation - - model_name, drug_id = get_model_name_and_drug_id(arguments.model_name) - model_class = MODEL_FACTORY[model_name] - model = model_class() - with open(arguments.split_dataset_path, "rb") as split_file: - split = pickle.load(split_file) - train_dataset, validation_dataset, es_dataset, test_dataset = get_datasets_from_cv_split( - split, model_class, model_name, drug_id - ) - - if model_class.early_stopping: - validation_dataset = split["validation_es"] - es_dataset = split["early_stopping"] - else: - es_dataset = None - train_dataset.add_rows(validation_dataset) - train_dataset.shuffle(random_state=42) - with open(arguments.hyperparameters_path) as f: - best_hpam_dict = yaml.safe_load(f) - best_hpams = best_hpam_dict[f"{arguments.model_name}_{arguments.split_id}"]["best_hpam_combi"] - response_transform = get_response_transformation(arguments.response_transformation) - return model, drug_id, best_hpams, train_dataset, test_dataset, es_dataset, response_transform - - -def run_train_and_predict_final( - *, - mode: str = "full", - model_name: str, - split_id: str, - split_dataset_path: str, - hyperparameters_path: str, - response_transformation: str = "None", - test_mode: str = "LPO", - path_data: str = "data", - randomization_views_path: str | None = None, - randomization_type: str = "permutation", - robustness_trial: int | None = None, - cross_study_datasets: list[str] | None = None, - model_checkpoint_dir: str = "TEMPORARY", -) -> None: - """Train and predict on the CV test set (full, randomization, or robustness mode).""" - from drevalpy.experiment import ( - cross_study_prediction, - generate_data_saving_path, - randomize_train_predict, - robustness_train_predict, - train_and_predict, - ) - - args = Namespace( - mode=mode, - model_name=model_name, - split_id=split_id, - split_dataset_path=split_dataset_path, - hyperparameters_path=hyperparameters_path, - response_transformation=response_transformation, - test_mode=test_mode, - path_data=path_data, - randomization_views_path=randomization_views_path, - randomization_type=randomization_type, - robustness_trial=robustness_trial, - cross_study_datasets=cross_study_datasets, - model_checkpoint_dir=model_checkpoint_dir, - ) - - selected_model, drug_id, hpam_combi, train_set, test_set, es_set, transformation = _prep_data_for_final_prediction( - args - ) - if args.mode == "full": - predictions_path = generate_data_saving_path( - model_name=selected_model.get_model_name(), - drug_id=drug_id, - result_path="", - suffix="predictions", - ) - hpam_path = generate_data_saving_path( - model_name=selected_model.get_model_name(), - drug_id=drug_id, - result_path="", - suffix="best_hpams", - ) - hpam_path = pathlib.Path(hpam_path) / f"best_hpams_{args.split_id}.json" - with open(hpam_path, "w", encoding="utf-8") as f: - json.dump(hpam_combi, f) - - test_set = train_and_predict( - model=selected_model, - hpams=hpam_combi, - path_data=args.path_data, - train_dataset=train_set, - prediction_dataset=test_set, - early_stopping_dataset=es_set, - response_transformation=transformation, - model_checkpoint_dir=args.model_checkpoint_dir, - ) - prediction_dataset = pathlib.Path(predictions_path) / f"predictions_{args.split_id}.csv" - test_set.to_csv(prediction_dataset) - if args.cross_study_datasets: - for cs_ds in args.cross_study_datasets: - if cs_ds == "NONE.csv": - continue - split_index = args.split_id.split("split_")[1] - with open(cs_ds, "rb") as cs_file: - cross_study_dataset = pickle.load(cs_file) - cross_study_dataset.remove_nan_responses() - cross_study_prediction( - dataset=cross_study_dataset, - model=selected_model, - test_mode=args.test_mode, - train_dataset=train_set, - path_data=args.path_data, - early_stopping_dataset=(es_set if selected_model.early_stopping else None), - response_transformation=transformation, - path_out=str(pathlib.Path(predictions_path).parent), - split_index=split_index, - single_drug_id=drug_id, - ) - elif args.mode == "randomization": - with open(args.randomization_views_path) as f: - rand_test_view = yaml.safe_load(f) - rand_path = generate_data_saving_path( - model_name=selected_model.get_model_name(), - drug_id=drug_id, - result_path="", - suffix="randomization", - ) - randomization_test_file = ( - pathlib.Path(rand_path) / f'randomization_{rand_test_view["test_name"]}_{args.split_id}.csv' - ) - randomize_train_predict( - view=rand_test_view["view"], - test_name=rand_test_view["test_name"], - randomization_type=args.randomization_type, - randomization_test_file=str(randomization_test_file), - model=selected_model, - hpam_set=hpam_combi, - path_data=args.path_data, - train_dataset=train_set, - test_dataset=test_set, - early_stopping_dataset=es_set, - response_transformation=transformation, - model_checkpoint_dir=args.model_checkpoint_dir, - ) - elif args.mode == "robustness": - rob_path = generate_data_saving_path( - model_name=selected_model.get_model_name(), - drug_id=drug_id, - result_path="", - suffix="robustness", - ) - robustness_test_file = pathlib.Path(rob_path) / f"robustness_{args.robustness_trial}_{args.split_id}.csv" - robustness_train_predict( - trial=args.robustness_trial, - trial_file=str(robustness_test_file), - train_dataset=train_set, - test_dataset=test_set, - early_stopping_dataset=es_set, - model=selected_model, - hpam_set=hpam_combi, - path_data=args.path_data, - response_transformation=transformation, - model_checkpoint_dir=args.model_checkpoint_dir, - ) - else: - raise ValueError(f"Invalid mode: {args.mode}. Choose full, randomization, or robustness.") - - -def run_randomization_split(*, model_name: str, randomization_mode: str) -> None: - """Create randomization test view YAML files for a model.""" - from drevalpy.experiment import get_randomization_test_views - from drevalpy.models import MODEL_FACTORY - - model_class = MODEL_FACTORY[model_name] - randomization_test_views: dict[str, list[str]] = {} - for hpam_combi in model_class.get_hyperparameter_set(): - model = model_class() - model.build_model(hpam_combi) - randomization_test_views.update( - get_randomization_test_views(model=model, randomization_mode=[randomization_mode]) - ) - - if not randomization_test_views: - raise RuntimeError( - f"No randomization test views were produced for {model_name} with mode {randomization_mode}. " - "Check that the model's hyperparameters.yaml declares cell_line_views/drug_views." - ) - - for test_name, views in randomization_test_views.items(): - for view in views: - rand_dict = {"test_name": test_name, "view": view} - with open(f"randomization_test_view_{test_name}.yaml", "w") as f: - yaml.dump(rand_dict, f) - - -def run_final_split( - *, - response: str, - model_name: str, - path_data: str = "data", - test_mode: str = "LPO", - val_ratio: float = 0.1, -) -> None: - """Create train/validation/early-stopping pickles for a final production model.""" - from drevalpy.datasets.dataset import split_early_stopping_data - from drevalpy.experiment import make_train_val_split - from drevalpy.models import MODEL_FACTORY - - with open(response, "rb") as response_file: - response_data = pickle.load(response_file) - response_data.remove_nan_responses() - model_class = MODEL_FACTORY[model_name] - model = model_class() - cl_features = model.load_cell_line_features(data_path=path_data, dataset_name=response_data.dataset_name) - drug_features = model.load_drug_features(data_path=path_data, dataset_name=response_data.dataset_name) - cell_lines_to_keep = cl_features.identifiers - drugs_to_keep = drug_features.identifiers if drug_features is not None else None - response_data.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - - train_dataset, validation_dataset = make_train_val_split(response_data, test_mode=test_mode, val_ratio=val_ratio) - - if model_class.early_stopping: - validation_dataset, early_stopping_dataset = split_early_stopping_data(validation_dataset, test_mode) - else: - early_stopping_dataset = None - - with open("training_dataset.pkl", "wb") as f: - pickle.dump(train_dataset, f) - with open("validation_dataset.pkl", "wb") as f: - pickle.dump(validation_dataset, f) - with open("early_stopping_dataset.pkl", "wb") as f: - pickle.dump(early_stopping_dataset, f) - - -def run_tune_final_model( - *, - train_data: str, - val_data: str, - early_stopping_data: str, - model_name: str, - hpam_combi: str, - response_transformation: str = "None", - path_data: str = "data", - model_checkpoint_dir: str = "TEMPORARY", -) -> None: - """Tune hyperparameters for the final model on full data.""" - from drevalpy.experiment import get_model_name_and_drug_id, train_and_predict - from drevalpy.models import MODEL_FACTORY - from drevalpy.utils import get_response_transformation - - with open(train_data, "rb") as train_file: - train_dataset = pickle.load(train_file) - with open(val_data, "rb") as val_file: - validation_dataset = pickle.load(val_file) - with open(early_stopping_data, "rb") as es_file: - early_stopping_dataset = pickle.load(es_file) - response_transform = get_response_transformation(response_transformation) - - resolved_name, _drug_id = get_model_name_and_drug_id(model_name) - model_class = MODEL_FACTORY[resolved_name] - with open(hpam_combi) as f: - hpams = yaml.safe_load(f) - model = model_class() - - validation_dataset = train_and_predict( - model=model, - hpams=hpams, - path_data=path_data, - train_dataset=train_dataset, - prediction_dataset=validation_dataset, - early_stopping_dataset=early_stopping_dataset, - response_transformation=response_transform, - model_checkpoint_dir=model_checkpoint_dir, - ) - with open(f"final_prediction_dataset_{resolved_name}_" f"{str(hpam_combi).split('.yaml')[0]}.pkl", "wb") as f: - pickle.dump(validation_dataset, f) - - -def run_train_final_model( - *, - train_data: str, - val_data: str, - early_stopping_data: str, - response_transformation: str = "None", - model_name: str, - path_data: str = "data", - model_checkpoint_dir: str = "TEMPORARY", - best_hpam_combi: str, -) -> None: - """Train and save the final production model.""" - from drevalpy.experiment import generate_data_saving_path, get_model_name_and_drug_id - from drevalpy.models import MODEL_FACTORY - from drevalpy.utils import get_response_transformation - - resolved_name, _drug_id = get_model_name_and_drug_id(model_name) - final_model_path = generate_data_saving_path( - model_name=resolved_name, drug_id=_drug_id, result_path="", suffix="final_model" - ) - response_transform = get_response_transformation(response_transformation) - with open(train_data, "rb") as train_file: - train_dataset = pickle.load(train_file) - with open(val_data, "rb") as val_file: - validation_dataset = pickle.load(val_file) - with open(early_stopping_data, "rb") as es_file: - es_dataset = pickle.load(es_file) - train_dataset.add_rows(validation_dataset) - train_dataset.shuffle(random_state=42) - if response_transform: - train_dataset.fit_transform(response_transform) - if es_dataset is not None: - es_dataset.transform(response_transform) - with open(best_hpam_combi) as f: - best_hpam = yaml.safe_load(f)[f"{resolved_name}_final"]["best_hpam_combi"] - model = MODEL_FACTORY[resolved_name]() - cl_features = model.load_cell_line_features(data_path=path_data, dataset_name=train_dataset.dataset_name) - drug_features = model.load_drug_features(data_path=path_data, dataset_name=train_dataset.dataset_name) - model.build_model(hyperparameters=best_hpam) - model.train( - output=train_dataset, - output_earlystopping=es_dataset, - cell_line_input=cl_features, - drug_input=drug_features, - model_checkpoint_dir=model_checkpoint_dir, - ) - pathlib.Path(final_model_path).mkdir(parents=True, exist_ok=True) - model.save(final_model_path) - - -def run_consolidate_results( - *, - run_id: str, - test_mode: str = "LPO", - model_name: str, - outdir_path: str, - n_cv_splits: int, - cross_study_datasets: list[str] | None = None, - randomization_modes: str = "[None]", - n_trials_robustness: int = 0, -) -> None: - """Consolidate single-drug model prediction outputs.""" - from drevalpy.experiment import consolidate_single_drug_model_predictions - from drevalpy.models import MODEL_FACTORY - - results_path = str(pathlib.Path(outdir_path) / run_id / test_mode) - if randomization_modes == "[None]": - randomizations = None - else: - randomizations = randomization_modes.split("[")[1].split("]")[0].split(", ") - model = MODEL_FACTORY[model_name] - cross_study = cross_study_datasets or [] - consolidate_single_drug_model_predictions( - models=[model], - n_cv_splits=n_cv_splits, - results_path=results_path, - cross_study_datasets=cross_study, - randomization_mode=randomizations, - n_trials_robustness=n_trials_robustness, - out_path="", - ) - - -def run_evaluate_test_results( - *, - test_mode: str = "LPO", - model_name: str, - pred_file: str, -) -> None: - """Evaluate test predictions and write metric CSVs.""" - from drevalpy.visualization.utils import evaluate_file - - results_all, eval_res_d, eval_res_cl, t_vs_pred, mname = evaluate_file( - test_mode=test_mode, model_name=model_name, pred_file=pred_file - ) - results_all.to_csv(f"{mname}_evaluation_results.csv") - if eval_res_d is not None: - eval_res_d.to_csv(f"{mname}_evaluation_results_per_drug.csv") - if eval_res_cl is not None: - eval_res_cl.to_csv(f"{mname}_evaluation_results_per_cl.csv") - t_vs_pred.to_csv(f"{mname}_true_vs_pred.csv") - - -def _parse_results(outfiles: list[str]) -> tuple[list[str], list[str], list[str], list[str]]: - result_files = [file for file in outfiles if "evaluation_results.csv" in file] - result_per_drug_files = [file for file in outfiles if "evaluation_results_per_drug.csv" in file] - result_per_cl_files = [file for file in outfiles if "evaluation_results_per_cl.csv" in file] - t_vs_pred_files = [file for file in outfiles if "true_vs_pred.csv" in file] - return result_files, result_per_drug_files, result_per_cl_files, t_vs_pred_files - - -def _collapse_file(files: list[str]) -> pd.DataFrame | None: - out_df = None - for file in files: - if out_df is None: - out_df = pd.read_csv(file, index_col=0) - else: - out_df = pd.concat([out_df, pd.read_csv(file, index_col=0)]) - if out_df is not None and "drug" in out_df.columns: - out_df["drug"] = out_df["drug"].astype(str) - return out_df - - -def run_collect_results( - *, - outfiles: list[str], - path_data: str = "data", -) -> None: - """Collect parallel Nextflow evaluation outputs into merged CSVs.""" - from drevalpy.visualization.utils import prep_results, write_results - - path_data_path = pathlib.Path(path_data) - eval_result_files, eval_result_per_drug_files, eval_result_per_cl_files, true_vs_pred_files = _parse_results( - outfiles - ) - eval_results = _collapse_file(eval_result_files) - eval_results_per_drug = _collapse_file(eval_result_per_drug_files) - eval_results_per_cell_line = _collapse_file(eval_result_per_cl_files) - t_vs_p = _collapse_file(true_vs_pred_files) - eval_results, eval_results_per_drug, eval_results_per_cell_line, t_vs_p = prep_results( - eval_results=eval_results, - eval_results_per_drug=eval_results_per_drug, - eval_results_per_cell_line=eval_results_per_cell_line, - t_vs_p=t_vs_p, - path_data=path_data_path, - ) - write_results( - path_out="", - eval_results=eval_results, - eval_results_per_drug=eval_results_per_drug, - eval_results_per_cl=eval_results_per_cell_line, - t_vs_p=t_vs_p, - ) diff --git a/drevalpy/cli_preprocess_custom.py b/drevalpy/cli_preprocess_custom.py deleted file mode 100644 index bcb5d637f..000000000 --- a/drevalpy/cli_preprocess_custom.py +++ /dev/null @@ -1,34 +0,0 @@ -"""For the nf-core/drugresponseeval subworkflow preprocess_custom.""" - -from pathlib import Path - - -def run_preprocess_raw_viability( - *, - path_data: str = "./data", - dataset_name: str, - cores: int = 4, -) -> None: - """Preprocess raw viability data with CurveCurator.""" - from drevalpy.datasets.curvecurator import preprocess - - input_file = Path(path_data).resolve() / dataset_name / f"{dataset_name}_raw.csv" - output_dir = input_file.parent - preprocess( - input_file=str(input_file), - output_dir=str(output_dir), - dataset_name=dataset_name, - cores=cores, - ) - - -def run_postprocess_viability( - *, - dataset_name: str, - path_data: str = "./", -) -> None: - """Postprocess CurveCurator output into a single dataset CSV.""" - from drevalpy.datasets.curvecurator import postprocess - - output_folder = Path(path_data).resolve() / dataset_name - postprocess(output_folder=str(output_folder), dataset_name=dataset_name) diff --git a/drevalpy/cli_run_cv.py b/drevalpy/cli_run_cv.py deleted file mode 100644 index a8449195f..000000000 --- a/drevalpy/cli_run_cv.py +++ /dev/null @@ -1,197 +0,0 @@ -"""For the nf-core/drugresponseeval subworkflow run_cv.""" - -import pickle -from pathlib import Path - -import pandas as pd -import yaml - - -def run_load_response( - *, - response_dataset: str, - cross_study_dataset: bool = False, - measure: str = "LN_IC50_curvecurator", -) -> None: - """Load drug response CSV and pickle a ``DrugResponseDataset``.""" - from drevalpy.datasets.dataset import DrugResponseDataset - from drevalpy.datasets.loader import AVAILABLE_DATASETS - from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER, TISSUE_IDENTIFIER - - input_file = Path(response_dataset) - dataset_name = input_file.stem - if dataset_name in AVAILABLE_DATASETS: - response_file = pd.read_csv(input_file, dtype={"pubchem_id": str}) - if dataset_name == "BeatAML2": - response_file[TISSUE_IDENTIFIER] = "Blood" - elif dataset_name == "PDX_Bruna": - response_file[TISSUE_IDENTIFIER] = "Breast" - response_data = DrugResponseDataset( - response=response_file[measure].values, - cell_line_ids=response_file[CELL_LINE_IDENTIFIER].values, - drug_ids=response_file[DRUG_IDENTIFIER].values, - tissues=response_file[TISSUE_IDENTIFIER].values, - dataset_name=dataset_name, - ) - else: - tissue_column: str | None = TISSUE_IDENTIFIER - if TISSUE_IDENTIFIER not in pd.read_csv(input_file, nrows=1).columns: - tissue_column = None - - response_data = DrugResponseDataset.from_csv( - input_file=input_file, dataset_name=dataset_name, measure=measure, tissue_column=tissue_column - ) - outfile = f"cross_study_{dataset_name}.pkl" if cross_study_dataset else "response_dataset.pkl" - with open(outfile, "wb") as f: - pickle.dump(response_data, f) - - -def run_cv_split( - *, - response: str, - n_cv_splits: int, - test_mode: str = "LPO", - validation_ratio: float = 0.1, - seed: int = 42, - custom_splitter_path: str | None = None, -) -> None: - """Split pickled response data into CV fold pickles.""" - from drevalpy.datasets.splits import create_and_record_splits - - with open(response, "rb") as f: - response_data = pickle.load(f) - create_and_record_splits( - response_data, - split_path=".", - split_label=test_mode, - external_splitter=custom_splitter_path, - test_mode=test_mode, - n_cv_splits=n_cv_splits, - validation_ratio=validation_ratio, - random_state=seed, - split_early_stopping=True, - ) - for split_index, split in enumerate(response_data.cv_splits): - with open(f"split_{split_index}.pkl", "wb") as f: - pickle.dump(split, f) - - -def run_hpam_split( - *, - model_name: str, - hyperparameter_tuning: bool = False, -) -> None: - """Write one YAML per hyperparameter combination for a model.""" - from drevalpy.models import MODEL_FACTORY, MULTI_DRUG_MODEL_FACTORY, SINGLE_DRUG_MODEL_FACTORY - - if model_name in MULTI_DRUG_MODEL_FACTORY: - resolved_name = model_name - else: - resolved_name = str(model_name).split(".")[0] - if resolved_name not in SINGLE_DRUG_MODEL_FACTORY: - raise ValueError(f"{resolved_name} neither in SINGLE_DRUG_MODEL_FACTORY nor in MULTI_DRUG_MODEL_FACTORY.") - model_class = MODEL_FACTORY[resolved_name] - hyperparameters = model_class.get_hyperparameter_set() - if not hyperparameter_tuning: - hyperparameters = [hyperparameters[0]] - hpam_idx = 0 - for hpam_combi in hyperparameters: - with open(f"hpam_{hpam_idx}.yaml", "w") as yaml_file: - hpam_idx += 1 - yaml.dump(hpam_combi, yaml_file, default_flow_style=False) - - -def run_train_and_predict_cv( - *, - model_name: str, - path_data: str = "data", - test_mode: str = "LPO", - hyperparameters: str, - cv_data: str, - response_transformation: str = "None", - model_checkpoint_dir: str = "TEMPORARY", -) -> None: - """Train on a CV split and pickle validation predictions.""" - from drevalpy.experiment import get_datasets_from_cv_split, get_model_name_and_drug_id, train_and_predict - from drevalpy.models import MODEL_FACTORY - from drevalpy.utils import get_response_transformation - - resolved_name, drug_id = get_model_name_and_drug_id(model_name) - model_class = MODEL_FACTORY[resolved_name] - with open(cv_data, "rb") as f: - split = pickle.load(f) - - train_dataset, validation_dataset, es_dataset, _test_dataset = get_datasets_from_cv_split( - split, model_class, resolved_name, drug_id - ) - - response_transform = get_response_transformation(response_transformation) - with open(hyperparameters) as f: - hpams = yaml.safe_load(f) - model = model_class() - - validation_dataset = train_and_predict( - model=model, - hpams=hpams, - path_data=path_data, - train_dataset=train_dataset, - prediction_dataset=validation_dataset, - early_stopping_dataset=es_dataset, - response_transformation=response_transform, - model_checkpoint_dir=model_checkpoint_dir, - ) - - with open( - f"prediction_dataset_{resolved_name}_{str(cv_data).split('.pkl')[0]}_" - f"{str(hyperparameters).split('.yaml')[0]}.pkl", - "wb", - ) as f: - pickle.dump(validation_dataset, f) - - -def _best_metric(metric, current_metric, best_metric, minimization_metrics, maximization_metrics): - if metric in minimization_metrics: - if current_metric < best_metric: - return True - elif metric in maximization_metrics: - if current_metric > best_metric: - return True - else: - raise ValueError(f"Metric {metric} not recognized.") - return False - - -def run_evaluate_and_find_max( - *, - model_name: str, - split_id: str, - hpam_yamls: list[str], - pred_datas: list[str], - optim_metric: str = "RMSE", -) -> None: - """Pick the best hyperparameter YAML for one CV split.""" - from drevalpy.evaluation import MAXIMIZATION_METRICS, MINIMIZATION_METRICS, evaluate - - best_hpam_combi = None - best_result = None - for i in range(0, len(pred_datas)): - with open(pred_datas[i], "rb") as pred_file: - pred_data = pickle.load(pred_file) - with open(hpam_yamls[i]) as yaml_file: - hpam_combi = yaml.safe_load(yaml_file) - results = evaluate(pred_data, optim_metric) - if best_result is None: - best_result = results[optim_metric] - best_hpam_combi = hpam_combi - elif _best_metric( - metric=optim_metric, - current_metric=results[optim_metric], - best_metric=best_result, - minimization_metrics=MINIMIZATION_METRICS, - maximization_metrics=MAXIMIZATION_METRICS, - ): - best_result = results[optim_metric] - best_hpam_combi = hpam_combi - final_result = {f"{model_name}_{split_id}": {"best_hpam_combi": best_hpam_combi, "best_result": best_result}} - with open(f"best_hpam_combi_{split_id}.yaml", "w") as yaml_file: - yaml.dump(final_result, yaml_file, default_flow_style=False) diff --git a/drevalpy/components/__init__.py b/drevalpy/components/__init__.py new file mode 100644 index 000000000..5972ea30a --- /dev/null +++ b/drevalpy/components/__init__.py @@ -0,0 +1,34 @@ +"""Composable model components: featurizers and predictors. + +Public registration and discovery functions are re-exported here for convenience. +The canonical source is ``drevalpy.registry``. +""" + +from __future__ import annotations + + +def __getattr__(name: str): + """Lazy re-exports from drevalpy.registry to avoid circular imports.""" + _mapping = { + "register_builtin_components": ("drevalpy.registry._builtins", "register_builtin_components"), + "load_extensions": ("drevalpy.registry._extensions", "load_extensions"), + "register_cell_line_featurizer": ("drevalpy.registry.cell_line_featurizer", "register"), + "register_drug_featurizer": ("drevalpy.registry.drug_featurizer", "register"), + "register_predictor": ("drevalpy.registry.predictor", "register"), + } + if name == "list_predictor_metadata": + from drevalpy.registry.predictor import predictor_registry + + def list_predictor_metadata(*, tag: str | None = None) -> list[dict]: + return predictor_registry.list_metadata(tag=tag) + + return list_predictor_metadata + + if name in _mapping: + import importlib + + module_path, attr = _mapping[name] + mod = importlib.import_module(module_path) + return getattr(mod, attr) + + raise AttributeError(f"module 'drevalpy.components' has no attribute {name!r}") diff --git a/drevalpy/components/contracts/__init__.py b/drevalpy/components/contracts/__init__.py new file mode 100644 index 000000000..c242856ab --- /dev/null +++ b/drevalpy/components/contracts/__init__.py @@ -0,0 +1,27 @@ +"""Feature contracts, training context, and hyperparameter space validation.""" + +from .contracts import ( + FeatureContract, + FeatureFormat, + contracts_compatible, + featurizer_contract, + normalize_feature_contract, + predictor_contracts, +) +from .hyperparameter_space import ( + validate_component_hyperparameter_space, + validate_hyperparameter_space, +) +from .training_context import TrainingContext + +__all__ = [ + "FeatureContract", + "FeatureFormat", + "TrainingContext", + "contracts_compatible", + "featurizer_contract", + "normalize_feature_contract", + "predictor_contracts", + "validate_component_hyperparameter_space", + "validate_hyperparameter_space", +] diff --git a/drevalpy/components/contracts/contracts.py b/drevalpy/components/contracts/contracts.py new file mode 100644 index 000000000..65eaf4858 --- /dev/null +++ b/drevalpy/components/contracts/contracts.py @@ -0,0 +1,102 @@ +"""Feature format and contract objects for component compatibility.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + + +class FeatureFormat(StrEnum): + """Runtime payload format of featurizer outputs / predictor inputs.""" + + NUMERIC_MATRIX = "numeric_matrix" + GRAPH = "graph" + RAGGED_SEQUENCE = "ragged_sequence" + + +@dataclass(frozen=True) +class FeatureContract: + """Structured description of a feature representation. + + This class should store all properties that are required to check if a featurizer is compatible with a predictor. + Currently, we only store the ``FeatureFormat``. + In future, we might want to store additional properties, like the type of graph. + """ + + format: FeatureFormat + + +def normalize_feature_contract(contract: FeatureContract | FeatureFormat) -> FeatureContract: + """Return a ``FeatureContract`` from a contract object or format shorthand. + + As our FeatureContracts currently only store the ``FeatureFormat``, the + featurizer and predictor decorators allow providing either a full + ``FeatureContract`` instance or just the ``FeatureFormat`` enum member. + This function makes sure that we always return a ``FeatureContract`` + instance, even if just a ``FeatureFormat`` is provided. + + :param contract: A ``FeatureContract`` instance or ``FeatureFormat`` enum member. + + :returns: Normalized ``FeatureContract`` instance. + + :raises TypeError: If *contract* is neither ``FeatureContract`` nor ``FeatureFormat``. + """ + if isinstance(contract, FeatureContract): + return contract + if isinstance(contract, FeatureFormat): + return FeatureContract(format=contract) + msg = f"Expected FeatureContract or FeatureFormat, got {type(contract).__name__}" + raise TypeError(msg) + + +def featurizer_contract(cls: type[Any]) -> FeatureContract: + """Return the featurizer contract from ``contract``. + + :param cls: Featurizer class registered in the component registry. + + :returns: Resolved ``FeatureContract`` for the featurizer class. + + :raises TypeError: If the class has no ``contract``, or it is not a ``FeatureContract``. + """ + contract = getattr(cls, "contract", None) + if contract is None: + raise TypeError(f"Featurizer {cls.__name__!r} must define a contract") + if not isinstance(contract, FeatureContract): + raise TypeError(f"Featurizer {cls.__name__!r} contract must be a FeatureContract") + return contract + + +def predictor_contracts(cls: type[Any]) -> tuple[FeatureContract, FeatureContract]: + """Return predictor input contracts for cell-line and drug sides. + + :param cls: Predictor class registered in the component registry. + + :returns: ``(cell_line_contract, drug_contract)`` pair for compatibility checks. + + :raises TypeError: If either contract is missing or not a ``FeatureContract``. + """ + cell_line = getattr(cls, "cell_line_contract", None) + drug = getattr(cls, "drug_contract", None) + if cell_line is None or drug is None: + msg = f"Predictor {cls.__name__!r} must define both cell_line_contract and drug_contract" + raise TypeError(msg) + if not isinstance(cell_line, FeatureContract) or not isinstance(drug, FeatureContract): + msg = f"Predictor {cls.__name__!r} contracts must be FeatureContract instances" + raise TypeError(msg) + return cell_line, drug + + +def contracts_compatible(produced: FeatureContract, required: FeatureContract) -> bool: + """Return whether *produced* satisfies *required*. + + Currently, this only checks if the ``FeatureFormat`` is the same. + In future, we might want to check additional properties, like the type of graph. + If we do, we need to ensure that the ``FeatureContract`` class is extended to store the additional properties. + + :param produced: Feature contract emitted by a featurizer. + :param required: Feature contract declared by a predictor input slot. + + :returns: ``True`` when both contracts share the same ``FeatureFormat``. + """ + return produced.format == required.format diff --git a/drevalpy/components/contracts/hyperparameter_space.py b/drevalpy/components/contracts/hyperparameter_space.py new file mode 100644 index 000000000..6f0fb9489 --- /dev/null +++ b/drevalpy/components/contracts/hyperparameter_space.py @@ -0,0 +1,136 @@ +"""Validate structured hyperparameter search-space specs, and the tunable-component hooks. + +:class:`TunableComponentMixin` lives beside the validator it calls rather than in +either component package. ``Featurizer`` and ``Predictor`` are siblings under +``components/``, so any home inside one of them would have inverted a dependency +between the two; this module is already the shared leaf both of them import +``validate_hyperparameter_space`` from, and +:func:`validate_component_hyperparameter_space` already duck-types over both kinds. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +__all__ = [ + "TunableComponentMixin", + "validate_component_hyperparameter_space", + "validate_hyperparameter_space", +] + + +def _classify_specs(space: Mapping[str, Any]) -> tuple[list[str], list[str]]: + """Split *space* into the entries that are not mappings and those without a default. + + :param space: Hyperparameter space to inspect. + :returns: ``(invalid, missing)`` name lists. + """ + invalid: list[str] = [] + missing: list[str] = [] + for name, spec in space.items(): + if not isinstance(spec, Mapping): + invalid.append(str(name)) + elif "default" not in spec: + missing.append(str(name)) + return invalid, missing + + +def _describe_problems(invalid: list[str], missing: list[str]) -> str: + """Phrase the offending names, sorted, for the error message. + + :param invalid: Names whose spec is not a mapping. + :param missing: Names whose spec declares no ``default``. + :returns: The problem clauses joined for interpolation into the message. + """ + parts: list[str] = [] + if invalid: + parts.append("non-mapping specs for " + ", ".join(repr(name) for name in sorted(invalid))) + if missing: + parts.append("missing 'default' for " + ", ".join(repr(name) for name in sorted(missing))) + return "; ".join(parts) + + +def validate_hyperparameter_space( + space: Mapping[str, Any] | None, + *, + context: str, +) -> None: + """Reject search-space entries that are not mappings with a ``default``. + + Every tunable parameter must declare a concrete ``default`` so model + construction without overrides can resolve a complete value mapping. + + :param space: Local or merged hyperparameter space, or ``None`` / empty. + :param context: Label included in error messages (class or config field). + :raises ValueError: If any entry is not a mapping or lacks ``default``. + """ + if not space: + return + invalid, missing = _classify_specs(space) + if invalid or missing: + msg = f"Invalid hyperparameter space in {context}: " + _describe_problems(invalid, missing) + raise ValueError(msg) + + +def validate_component_hyperparameter_space(name: str, cls: type[Any]) -> None: + """Validate ``cls.get_hyperparameter_space()`` at registration time. + + :param name: Registry name under which *cls* is being registered. + :param cls: Component class exposing ``get_hyperparameter_space``. + """ + getter = getattr(cls, "get_hyperparameter_space", None) + if not callable(getter): + return + validate_hyperparameter_space( + getter(), + context=f"{name!r} ({cls.__name__}.get_hyperparameter_space())", + ) + + +class TunableComponentMixin: + """The HPO and checkpoint hooks every component kind declares identically. + + Both ``Featurizer`` and ``Predictor`` are tuned by the same search-space + grammar and persisted by the same checkpoint protocol, so both carried + byte-identical copies of these four methods. The defaults here are the + no-op end of each contract: a component with nothing to tune returns an + empty space, and one with nothing fitted returns an empty state. + + Subclasses override ``get_hyperparameter_space`` to declare what is tunable, + and ``get_state`` / ``set_state`` together when they hold fitted state that + a checkpoint must round-trip. ``get_default_hyperparameters`` is not an + override point - it reads whatever the space declares. + """ + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable hyperparameter specs for HPO. + + :returns: Mapping of parameter name to Ray Tune-style spec dicts. + """ + return {} + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Return default hyperparameter values from the HP space. + + :returns: Parameter names mapped to their declared ``default`` values. + """ + space = cls.get_hyperparameter_space() + validate_hyperparameter_space(space, context=f"{cls.__name__}.get_hyperparameter_space()") + return {key: spec["default"] for key, spec in space.items()} + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state for checkpoint persistence. + + :returns: JSON-serializable mapping of fitted attributes. + """ + return {} + + def set_state(self, state: dict[str, object]) -> None: + """Restore fitted state produced by ``get_state``. + + :param state: Mapping previously returned by ``get_state``. + """ + _ = state diff --git a/drevalpy/components/contracts/training_context.py b/drevalpy/components/contracts/training_context.py new file mode 100644 index 000000000..bcfa96166 --- /dev/null +++ b/drevalpy/components/contracts/training_context.py @@ -0,0 +1,15 @@ +"""Small, serializable context for one component training call.""" + +from dataclasses import dataclass, field + +from upath import UPath as Path + +_DEFAULT_CHECKPOINT_DIR = Path("checkpoints") + + +@dataclass(frozen=True) +class TrainingContext: + """Runtime metadata that does not belong in predictor hyperparameters.""" + + checkpoint_dir: Path = _DEFAULT_CHECKPOINT_DIR + logging_metadata: dict[str, str] = field(default_factory=dict) diff --git a/drevalpy/components/featurizers/__init__.py b/drevalpy/components/featurizers/__init__.py new file mode 100644 index 000000000..51bb3ae95 --- /dev/null +++ b/drevalpy/components/featurizers/__init__.py @@ -0,0 +1,11 @@ +"""Public exports for featurizers.""" + +from .base import Featurizer +from .cell_line.base import CellLineFeaturizer +from .drug.base import DrugFeaturizer + +__all__ = [ + "Featurizer", + "CellLineFeaturizer", + "DrugFeaturizer", +] diff --git a/drevalpy/components/featurizers/_concat.py b/drevalpy/components/featurizers/_concat.py new file mode 100644 index 000000000..8a7397796 --- /dev/null +++ b/drevalpy/components/featurizers/_concat.py @@ -0,0 +1,185 @@ +"""Shared dense concatenation logic for cell-line and drug featurizers.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers._featurizer_label import featurizer_config_block_label +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.models.config.featurizer import FeaturizerConfig +from drevalpy.types.data.batch.feature_block import FeatureBlock, merge_feature_blocks + + +class ConcatFeaturizersMixin: + """Fit child featurizers independently and concatenate their dense outputs.""" + + @classmethod + def resolve_input_views(cls, **kwargs: Any) -> tuple[str, ...]: + """Reject direct resolution; input views come from the child configs. + + :param kwargs: Unused featurizer kwargs. + :raises TypeError: Always; use ``views_from_featurizer_config`` on the tree instead. + """ + _ = kwargs + msg = ( + f"{cls.__name__} has no input views of its own; resolve them from the child configs " + "via drevalpy.models.config.view_resolution.views_from_featurizer_config" + ) + raise TypeError(msg) + + def _init_concat( + self, + *, + featurizers: list[Any] | None, + registry: str, + ) -> None: + if not featurizers: + msg = "featurizers must be a non-empty list" + raise ValueError(msg) + self._registry = registry + self._children: list[tuple[str, Featurizer]] = [] + self._child_configs: list[FeaturizerConfig] = [] + self._block_dims: dict[str, int] = {} + self._output_dim = 0 + self._is_fitted = False + + # Accept either already-built Featurizer instances or template configs/dicts. + if all(isinstance(item, Featurizer) for item in featurizers): + for item in featurizers: + label = getattr(item, "registry_name", type(item).__name__) + view = getattr(item, "_view", None) + self._children.append( + (featurizer_config_block_label(str(label), view if isinstance(view, str) else None), item) + ) + return + + parent = FeaturizerConfig.model_validate( + { + "name": "concatFeaturizers", + "registry": registry, + "featurizers": [ + item.model_dump(mode="python") if isinstance(item, FeaturizerConfig) else item + for item in featurizers + ], + }, + ) + self._child_configs = list(parent.featurizers or ()) + self._materialize_children() + + def _materialize_children(self) -> None: + if self._children: + return + if not self._child_configs: + return + children: list[tuple[str, Featurizer]] = [] + for config in self._child_configs: + label = featurizer_config_block_label(config.name, config.view) + children.append((label, config.create_instance())) + self._children = children + + @staticmethod + def _reject_non_numeric_children(children: list[tuple[str, Featurizer]]) -> None: + for label, child in children: + if child.contract.format != FeatureFormat.NUMERIC_MATRIX: + msg = ( + f"concat featurizer child {label!r} emits {child.contract.format.value}; " + "only numeric_matrix children are supported" + ) + raise ValueError(msg) + + def _fit( + self, + features, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ): + """Fit on training data. + + :param features: features. + :param entity_ids: entity ids. + :param pair_expanded_ids: Training entity IDs with duplicates per response pair. + :param pair_expanded_es_ids: Early-stopping entity IDs with duplicates. + :returns: Result. + """ + self._materialize_children() + self._reject_non_numeric_children(self._children) + self._block_dims = {} + for name, child in self._children: + child.fit( + features, + entity_ids=entity_ids, + pair_expanded_ids=pair_expanded_ids, + pair_expanded_es_ids=pair_expanded_es_ids, + ) + self._block_dims[name] = child.output_dim + self._output_dim = sum(self._block_dims.values()) + self._is_fitted = True + return self + + def _transform_blocks(self, features, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Transform blocks. + + :param features: features. + :param entity_ids: entity ids. + :returns: Result. + :raises RuntimeError: Raised on invalid input. + """ + if not self._is_fitted: + msg = f"{type(self).__name__} must be fit before transform" + raise RuntimeError(msg) + child_blocks = [child.transform_blocks(features, entity_ids) for _, child in self._children] + return merge_feature_blocks(*child_blocks) + + @property + def output_dim(self) -> int: + """Return output feature dimension after fitting. + + :returns: Result. + """ + return self._output_dim + + @property + def block_dims(self) -> dict[str, int]: + """Return per-child output dimensions after fitting. + + :returns: Result. + """ + return dict(self._block_dims) + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + return { + "child_states": {name: child.get_state() for name, child in self._children}, + "block_dims": dict(self._block_dims), + "output_dim": self._output_dim, + "fitted": self._is_fitted, + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + self._materialize_children() + child_states = state.get("child_states") + if isinstance(child_states, dict): + for name, child in self._children: + child_state = child_states.get(name) + if isinstance(child_state, dict): + child.set_state(child_state) + block_dims = state.get("block_dims") + if isinstance(block_dims, dict): + self._block_dims = {str(key): int(value) for key, value in block_dims.items()} + output_dim = state.get("output_dim") + if isinstance(output_dim, int): + self._output_dim = output_dim + if state.get("fitted"): + self._is_fitted = True diff --git a/drevalpy/components/featurizers/_constant.py b/drevalpy/components/featurizers/_constant.py new file mode 100644 index 000000000..b49ebb1aa --- /dev/null +++ b/drevalpy/components/featurizers/_constant.py @@ -0,0 +1,80 @@ +"""Shared constant (one-category / intercept) featurizer logic.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.types.data.batch.feature_block import FeatureBlock, numeric_feature_block +from drevalpy.types.data.feature_source import FeatureSource + + +class ConstantFeaturizerMixin: + """Emit a single column of ones for every entity (no identity information).""" + + entity_id_only: ClassVar[bool] = True + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ): + """Fit on training data. + + :param source: Feature source providing views for the entity type. + :param entity_ids: entity ids. + :param pair_expanded_ids: Unused training IDs with duplicates. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: Result. + """ + _ = source, entity_ids, pair_expanded_ids, pair_expanded_es_ids + return self + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Transform inputs into feature payloads. + + :param source: Feature source providing views for the entity type. + :param entity_ids: entity ids. + :returns: Result. + """ + _ = source + return np.ones((len(entity_ids), 1), dtype=np.float32) + + def _transform_blocks( + self, + source: FeatureSource, + entity_ids: np.ndarray, + ) -> dict[str, FeatureBlock]: + """Transform blocks. + + :param source: Feature source providing views for the entity type. + :param entity_ids: entity ids. + :returns: Result. + """ + return {"constant": numeric_feature_block(self._transform(source, entity_ids))} + + @property + def output_dim(self) -> int: + """Return output feature dimension after fitting. + + :returns: Result. + """ + return 1 + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + return {} + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + _ = state diff --git a/drevalpy/components/featurizers/_declarations.py b/drevalpy/components/featurizers/_declarations.py new file mode 100644 index 000000000..32955be25 --- /dev/null +++ b/drevalpy/components/featurizers/_declarations.py @@ -0,0 +1,104 @@ +"""What a featurizer declares: its contract, the views it reads, the blocks it emits. + +These are the class-body declarations the registry and the config layer read +*without instantiating anything* - the contract normalization that runs at class +creation, the view resolution the model config calls to know what to load from +disk, and the output block specs ``models/config/_block_specs.py`` reads to +predict a featurizer's output shape. None of it touches fitted state, which is +what separates it from the fit/transform contract in ``base.py``. + +The module is ``_``-prefixed so ``_discover_modules`` in +``drevalpy/registry/_builtins.py`` does not scan it as a component. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from drevalpy.components.contracts.contracts import ( + FeatureContract, + featurizer_contract, + normalize_feature_contract, +) +from drevalpy.types.data.batch.feature_block import BlockSpec + + +class FeaturizerDeclarationsMixin: + """Hold and resolve a featurizer's class-body declarations.""" + + contract: ClassVar[FeatureContract] + precompute: ClassVar[bool] = False + requires_view: ClassVar[bool] = False + entity_id_only: ClassVar[bool] = False + input_views: ClassVar[tuple[str, ...] | None] = None + source_views: ClassVar[tuple[str, ...] | None] = None + + def __init_subclass__(cls, **kwargs: object) -> None: + """Normalize a class-body ``contract`` declaration, if there is one. + + A ``FeatureFormat`` shorthand is widened to a ``FeatureContract`` so the + class body and the ``@register`` argument accept the same spellings. + Subclasses that declare nothing are registered with the decorator's + ``contract=`` instead. + + :param kwargs: Forwarded to ``ABC.__init_subclass__``. + :raises TypeError: If a class-body ``contract`` is neither a + ``FeatureContract`` nor a ``FeatureFormat``. + """ + super().__init_subclass__(**kwargs) + if "contract" not in cls.__dict__: + return + try: + cls.contract = normalize_feature_contract(cls.__dict__["contract"]) + except TypeError as exc: + msg = f"{cls.__name__}: class-body contract is invalid: {exc}" + raise TypeError(msg) from exc + + @classmethod + def output_block_specs_for_config(cls, config: Any) -> tuple[BlockSpec, ...]: + """Return named output blocks for a featurizer config node. + + Declared ``output_block_specs`` win when present; otherwise a single + block named after the configured (or single declared input) view is emitted. + + :param config: Featurizer config with optional ``view`` / ``hyperparameters``. + :returns: Block specs emitted by this featurizer under *config*. + """ + declared = getattr(cls, "output_block_specs", ()) + if declared: + return tuple(spec for spec in declared if isinstance(spec, BlockSpec)) + view = getattr(config, "view", None) + if not isinstance(view, str): + view = cls.input_views[0] if cls.input_views else None + if isinstance(view, str): + return (BlockSpec(view, featurizer_contract(cls).format),) + return () + + @classmethod + def resolve_input_views(cls, **kwargs: Any) -> tuple[str, ...]: + """Return the raw feature views this featurizer reads under *kwargs*. + + An explicit ``view`` kwarg always wins, which covers view-parameterized + featurizers such as ``raw`` and ``pca``. Otherwise the declared + ``input_views`` are used. Featurizers whose input depends on other + hyperparameters override this hook. + + :param kwargs: Featurizer construction / loader kwargs from the model config. + :returns: Raw view names required from disk, empty when only entity ids are needed. + :raises TypeError: If the views cannot be determined from *kwargs* and the class body. + """ + view = kwargs.get("view") + if isinstance(view, str) and view.strip(): + return (view,) + if cls.input_views is not None: + return cls.input_views + if cls.entity_id_only: + return () + if cls.requires_view: + msg = f"{cls.__name__} requires an explicit view; pass view= to resolve_input_views" + raise TypeError(msg) + msg = ( + f"{cls.__name__}: declare input_views on the class body, set requires_view/entity_id_only, " + "or override resolve_input_views" + ) + raise TypeError(msg) diff --git a/drevalpy/components/featurizers/_dense_view.py b/drevalpy/components/featurizers/_dense_view.py new file mode 100644 index 000000000..aaaedcded --- /dev/null +++ b/drevalpy/components/featurizers/_dense_view.py @@ -0,0 +1,250 @@ +"""Side-agnostic base for featurizers that emit one dense matrix for one view. + +Every dense featurizer in the package - the plain pass-throughs as well as the +ones that reduce, scale or embed - shares the same shape: ask the storage layer +whether the matrix has already been pre-computed, otherwise compute it from the +view, then wrap the result in one numeric block. This module holds that shape +once so subclasses keep only their distinct transform. + +Subclass hooks, all optional: + +* ``_compute_matrix`` - turn the raw view matrix into the output matrix. The + default is the identity, which makes a pass-through featurizer a subclass with + no method bodies at all. +* ``_fit_state`` - learn whatever the transform needs and return the output + width. The default derives the width from ``_compute_matrix``. +* ``_fit_entity_ids`` - choose the rows to fit on (deduplicated, pair-expanded). +* ``_fetch_hyperparameters`` - the HP setting a stored variant must match. +* ``_block_name`` / ``_block_feature_names`` - name and label the emitted block. + +``precompute`` subclasses additionally provide ``_compute_from_source``, used as a +fallback when the declared view is absent from the source altogether. + +:class:`DenseViewFeaturizer` is **public**, re-exported from +:mod:`drevalpy.plugin` and covered by that facade's compatibility promise. The +module keeps its leading underscore anyway: ``_discover_modules`` in +``drevalpy/registry/_builtins.py`` skips ``_``-prefixed files, and without it +this base class would be scanned and spuriously registered as a component. So +the *module path* is private and may move; the symbol reached through +``drevalpy.plugin`` may not. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.featurizers._matrix import feature_names_for_view, stack_view_matrix +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.types.data.batch.feature_block import BlockSpec, FeatureBlock, numeric_feature_block +from drevalpy.types.data.feature_source import FeatureSource + + +class DenseViewFeaturizer(Featurizer): + """Emit one dense numeric block for one feature view, on either entity side.""" + + #: Set on subclasses whose transform is meaningless before ``fit``; they then + #: raise instead of returning garbage. + requires_fit: ClassVar[bool] = False + #: Fit on the deduplicated entity IDs. Right whenever fitting learns a + #: distribution, where repeated rows would silently reweight it. + fit_on_unique_ids: ClassVar[bool] = False + + def __init__(self, *, view: str | None = None) -> None: + """Bind the view this instance reads. + + :param view: View name; ``None`` falls back to the single declared input view. + """ + self._view = view or self.resolve_input_views()[0] + self._output_dim = 0 + self._is_fitted = False + + # ------------------------------------------------------------------ + # Subclass hooks + # ------------------------------------------------------------------ + + def _fetch_hyperparameters(self) -> dict[str, Any] | None: + """Return the HP setting a stored variant must match to be reusable. + + ``None`` matches the default (parameter-free) variant, which is right for + every featurizer whose stored output does not depend on its hyperparameters. + + :returns: HP mapping to match, or ``None``. + """ + return None + + def _fit_entity_ids( + self, + source: FeatureSource, + entity_ids: np.ndarray | None, + pair_expanded_ids: np.ndarray | None, + pair_expanded_es_ids: np.ndarray | None, + ) -> np.ndarray: + """Return the entity IDs to fit on. + + :param source: Feature source providing views for the entity type. + :param entity_ids: Explicit fit IDs, or ``None`` for all identifiers. + :param pair_expanded_ids: Training entity IDs with duplicates per response pair. + :param pair_expanded_es_ids: Early-stopping entity IDs with duplicates. + :returns: IDs whose rows the fit reads. + """ + _ = pair_expanded_ids, pair_expanded_es_ids + ids = entity_ids if entity_ids is not None else source.identifiers + return np.unique(ids) if self.fit_on_unique_ids else ids + + def _fit_state(self, source: FeatureSource, entity_ids: np.ndarray) -> int: + """Learn the transform state and return the resulting output width. + + :param source: Feature source providing views for the entity type. + :param entity_ids: IDs chosen by ``_fit_entity_ids``. + :returns: Output feature dimension after fitting. + """ + return int(self._compute_matrix(source, self._raw_matrix(source, entity_ids)).shape[1]) + + def _compute_matrix(self, source: FeatureSource, matrix: np.ndarray) -> np.ndarray: + """Turn a raw view *matrix* into this featurizer's output matrix. + + :param source: Feature source the matrix came from. + :param matrix: Raw view matrix for the requested entity IDs. + :returns: Output matrix aligned with the same rows. + """ + _ = source + return matrix + + def _block_name(self) -> str: + """Return the name of the single emitted block. + + A declared ``output_block_specs`` wins, so a featurizer can publish under a + name that differs from the view it reads. + + :returns: Block name. + """ + specs: tuple[BlockSpec, ...] = getattr(self, "output_block_specs", ()) + return specs[0].name if specs else self._view + + def _block_feature_names(self, source: FeatureSource) -> tuple[str, ...] | None: + """Return the feature names to attach to the emitted block. + + :param source: Feature source providing views for the entity type. + :returns: Ordered feature names, or ``None`` when the source has none. + """ + return feature_names_for_view(source, self._view) + + # ------------------------------------------------------------------ + # Featurizer contract + # ------------------------------------------------------------------ + + def _raw_matrix(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Stack the declared view, falling back to an on-the-fly computation. + + :param source: Feature source providing views for the entity type. + :param entity_ids: Entity identifiers to stack. + :returns: Raw view matrix. + :raises KeyError: If the view is missing and no fallback is available. + :raises TypeError: If the view cannot be stacked and no fallback is available. + :raises ValueError: If the view is unusable and no fallback is available. + """ + try: + return stack_view_matrix(source, self._view, entity_ids) + except (KeyError, TypeError, ValueError): + compute = getattr(self, "_compute_from_source", None) + if self.precompute and callable(compute): + return compute(source, entity_ids) + raise + + def _require_fitted(self) -> None: + """Reject a transform on a subclass that has state to learn first. + + :raises RuntimeError: If ``requires_fit`` is set and ``fit`` has not run. + """ + if self.requires_fit and not self._is_fitted: + msg = f"{type(self).__name__} must be fit before transform" + raise RuntimeError(msg) + + def _restore_dense_state(self, state: dict[str, object]) -> None: + """Restore the three fields every dense subclass writes in ``get_state``. + + ``view``, ``output_dim`` and the ``fitted`` flag are set by ``__init__`` + here, not by any subclass, so every subclass ``set_state`` was repeating + the same three type-guarded reads around its own one fitted object. A key + the subclass does not write is simply absent and leaves the field alone. + + :param state: Mapping previously returned by ``get_state``. + """ + view = state.get("view") + if isinstance(view, str): + self._view = view + output_dim = state.get("output_dim") + if isinstance(output_dim, int): + self._output_dim = output_dim + if state.get("fitted"): + self._is_fitted = True + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> DenseViewFeaturizer: + """Record the output width, reusing a pre-computed variant when there is one. + + :param source: Feature source providing views for the entity type. + :param entity_ids: entity ids. + :param pair_expanded_ids: Training entity IDs with duplicates per response pair. + :param pair_expanded_es_ids: Early-stopping entity IDs with duplicates. + :returns: Result. + """ + ids = self._fit_entity_ids(source, entity_ids, pair_expanded_ids, pair_expanded_es_ids) + precomputed = self.fetch_precomputed(source, ids, self._fetch_hyperparameters()) + if precomputed is not None: + self._output_dim = int(precomputed.shape[1]) + self._on_precomputed_fit(source) + else: + self._output_dim = self._fit_state(source, ids) + self._is_fitted = True + return self + + def _on_precomputed_fit(self, source: FeatureSource) -> None: + """Record whatever fit metadata survives a pre-computed short circuit. + + :param source: Feature source carrying the stored variant. + """ + _ = source + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Return the dense matrix for *entity_ids*. + + :param source: Feature source providing views for the entity type. + :param entity_ids: entity ids. + :returns: Result. + """ + self._require_fitted() + precomputed = self.fetch_precomputed(source, entity_ids, self._fetch_hyperparameters()) + if precomputed is not None: + return precomputed.astype(np.float32) + return self._compute_matrix(source, self._raw_matrix(source, entity_ids)).astype(np.float32) + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Wrap the dense matrix in a single named numeric block. + + :param source: Feature source providing views for the entity type. + :param entity_ids: entity ids. + :returns: Result. + """ + return { + self._block_name(): numeric_feature_block( + self._transform(source, entity_ids), + feature_names=self._block_feature_names(source), + ) + } + + @property + def output_dim(self) -> int: + """Return output feature dimension after fitting. + + :returns: Result. + """ + return self._output_dim diff --git a/drevalpy/components/featurizers/_featurizer_label.py b/drevalpy/components/featurizers/_featurizer_label.py new file mode 100644 index 000000000..049ffe52f --- /dev/null +++ b/drevalpy/components/featurizers/_featurizer_label.py @@ -0,0 +1,43 @@ +"""Stable labels for featurizer configs in concat blocks and HPO keys.""" + +from __future__ import annotations + +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer + + +def qualified_featurizer_selector(name: str, view: str | None = None) -> str: + """Return the canonical selector for a featurizer leaf. + + View-specific featurizers use bracket syntax (``pca[expression]``). Non-view + featurizers use the bare registry name (``landmarkGenes``). + + :param name: Featurizer registry name. + :param view: Optional explicit omics view. + :returns: Canonical selector string for HPO keys and concat blocks. + """ + if view is not None: + return f"{name}[{view}]" + return name + + +def featurizer_config_block_label(name: str, view: str | None) -> str: + """Return the concat block label for a normalized featurizer config. + + :param name: name. + :param view: view. + :returns: Result. + """ + return qualified_featurizer_selector(name, view) + + +def requires_explicit_view(name: str) -> bool: + """Return whether a featurizer registry name requires an explicit view. + + :param name: name. + :returns: Result. + """ + try: + cls = get_cell_line_featurizer(name) + except (ValueError, ImportError): + return False + return bool(getattr(cls, "requires_view", False)) diff --git a/drevalpy/components/featurizers/_featurizer_tree.py b/drevalpy/components/featurizers/_featurizer_tree.py new file mode 100644 index 000000000..fdc457cec --- /dev/null +++ b/drevalpy/components/featurizers/_featurizer_tree.py @@ -0,0 +1,53 @@ +"""Walk and validate featurizer config trees.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from drevalpy.components.featurizers._featurizer_label import qualified_featurizer_selector + +if TYPE_CHECKING: + from drevalpy.models.config.featurizer import FeaturizerConfig + + +def iter_featurizer_leaves( + featurizer: FeaturizerConfig, + registry: str, +) -> Iterator[FeaturizerConfig]: + """Yield leaf featurizer configs from a tree (concat parents are expanded). + + :param featurizer: Root featurizer config, possibly a concat parent. + :param registry: Default registry used when normalizing nested children. + :yields: Leaf ``FeaturizerConfig`` nodes. + """ + if featurizer.name == "concatFeaturizers": + for child in featurizer.featurizers or (): + yield from iter_featurizer_leaves(child, registry) + return + yield featurizer + + +def ensure_unique_qualified_featurizers(featurizer: FeaturizerConfig, registry: str) -> None: + """Raise ``ValueError`` when a registry slot repeats a qualified selector. + + Duplicate means the same qualified selector (for example ``raw[expression]``) + appears more than once under one registry. The same base name on different + views (``raw[expression]+raw[mutations]``) is allowed. + + :param featurizer: Featurizer tree to validate (concat parents are walked). + :param registry: Registry slot name used in error messages. + :raises ValueError: If the same qualified selector appears twice. + """ + if featurizer.name != "concatFeaturizers": + return + seen: set[str] = set() + for leaf in iter_featurizer_leaves(featurizer, registry): + selector = qualified_featurizer_selector(leaf.name, leaf.view) + if selector in seen: + msg = ( + f"Duplicate featurizer selector {selector!r} in registry {registry!r}. " + "Each qualified featurizer may appear at most once per slot." + ) + raise ValueError(msg) + seen.add(selector) diff --git a/drevalpy/components/featurizers/_leaf_kwargs.py b/drevalpy/components/featurizers/_leaf_kwargs.py new file mode 100644 index 000000000..2c7dec0ef --- /dev/null +++ b/drevalpy/components/featurizers/_leaf_kwargs.py @@ -0,0 +1,46 @@ +"""Resolve featurizer leaf kwargs from options, HP defaults, and resolved values.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Literal + +from drevalpy.components.featurizers._featurizer_label import qualified_featurizer_selector +from drevalpy.models.config.featurizer import FeaturizerConfig +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer + +if TYPE_CHECKING: + from drevalpy.models.config.resolved import ResolvedModelConfig + + +def featurizer_leaf_kwargs( + leaf: FeaturizerConfig, + *, + registry: Literal["cell_line", "drug"], + resolved: ResolvedModelConfig | None, +) -> dict[str, Any]: + """Build featurizer kwargs from options, hyperparameter defaults, and resolved values. + + These are the kwargs passed to featurizer construction, and + the same kwargs that ``Featurizer.resolve_input_views`` interprets. + + :param leaf: Featurizer leaf configuration. + :param registry: ``cell_line`` or ``drug``. + :param resolved: Optional resolved instance values for tunable kwargs. + :returns: Keyword arguments for featurizer construction. + """ + kwargs: dict[str, Any] = dict(leaf.options or {}) + space = dict(leaf.hyperparameter_space or {}) + if not space: + cls = get_cell_line_featurizer(leaf.name) if registry == "cell_line" else get_drug_featurizer(leaf.name) + space = dict(cls.get_hyperparameter_space()) + for key, spec in space.items(): + if isinstance(spec, Mapping) and "default" in spec: + kwargs.setdefault(key, spec["default"]) + if resolved is not None: + selector = qualified_featurizer_selector(leaf.name, leaf.view) + kwargs.update(resolved.featurizer_values(registry, selector)) + if leaf.view is not None: + kwargs.setdefault("view", leaf.view) + return kwargs diff --git a/drevalpy/components/featurizers/_matrix.py b/drevalpy/components/featurizers/_matrix.py new file mode 100644 index 000000000..c7a98761a --- /dev/null +++ b/drevalpy/components/featurizers/_matrix.py @@ -0,0 +1,74 @@ +"""Helpers for building dense matrices from a `FeatureSource`.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.types.data.feature_source import FeatureSource + + +def unique_entity_ids(entity_ids: np.ndarray) -> np.ndarray: + """Return unique entity ids in first-seen order. + + :param entity_ids: entity ids. + :returns: Result. + """ + uniq, index = np.unique(entity_ids, return_index=True) + return uniq[index.argsort()] + + +def entity_index_map(entity_ids: np.ndarray) -> dict[str, int]: + """Map entity id strings to row indices in a dense featurization matrix. + + :param entity_ids: entity ids. + :returns: Result. + """ + return {str(entity_id): row for row, entity_id in enumerate(entity_ids)} + + +def feature_names_for_view(source: FeatureSource, view: str) -> tuple[str, ...] | None: + """Return ordered feature names for *view* via the source protocol. + + :param source: Feature source implementing the FeatureSource protocol. + :param view: view. + :returns: Result. + """ + return source.get_feature_names(view) + + +def stack_view_matrix( + source: FeatureSource, + view: str, + entity_ids: np.ndarray, +) -> np.ndarray: + """Stack one view into ``(len(entity_ids), n_features)`` via the source protocol. + + :param source: Feature source implementing the FeatureSource protocol. + :param view: view. + :param entity_ids: entity ids. + :returns: Result. + """ + return source.get_view_matrix(view, entity_ids) + + +def stack_pair_features( + cell_line_matrix: np.ndarray, + drug_matrix: np.ndarray, + cell_line_indices: np.ndarray, + drug_indices: np.ndarray, +) -> np.ndarray: + """Concatenate featurized cell-line and drug rows for each pair. + + :param cell_line_matrix: cell line matrix. + :param drug_matrix: drug matrix. + :param cell_line_indices: cell line indices. + :param drug_indices: drug indices. + :returns: Result. + """ + if cell_line_matrix.size == 0: + return drug_matrix[drug_indices] + if drug_matrix.size == 0: + return cell_line_matrix[cell_line_indices] + x_cell_line = cell_line_matrix[cell_line_indices] + x_drug = drug_matrix[drug_indices] + return np.concatenate([x_cell_line, x_drug], axis=1) diff --git a/drevalpy/components/featurizers/_nan_tolerance.py b/drevalpy/components/featurizers/_nan_tolerance.py new file mode 100644 index 000000000..e92106c9c --- /dev/null +++ b/drevalpy/components/featurizers/_nan_tolerance.py @@ -0,0 +1,132 @@ +"""NaN tolerance for featurizers: detect all-NaN entities, warn, re-insert. + +Every public ``fit``/``transform`` on :class:`~drevalpy.plugin.Featurizer` brackets +its subclass hook with the same three steps - work out which entities have usable +rows, complain when too few do, and pad the result back to full length. That +policy is independent of what any featurizer computes, and it reaches nothing on +the class beyond the three declarations below, which is why it lives apart from +the fit/transform contract in ``base.py``. + +The module is ``_``-prefixed so ``_discover_modules`` in +``drevalpy/registry/_builtins.py`` does not scan it as a component. +""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.log import get_logger +from drevalpy.types.data.batch.feature_block import FeatureBlock +from drevalpy.types.data.feature_source import FeatureSource + +_logger = get_logger(__name__) + + +class NanToleranceMixin: + """Decide which entities are usable and keep NaN rows out of the transform.""" + + #: Fraction of invalid entities above which a warning is logged. + nan_threshold: ClassVar[float] = 0.2 + + #: Declared by ``FeaturizerDeclarationsMixin``; read here to pick the probe view. + entity_id_only: ClassVar[bool] + input_views: ClassVar[tuple[str, ...] | None] + + def _expand_blocks_with_nan( + self, + valid_blocks: dict[str, FeatureBlock], + valid_mask: np.ndarray, + n_total: int, + ) -> dict[str, FeatureBlock]: + """Expand valid-only blocks back to full size, inserting NaN for invalid rows. + + Non-entity-aligned blocks are passed through unchanged. + + :param valid_blocks: Blocks computed on only valid entity IDs. + :param valid_mask: Boolean mask of shape ``(n_total,)`` (True = valid). + :param n_total: Total number of entities (valid + invalid). + :returns: Blocks aligned to the full set of entity IDs. + """ + expanded: dict[str, FeatureBlock] = {} + for name, block in valid_blocks.items(): + if not block.entity_aligned: + expanded[name] = block + continue + expanded[name] = _padded_block(block, valid_mask, n_total) + return expanded + + def _detect_valid(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Return a boolean mask indicating which entities have non-NaN features. + + Default: entity_id_only featurizers treat all as valid; view-based + featurizers check the first input view for all-NaN rows. + + :param source: Feature source. + :param entity_ids: Entity IDs to check. + :returns: Boolean array of shape ``(len(entity_ids),)``. + """ + all_valid = np.ones(len(entity_ids), dtype=bool) + if self.entity_id_only: + return all_valid + + view = getattr(self, "_view", None) + if view is None and self.input_views: + view = self.input_views[0] + if view is None: + return all_valid + + try: + matrix = source.get_view_matrix(view, entity_ids) + except (KeyError, TypeError, ValueError): + return all_valid + + if matrix.ndim != 2 or matrix.dtype.kind not in ("f", "i", "u"): + return all_valid + + return ~np.all(np.isnan(matrix), axis=1) + + def _warn_if_above_threshold(self, valid_mask: np.ndarray, context: str) -> None: + """Log a warning when the fraction of invalid entities exceeds the threshold. + + :param valid_mask: Boolean array (True = valid). + :param context: Human-readable label for the warning message. + """ + if len(valid_mask) == 0: + return + invalid_frac = 1.0 - valid_mask.mean() + if invalid_frac > self.nan_threshold: + _logger.warning( + "%s: %.0f%% of inputs are invalid (threshold: %.0f%%)", + context, + invalid_frac * 100, + self.nan_threshold * 100, + ) + + +def _padded_block(block: FeatureBlock, valid_mask: np.ndarray, n_total: int) -> FeatureBlock: + """Rebuild one entity-aligned block at full length, padding the invalid rows. + + A numeric matrix is padded with NaN; any other payload is an object array + padded with ``None``, since there is no NaN for a graph or a token sequence. + + :param block: Block computed on the valid entity IDs only. + :param valid_mask: Boolean mask of shape ``(n_total,)`` (True = valid). + :param n_total: Total number of entities (valid + invalid). + :returns: Block of length *n_total* carrying *block*'s metadata. + """ + if block.format == FeatureFormat.NUMERIC_MATRIX: + full = np.full((n_total, block.values.shape[1]), np.nan, dtype=np.float32) + else: + full = np.empty(n_total, dtype=object) + full[:] = None + full[valid_mask] = block.values + return FeatureBlock( + values=full, + format=block.format, + feature_names=block.feature_names, + metadata=block.metadata, + entity_aligned=True, + ) diff --git a/drevalpy/components/featurizers/_one_hot.py b/drevalpy/components/featurizers/_one_hot.py new file mode 100644 index 000000000..1708fc467 --- /dev/null +++ b/drevalpy/components/featurizers/_one_hot.py @@ -0,0 +1,74 @@ +"""Shared one-hot encoding helpers for categorical featurizers.""" + +from __future__ import annotations + +import numpy as np + + +class OneHotCategoryEncoder: + """Fit a fixed category vocabulary and emit dense one-hot rows.""" + + def __init__(self) -> None: + """Initialize instance state.""" + self._category_to_index: dict[str, int] = {} + + @property + def categories(self) -> list[str]: + """Return fitted category labels in index order. + + :returns: Result. + """ + return [category for category, _ in sorted(self._category_to_index.items(), key=lambda item: item[1])] + + @property + def output_dim(self) -> int: + """Return output feature dimension after fitting. + + :returns: Result. + """ + return len(self._category_to_index) + + def fit_categories(self, categories: np.ndarray) -> None: + """Learn the category vocabulary from observed values. + + :param categories: categories. + """ + unique = sorted({str(category) for category in np.asarray(categories).reshape(-1)}) + self._category_to_index = {category: index for index, category in enumerate(unique)} + + def transform(self, categories: np.ndarray, *, unknown_zero: bool = True) -> np.ndarray: + """Transform inputs into feature payloads. + + :param categories: categories. + :param unknown_zero: unknown zero. + :returns: Result. + :raises KeyError: Raised on invalid input. + """ + if self.output_dim == 0: + return np.empty((len(categories), 0), dtype=np.float32) + matrix = np.zeros((len(categories), self.output_dim), dtype=np.float32) + for row, category in enumerate(np.asarray(categories).reshape(-1)): + index = self._category_to_index.get(str(category)) + if index is None: + if not unknown_zero: + msg = f"Unknown category {category!r} for one-hot featurizer" + raise KeyError(msg) + continue + matrix[row, index] = 1.0 + return matrix + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + return {"categories": self.categories} + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + categories = state.get("categories") + if isinstance(categories, list): + self._category_to_index = {str(category): index for index, category in enumerate(categories)} diff --git a/drevalpy/components/featurizers/_side_binding.py b/drevalpy/components/featurizers/_side_binding.py new file mode 100644 index 000000000..25edeeda3 --- /dev/null +++ b/drevalpy/components/featurizers/_side_binding.py @@ -0,0 +1,152 @@ +"""Bind one side-agnostic featurizer implementation to both entity sides. + +``Featurizer.side`` is a ``ClassVar`` stamped onto the class by the registry +(``FeaturizerRegistry.register``), and ``list_stored_variants`` is a +``classmethod`` reading ``cls.side``. A single class therefore cannot be +registered on both sides - the second registration would overwrite the first +one's ``side``. :func:`register_for_sides` resolves that by deriving one subclass +per side from the shared implementation, so each registry gets its own class +object to stamp. + +The derived classes are injected back into the defining module's namespace +because ``_reregister_from_module`` in ``drevalpy/registry/_builtins.py`` walks +``vars(module)`` and dispatches on each class's ``side``; a class living only in +this decorator's closure would silently vanish from the registries after a +registry ``clear()``. + +:func:`register_for_sides` is **public**, re-exported from +:mod:`drevalpy.plugin` and covered by that facade's compatibility promise. The +module keeps its leading underscore anyway: ``_discover_modules`` in +``drevalpy/registry/_builtins.py`` skips ``_``-prefixed files, and this module +must stay out of the component scan. So the *module path* is private and may +move; the symbol reached through ``drevalpy.plugin`` may not. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable, Iterable +from typing import Any + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.types.enums.literature_reference import LiteratureReference + +#: Entity side -> (derived-class name prefix, ``module:ClassName`` of the side base). +_SIDE_BASES: dict[str, tuple[str, str]] = { + "cell_line": ("CellLine", "drevalpy.components.featurizers.cell_line.base:CellLineFeaturizer"), + "drug": ("Drug", "drevalpy.components.featurizers.drug.base:DrugFeaturizer"), +} + +#: Marker stripped out of a shared implementation's name to make room for the side +#: prefix: ``SharedIdentityFeaturizer`` -> ``CellLineIdentityFeaturizer``. +_SHARED_PREFIX = "Shared" + + +def known_sides() -> tuple[str, ...]: + """Return the entity sides a shared featurizer can be bound to. + + :returns: Sorted side names. + """ + return tuple(sorted(_SIDE_BASES)) + + +def _side_base(side: str) -> type[Any]: + """Import and return the featurizer base class for *side*. + + :param side: Entity side, ``"cell_line"`` or ``"drug"``. + :returns: That side's ``Featurizer`` subclass. + :raises ValueError: If *side* is not a known entity side. + """ + entry = _SIDE_BASES.get(side) + if entry is None: + msg = f"unknown featurizer side {side!r}; expected one of {list(known_sides())}" + raise ValueError(msg) + module_name, class_name = entry[1].split(":") + return getattr(__import__(module_name, fromlist=[class_name]), class_name) + + +def _side_register(side: str) -> Callable[..., Callable[[type[Any]], type[Any]]]: + """Return the ``register`` decorator factory of the *side* registry. + + :param side: Entity side, ``"cell_line"`` or ``"drug"``. + :returns: That registry's ``register`` function. + """ + return __import__(f"drevalpy.registry.{side}_featurizer", fromlist=["register"]).register + + +def derived_class_name(implementation_name: str, side: str) -> str: + """Return the class name for the *side* binding of an implementation. + + :param implementation_name: ``__name__`` of the shared implementation class. + :param side: Entity side, ``"cell_line"`` or ``"drug"``. + :returns: Side-prefixed class name. + :raises ValueError: If *side* is not a known entity side. + """ + entry = _SIDE_BASES.get(side) + if entry is None: + msg = f"unknown featurizer side {side!r}; expected one of {list(known_sides())}" + raise ValueError(msg) + return f"{entry[0]}{implementation_name.removeprefix(_SHARED_PREFIX)}" + + +def _derive(implementation: type[Any], side: str) -> type[Any]: + """Create the *side*-bound subclass of *implementation*. + + The side base carries ``ABCMeta``, so the subclass is built through that + metaclass rather than through ``type`` directly. + + :param implementation: Side-agnostic implementation class. + :param side: Entity side, ``"cell_line"`` or ``"drug"``. + :returns: Freshly created, not-yet-registered subclass. + """ + base = _side_base(side) + return type(base)( + derived_class_name(implementation.__name__, side), + (implementation, base), + {"__module__": implementation.__module__, "__doc__": implementation.__doc__}, + ) + + +def register_for_sides( + name: str, + *, + description: str | dict[str, str], + contract: FeatureContract | FeatureFormat | None = None, + tags: Iterable[str] | None = None, + reference: LiteratureReference | None = None, + sides: Iterable[str] = ("cell_line", "drug"), +) -> Callable[[type[Any]], type[Any]]: + """Register one side-agnostic implementation under *name* on every side. + + For each side a subclass of the decorated implementation is derived against + that side's featurizer base, registered under *name*, and bound into the + defining module's namespace under a side-prefixed class name. The decorated + implementation is returned unregistered, so it stays importable as the shared + logic it is. + + :param name: Registry name, identical on every side. + :param description: Registry description; pass a ``{side: text}`` mapping to + word it per side, or a single string used for every side. + :param contract: Feature format contract, forwarded to each registration. + :param tags: Optional discovery tags, forwarded to each registration. + :param reference: Optional literature citation, forwarded to each registration. + :param sides: Entity sides to bind; both by default. + :returns: Class decorator returning the undecorated implementation. + """ + + def decorator(implementation: type[Any]) -> type[Any]: + module = sys.modules[implementation.__module__] + for side in sides: + derived = _derive(implementation, side) + text = description[side] if isinstance(description, dict) else description + registered = _side_register(side)( + name, + description=text, + contract=contract, + tags=tags, + reference=reference, + )(derived) + setattr(module, registered.__name__, registered) + return implementation + + return decorator diff --git a/drevalpy/components/featurizers/base.py b/drevalpy/components/featurizers/base.py new file mode 100644 index 000000000..f790be7e9 --- /dev/null +++ b/drevalpy/components/featurizers/base.py @@ -0,0 +1,196 @@ +"""Base classes for featurizers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.contracts.hyperparameter_space import TunableComponentMixin +from drevalpy.components.featurizers._declarations import FeaturizerDeclarationsMixin +from drevalpy.components.featurizers._nan_tolerance import NanToleranceMixin +from drevalpy.components.featurizers.storage import FeaturizerStorageMixin +from drevalpy.types.data.batch.feature_block import FeatureBlock +from drevalpy.types.data.feature_source import FeatureSource + + +class HPOStrategy(Enum): + """How a featurizer's hyperparameters are searched during HPO.""" + + CONTINUOUS = "continuous" + PRECOMPUTED = "precomputed" + + +class Featurizer( + FeaturizerStorageMixin, + FeaturizerDeclarationsMixin, + NanToleranceMixin, + TunableComponentMixin, + ABC, +): + """Transform feature tables into per-entity representation payloads. + + Cell-line featurizers consume cell-line features; drug featurizers consume + drug features. Subclasses must be registered + to the cell-line or drug featurizer registry using + ``@register`` (from the cell_line_featurizer or drug_featurizer registry), so that + they can be discovered and used in models. + + Each subclass declares which raw feature views it reads via ``input_views`` + (or ``requires_view`` / ``entity_id_only`` / a ``resolve_input_views`` + override); registration rejects featurizers that declare nothing. + + ``contract`` may be declared on the class body or passed to ``@register``. When + both are given the decorator argument wins. + + What is left in this class is the fit/transform contract itself. The concerns + that reach none of it live in mixins: the class-body declarations in + ``_declarations.py``, the NaN policy the public methods below bracket their + subclass hooks with in ``_nan_tolerance.py``, the pre-computed variant store + in ``storage.py``, and the HPO-space and checkpoint hooks it shares verbatim + with ``Predictor`` in ``contracts/hyperparameter_space.py``. + """ + + hpo_strategy: ClassVar[HPOStrategy] = HPOStrategy.CONTINUOUS + + # ------------------------------------------------------------------ + # Public fit / transform with NaN tolerance + # ------------------------------------------------------------------ + + def fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> Featurizer: + """Fit on valid entities, skipping those with all-NaN feature rows. + + :param source: Feature source providing views for the entity type. + :param entity_ids: Subset of entity identifiers to fit on; ``None`` uses all. + :param pair_expanded_ids: Training entity IDs with duplicates per response pair. + :param pair_expanded_es_ids: Early-stopping entity IDs with duplicates. + + :returns: Fitted featurizer instance (usually ``self``). + """ + ids = entity_ids if entity_ids is not None else source.identifiers + valid_mask = self._detect_valid(source, ids) + self._warn_if_above_threshold(valid_mask, f"{type(self).__name__}.fit") + valid_ids = ids[valid_mask] if not valid_mask.all() else ids + + self._fit( + source, + entity_ids=valid_ids, + pair_expanded_ids=pair_expanded_ids, + pair_expanded_es_ids=pair_expanded_es_ids, + ) + return self + + def transform_blocks( + self, + source: FeatureSource, + entity_ids: np.ndarray, + ) -> dict[str, FeatureBlock]: + """Public NaN-safe entry point for block-based transform. + + Detects invalid (all-NaN) entities, transforms only valid ones via + ``_transform_blocks``, and inserts NaN rows for invalid entities. + + :param source: Feature source providing views for the entity type. + :param entity_ids: Entity identifiers to transform. + :returns: Mapping of block name to ``FeatureBlock`` payloads aligned with *entity_ids*. + """ + valid_mask = self._detect_valid(source, entity_ids) + self._warn_if_above_threshold(valid_mask, f"{type(self).__name__}.transform_blocks") + if valid_mask.all(): + return self._transform_blocks(source, entity_ids) + valid_ids = entity_ids[valid_mask] + valid_blocks = self._transform_blocks(source, valid_ids) + return self._expand_blocks_with_nan(valid_blocks, valid_mask, len(entity_ids)) + + def transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Public NaN-safe entry point for matrix-based transform. + + Detects invalid (all-NaN) entities, transforms only valid ones via + ``_transform``, and inserts NaN rows for invalid entities. + + :param source: Feature source providing views for the entity type. + :param entity_ids: Entity identifiers to transform. + :returns: Feature matrix aligned with *entity_ids*. + """ + valid_mask = self._detect_valid(source, entity_ids) + self._warn_if_above_threshold(valid_mask, f"{type(self).__name__}.transform") + if valid_mask.all(): + return self._transform(source, entity_ids) + valid_ids = entity_ids[valid_mask] + valid_result = self._transform(source, valid_ids) + result = np.full((len(entity_ids), valid_result.shape[1]), np.nan, dtype=np.float32) + result[valid_mask] = valid_result + return result + + # ------------------------------------------------------------------ + # Abstract methods for subclasses + # ------------------------------------------------------------------ + + @abstractmethod + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> Featurizer: + """Subclass fitting logic on pre-validated (non-NaN) entity IDs. + + :param source: Feature source providing views for the entity type. + :param entity_ids: Subset of entity identifiers to fit on; ``None`` uses all. + :param pair_expanded_ids: Training entity IDs with duplicates per response pair. + :param pair_expanded_es_ids: Early-stopping entity IDs with duplicates. + + :returns: Fitted featurizer instance (usually ``self``). + """ + + @abstractmethod + def _transform_blocks( + self, + source: FeatureSource, + entity_ids: np.ndarray, + ) -> dict[str, FeatureBlock]: + """Return named feature blocks for pre-validated (non-NaN) entity IDs. + + Subclasses must implement this. Called by ``transform_blocks`` after + NaN filtering. + + :param source: Feature source providing views for the entity type. + :param entity_ids: Entity identifiers to transform (only valid ones). + :returns: Mapping of block name to ``FeatureBlock`` payloads aligned with *entity_ids*. + """ + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Return a flat feature matrix by concatenating numeric blocks. + + Default: derives from ``_transform_blocks``. Subclasses that work + directly on matrices can override this. + + :param source: Feature source providing views for the entity type. + :param entity_ids: Entity identifiers to transform (only valid ones). + :returns: Feature matrix aligned with *entity_ids*. + """ + blocks = self._transform_blocks(source, entity_ids) + arrays = [b.values for b in blocks.values() if b.entity_aligned and b.format == FeatureFormat.NUMERIC_MATRIX] + if not arrays: + return np.empty((len(entity_ids), 0), dtype=np.float32) + return np.concatenate(arrays, axis=1) + + @property + @abstractmethod + def output_dim(self) -> int: + """Feature dimension after ``fit``. + + :returns: Result. + """ diff --git a/drevalpy/components/featurizers/cell_line/__init__.py b/drevalpy/components/featurizers/cell_line/__init__.py new file mode 100644 index 000000000..7a104c006 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/__init__.py @@ -0,0 +1 @@ +"""Cell-line featurizers.""" diff --git a/drevalpy/components/featurizers/cell_line/_proteomics_transformer.py b/drevalpy/components/featurizers/cell_line/_proteomics_transformer.py new file mode 100644 index 000000000..da4e2b4be --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/_proteomics_transformer.py @@ -0,0 +1,92 @@ +"""The sklearn transformer behind the ``normalizedProteomics`` featurizer. + +This class lives in its own module purely so that ``sklearn`` stays off the +startup path. It subclasses ``BaseEstimator`` and ``TransformerMixin``, and a +base class has to exist when the ``class`` statement runs - unlike a call inside +a method, it cannot be deferred. Since ``drevalpy.registry`` imports every public +module under ``featurizers/cell_line/`` to register its featurizers on +``import drevalpy``, leaving the class in ``normalized_proteomics.py`` put the +whole ~0.39s ``sklearn`` import (it reaches ``scipy.stats`` through +``sklearn.utils``) on the critical path of every CLI invocation. + +``normalized_proteomics`` re-exports the class lazily, so the historical import +path keeps working - including for checkpoints pickled before the split, which +record the class by its old module name. + +See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +import numpy as np +from sklearn.base import BaseEstimator, TransformerMixin + + +class ProteomicsMedianCenterAndImputeTransformer(BaseEstimator, TransformerMixin): + """Performs median centering and imputation of proteomics data.""" + + def __init__( + self, + feature_threshold=0.7, + n_features=1000, + normalization_downshift=1.8, + normalization_width=0.3, + imputation_seed=100, + ): + """Hyperparameters for the normalization. + + :param feature_threshold: Minimum fraction of non-missing protein values per feature. + :param n_features: Fallback feature count when thresholding leaves too few features. + :param normalization_downshift: Downshift factor for the mean. + :param normalization_width: Width factor for the standard deviation. + :param imputation_seed: Seed for per-call missing-value imputation without touching the + global NumPy RNG state. + """ + self.feature_threshold = feature_threshold + self.n_features = n_features + self.normalization_downshift = normalization_downshift + self.normalization_width = normalization_width + self.imputation_seed = imputation_seed + self.protein_indices = np.array([]) + self.mean_median = 0 + + def fit(self, X, y=None): # noqa: N803 # sklearn API + """Learn top n_feature complete proteins and calculate the mean median of train cell lines. + + :param X: input proteomics data + :param y: not used + :returns: self + """ + required_proteins = int(X.shape[0] * self.feature_threshold) + completeness = np.sum(~np.isnan(X), axis=0) + n_complete_features = np.count_nonzero(completeness >= required_proteins) + if n_complete_features < self.n_features: + sorted_indices = np.argsort(completeness)[::-1] + self.protein_indices = sorted_indices[: self.n_features] + else: + self.protein_indices = np.where(completeness >= required_proteins)[0] + selected_proteins = X[:, self.protein_indices] + medians = np.nanmedian(selected_proteins, axis=1) + self.mean_median = np.nanmean(medians) + return self + + def transform(self, X): # noqa: N803 # sklearn API + """Median center the data and impute missing values with downshifted normal distribution. + + :param X: input proteomics data + :returns: transformed proteomics data + """ + proteomics_vector = X[0][self.protein_indices] + + correction_factor = self.mean_median / np.nanmedian(proteomics_vector) + proteomics_vector = proteomics_vector * correction_factor + cell_line_mean = np.nanmean(proteomics_vector) + cell_line_sd = np.nanstd(proteomics_vector) + downshifted_mean = cell_line_mean - (self.normalization_downshift * cell_line_sd) + shrinked_sd = self.normalization_width * cell_line_sd + n_missing = np.count_nonzero(np.isnan(proteomics_vector)) + rng = np.random.default_rng(self.imputation_seed) + proteomics_vector[np.isnan(proteomics_vector)] = rng.normal( + loc=downshifted_mean, scale=shrinked_sd, size=n_missing + ) + return [proteomics_vector] diff --git a/drevalpy/components/featurizers/cell_line/_sparsego_metadata.py b/drevalpy/components/featurizers/cell_line/_sparsego_metadata.py new file mode 100644 index 000000000..006a60b0f --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/_sparsego_metadata.py @@ -0,0 +1,56 @@ +"""Typed access to SparseGO ontology metadata via ``FeatureSource``.""" + +from __future__ import annotations + +from typing import Any, TypedDict + +import numpy as np + +from drevalpy.types.data.feature_source import FeatureSource + + +class SparseGOOntologyMetadata(TypedDict): + """Ontology graph metadata attached to a SparseGO ``FeatureSource``.""" + + layer_connections: list[np.ndarray] + gene2id_mapping_ont: dict[str, int] + ontology_gene_order: tuple[str, ...] + gene_dim_input: int + + +def attach_sparsego_ontology_metadata(source: FeatureSource, metadata: SparseGOOntologyMetadata) -> None: + """Store SparseGO ontology metadata on a feature source. + + :param source: Feature source supporting metadata storage. + :param metadata: Ontology metadata to attach. + """ + if hasattr(source, "_meta_info"): + source._meta_info["sparsego_ontology"] = metadata + elif hasattr(source, "_dataset"): + source._dataset._mdata.uns["sparsego_ontology"] = metadata + else: + raise TypeError(f"Cannot attach metadata to {type(source).__name__}") + + +def read_sparsego_ontology_metadata(source: FeatureSource) -> SparseGOOntologyMetadata | None: + """Return SparseGO ontology metadata from a feature source. + + :param source: Feature source with metadata access. + :returns: Result. + """ + metadata: Any = None + for key in ("sparsego_ontology", "sparsego"): + try: + metadata = source.get_metadata(key) + except (KeyError, AttributeError): + continue + if metadata is not None: + break + if isinstance(metadata, dict) and "layer_connections" in metadata: + return { + "layer_connections": metadata["layer_connections"], + "gene2id_mapping_ont": metadata["gene2id_mapping_ont"], + "ontology_gene_order": metadata["ontology_gene_order"], + "gene_dim_input": metadata["gene_dim_input"], + } + return None diff --git a/drevalpy/components/featurizers/cell_line/base.py b/drevalpy/components/featurizers/cell_line/base.py new file mode 100644 index 000000000..1563f6b36 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/base.py @@ -0,0 +1,14 @@ +"""Base classes for cell-line featurizers.""" + +from __future__ import annotations + +from drevalpy.components.featurizers._dense_view import DenseViewFeaturizer +from drevalpy.components.featurizers.base import Featurizer + + +class CellLineFeaturizer(Featurizer): + """Base for featurizers that read one or more cell-line feature views.""" + + +class DenseViewCellLineFeaturizer(DenseViewFeaturizer, CellLineFeaturizer): + """Cell-line binding of the shared single-view dense featurizer base.""" diff --git a/drevalpy/components/featurizers/cell_line/bionic.py b/drevalpy/components/featurizers/cell_line/bionic.py new file mode 100644 index 000000000..0821294c5 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/bionic.py @@ -0,0 +1,138 @@ +"""BIONIC featurizer for DIPK.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.data.artifacts import get_artifact +from drevalpy.log import get_logger +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.feature_source import FeatureSource + +logger = get_logger(__name__) + + +@register( + "bionic", + description="BIONIC PPI-based cell-line features for DIPK.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class BionicCellLineFeaturizer(DenseViewCellLineFeaturizer): + """Bionic cell line featurizer component. + + Uses pre-trained BIONIC gene embeddings (from PPI networks) to create + per-cell-line feature vectors by aggregating the PPI embeddings of the + top-expressed genes. + """ + + input_views: ClassVar[tuple[str, ...]] = ("bionic_features",) + source_views: ClassVar[tuple[str, ...]] = ("gene_expression",) + precompute: ClassVar[bool] = True + + def __init__(self, *, view: str | None = None, gene_add_num: int = 512, aggregation: str = "mean") -> None: + """Initialize instance state. + + :param view: view. + :param gene_add_num: Number of top-expressed genes to aggregate. + :param aggregation: Aggregation method for gene embeddings ("mean", "max", "sum"). + """ + super().__init__(view=view) + self._gene_add_num = int(gene_add_num) + self._aggregation = aggregation + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable hyperparameter specs. + + :returns: HP space mapping. + """ + return { + "gene_add_num": {"type": "categorical", "choices": [128, 256, 512, 1024], "default": 512}, + "aggregation": {"type": "categorical", "choices": ["mean", "max", "sum"], "default": "mean"}, + } + + def _compute_from_source(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Compute BIONIC features from gene expression + downloaded PPI embeddings. + + :param source: Feature source providing cell-line views. + :param entity_ids: Cell-line IDs. + :returns: Float32 array of shape (len(entity_ids), embed_dim). + """ + ppi_features, ppi_gene_names, gene_list_sel = _load_ppi_data() + + ppi_lookup = {gene: ppi_features[i] for i, gene in enumerate(ppi_gene_names)} + eligible_genes = gene_list_sel & set(ppi_gene_names) + + expr_matrix = source.get_view_matrix("gene_expression", entity_ids) + gene_names = source.get_feature_names("gene_expression") + if gene_names is None: + msg = "gene_expression view must provide feature names" + raise ValueError(msg) + + embed_dim = ppi_features.shape[1] + result = np.zeros((len(entity_ids), embed_dim), dtype=np.float32) + for i in range(len(entity_ids)): + result[i] = _aggregate_ppi_for_cell_line( + expr_matrix[i], + gene_names, + eligible_genes, + ppi_lookup, + self._gene_add_num, + embed_dim, + self._aggregation, + ) + return result + + +def _load_ppi_data() -> tuple[np.ndarray, list[str], set[str]]: + """Load PPI features and gene list from the artifact cache (auto-downloads if needed). + + :returns: Tuple of (ppi_features array, gene_names list, gene_list_sel set). + """ + import pandas as pd + + ppi_path = get_artifact("human_ppi_features.tsv") + gene_list_path = get_artifact("gene_list_sel.txt") + + ppi_df = pd.read_csv(ppi_path, index_col=0, sep="\t") + ppi_features = ppi_df.values.astype(np.float32) + ppi_gene_names = list(ppi_df.index) + + with open(gene_list_path, encoding="utf-8") as f: + gene_list_sel = {line.strip() for line in f if line.strip()} + + return ppi_features, ppi_gene_names, gene_list_sel + + +def _aggregate_ppi_for_cell_line( + expr_row: np.ndarray, + gene_names: tuple[str, ...], + eligible_genes: set[str], + ppi_lookup: dict[str, np.ndarray], + gene_add_num: int, + embed_dim: int, + aggregation: str, +) -> np.ndarray: + """Aggregate PPI vectors of top-expressed eligible genes for one cell line.""" + sorted_indices = np.argsort(-expr_row) + selected: list[np.ndarray] = [] + for idx in sorted_indices: + if len(selected) >= gene_add_num: + break + gene = gene_names[idx] + if gene in eligible_genes: + selected.append(ppi_lookup[gene]) + if not selected: + return np.zeros(embed_dim, dtype=np.float32) + stacked = np.array(selected) + if aggregation == "mean": + return stacked.mean(axis=0).astype(np.float32) + if aggregation == "max": + return stacked.max(axis=0).astype(np.float32) + if aggregation == "sum": + return stacked.sum(axis=0).astype(np.float32) + return stacked.mean(axis=0).astype(np.float32) diff --git a/drevalpy/components/featurizers/cell_line/dipk_gene_expression.py b/drevalpy/components/featurizers/cell_line/dipk_gene_expression.py new file mode 100644 index 000000000..e747b8288 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/dipk_gene_expression.py @@ -0,0 +1,181 @@ +"""DIPK gene-expression autoencoder featurizer. + +The autoencoder in +``drevalpy.components.predictors.literature.dipk.gene_expression_encoder`` +defines ``torch.nn.Module`` subclasses, so importing it costs ~0.32s of ``torch``. +``drevalpy.registry`` imports this module to register the +``dipkGeneExpression`` featurizer on ``import drevalpy``, so the three symbols are +pulled in inside the methods that use them and re-exported lazily below for the +historical import path. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.feature_source import FeatureSource +from drevalpy.utils.torch_io import load_state_dict, save_state_dict + +if TYPE_CHECKING: + from drevalpy.components.predictors.literature.dipk.gene_expression_encoder import GeneExpressionEncoder + +_RE_EXPORTED = frozenset({"GeneExpressionEncoder", "encode_gene_expression", "train_gene_expession_autoencoder"}) + + +def __getattr__(name: str) -> Any: + """Resolve the lazily re-exported autoencoder symbols on first access. + + :param name: Attribute being looked up on this module. + :returns: The requested attribute, imported on demand. + :raises AttributeError: If *name* is not one of the re-exported symbols. + """ + if name in _RE_EXPORTED: + from drevalpy.components.predictors.literature.dipk import gene_expression_encoder + + return getattr(gene_expression_encoder, name) + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + +@register( + "dipkGeneExpression", + description="DIPK gene-expression autoencoder embeddings.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class DIPKGeneExpressionFeaturizer(DenseViewCellLineFeaturizer): + """Encode intersection genes into the 512-dimensional DIPK representation.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX),) + input_views: ClassVar[tuple[str, ...]] = ("gene_expression",) + requires_fit: ClassVar[bool] = True + + def __init__(self, *, epochs_autoencoder: int = 100) -> None: + """Store the autoencoder training epoch budget. + + :param epochs_autoencoder: Number of autoencoder training epochs. + """ + super().__init__() + self._epochs = int(epochs_autoencoder) + self._encoder: GeneExpressionEncoder | None = None + self._input_dim = 0 + self._latent_dim = 512 + self._validation_ids: np.ndarray | None = None + + def _fit_entity_ids( + self, + source: FeatureSource, + entity_ids: np.ndarray | None, + pair_expanded_ids: np.ndarray | None, + pair_expanded_es_ids: np.ndarray | None, + ) -> np.ndarray: + """Fit on the pair-expanded training IDs, remembering the validation split. + + :param source: Feature source providing view matrices. + :param entity_ids: Unused; IDs come from *pair_expanded_ids*. + :param pair_expanded_ids: Training entity IDs with duplicates per response pair. + :param pair_expanded_es_ids: Early-stopping entity IDs with duplicates. + :returns: The pair-expanded training IDs. + :raises ValueError: If *pair_expanded_ids* is missing. + """ + _ = source, entity_ids + if pair_expanded_ids is None: + raise ValueError("dipkGeneExpression requires pair_expanded_ids") + self._validation_ids = pair_expanded_es_ids + return pair_expanded_ids + + def _fit_state(self, source: FeatureSource, entity_ids: np.ndarray) -> int: + """Train the DIPK autoencoder and return its latent width. + + :param source: Feature source providing view matrices. + :param entity_ids: Pair-expanded training entity IDs. + :returns: Latent dimensionality of the trained encoder. + :raises ValueError: If the train or early-stopping ID set is missing or empty. + """ + from drevalpy.components.predictors.literature.dipk.gene_expression_encoder import ( + train_gene_expession_autoencoder, + ) + + validation_ids = self._validation_ids + if validation_ids is None or len(entity_ids) == 0 or len(validation_ids) == 0: + raise ValueError("dipkGeneExpression requires non-empty train and early-stopping IDs") + train = self._raw_matrix(source, entity_ids) + validation = self._raw_matrix(source, validation_ids) + self._input_dim = int(train.shape[1]) + self._encoder = train_gene_expession_autoencoder(train, validation, self._epochs) + self._latent_dim = int(self._encoder.latent_dim) + return self._latent_dim + + def _compute_matrix(self, source: FeatureSource, matrix: np.ndarray) -> np.ndarray: + """Encode *matrix* into DIPK latent vectors. + + Reached only after the ``requires_fit`` gate, so the encoder exists. + + :param source: Feature source the matrix came from. + :param matrix: Raw gene-expression matrix. + :returns: Float matrix of latent embeddings. + """ + from drevalpy.components.predictors.literature.dipk.gene_expression_encoder import encode_gene_expression + + _ = source + return encode_gene_expression(matrix, self._encoder) + + def _block_feature_names(self, source: FeatureSource) -> None: + """Latent dimensions have no names to inherit from the source view. + + :param source: Feature source (unused). + :returns: Always ``None``. + """ + _ = source + return None + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable autoencoder epoch count. + + :returns: Ray Tune-style hyperparameter space mapping. + """ + return {"epochs_autoencoder": {"type": "int", "low": 1, "high": 500, "default": 100}} + + def get_state(self) -> dict[str, object]: + """Serialize fitted encoder weights and shape metadata. + + :returns: State mapping, or an empty dict before fitting. + """ + if self._encoder is None: + return {} + return { + "encoder_state": save_state_dict(self._encoder.state_dict()), + "input_dim": self._input_dim, + "latent_dim": self._latent_dim, + "epochs": self._epochs, + "epochs_autoencoder": self._epochs, + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore a serialized DIPK encoder from ``get_state``. + + :param state: Mapping previously returned by ``get_state``. + """ + from drevalpy.components.predictors.literature.dipk.gene_expression_encoder import GeneExpressionEncoder + + blob = state.get("encoder_state") + input_dim = state.get("input_dim") + latent_dim = state.get("latent_dim", 512) + if not isinstance(blob, bytes) or not isinstance(input_dim, int) or not isinstance(latent_dim, int): + return + self._input_dim = input_dim + self._latent_dim = latent_dim + self._output_dim = latent_dim + epochs = state.get("epochs", state.get("epochs_autoencoder")) + if isinstance(epochs, int): + self._epochs = epochs + self._encoder = GeneExpressionEncoder(input_dim, latent_dim=latent_dim) + self._encoder.load_state_dict(load_state_dict(blob)) + self._encoder.eval() + self._is_fitted = True diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/__init__.py b/drevalpy/components/featurizers/cell_line/gene_lists/__init__.py new file mode 100644 index 000000000..c5762ca0c --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/__init__.py @@ -0,0 +1,56 @@ +"""Gene-list CSVs shipped with drevalpy, plus helpers to resolve and parse them. + +See the maintenance script ``_make_gene_lists.py`` in this directory for how each CSV +was produced. +""" + +from __future__ import annotations + +from upath import UPath as Path + +__all__ = ["GENE_LISTS_DIR", "gene_names_from_list_csv", "resolve_gene_list_path"] + +_GENE_NAME_COLUMNS = ("Symbol", "gene_name", "symbol", "Gene", "gene") + +# The CSVs live next to this module and are shipped as package data, so featurizers never +# depend on a downloaded ``meta`` bundle or any other external location. +GENE_LISTS_DIR = Path(__file__).resolve().parent + + +def resolve_gene_list_path( + gene_list_stem: str, +) -> Path: + """Resolve ``{stem}.csv`` among the gene lists shipped with the package. + + :param gene_list_stem: Gene-list filename stem without ``.csv``. + :returns: Path to the resolved gene-list CSV. + :raises FileNotFoundError: If no matching gene-list file exists. + """ + path = GENE_LISTS_DIR / f"{gene_list_stem}.csv" + if path.is_file(): + return path + available = ", ".join(sorted(candidate.stem for candidate in GENE_LISTS_DIR.glob("*.csv"))) + msg = f"Gene list {gene_list_stem!r} not found in {GENE_LISTS_DIR}. Available gene lists: {available}" + raise FileNotFoundError(msg) + + +def gene_names_from_list_csv(path: Path | str) -> list[str]: + """Return ordered gene symbols from a gene-list CSV. + + Accepts common column names (``Symbol``, ``gene_name``, …). + + :param path: Path to a gene-list CSV. + :returns: Ordered gene symbol strings. + :raises ValueError: If the CSV has no recognized gene-name column. + """ + import pandas as pd + + gene_info = pd.read_csv(path) + for column in _GENE_NAME_COLUMNS: + if column in gene_info.columns: + return [str(value) for value in gene_info[column].tolist()] + msg = ( + f"Gene list {path} has no recognized gene-name column; " + f"expected one of {list(_GENE_NAME_COLUMNS)}, got {list(gene_info.columns)}" + ) + raise ValueError(msg) diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/_make_gene_lists.py b/drevalpy/components/featurizers/cell_line/gene_lists/_make_gene_lists.py new file mode 100644 index 000000000..64b6a9b41 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/_make_gene_lists.py @@ -0,0 +1,220 @@ +"""Provenance record for the gene-list CSVs shipped next to this module. + +This is a maintenance script, **not** part of the public API. Nothing in the package +imports it: the name starts with an underscore, so neither the built-in component +discovery in :mod:`drevalpy.registry._builtins` nor the recursive API docs pick it up. +It exists so the origin of every shipped CSV stays reproducible. + +It replaces the ``make_gene_lists.ipynb`` notebook that used to live here. That notebook +also intersected the retired v1 toy datasets, which were synthetic subsets of the real +screens and no longer exist, so only registered datasets are used now (see +``drevalpy/data/datasets/available_datasets.json``). + +``CCLE`` was the sole proteomics source, and it is currently **not registered**: the +CurveCurator-refit generation of the screens has no ``CCLE.h5mu`` yet. The shipped +``proteomics``-derived CSVs were generated while it still was, and they are kept as they +are. :data:`OMIC_DATASETS` therefore lists no dataset for ``proteomics``, which makes +re-running this script raise rather than silently write an empty intersection - see +:func:`gene_intersection`. Put ``CCLE`` back in both places once the file is uploaded. + +Inputs +------ +Per-dataset omics tables, as ``//.csv`` with a +``cell_line_name`` index and one column per gene. These raw downloads are not part of the +repository; point ``--data-path`` at a local data directory. + +Three curated lists are read back from this directory rather than derived: +``landmark_genes.csv``, ``drug_target_genes_all_drugs.csv`` and +``gene_list_paccmann_network_prop.csv``. The original notebook did not record where they +came from ("Todo: how did we get here?"), so this script does not claim to reproduce them +either - it only documents that everything else is derived from them. + +Outputs +------- +Written into ``--output-dir`` (this directory by default): + +* ``gene_expression_intersection.csv``, ``mutations_intersection.csv``, + ``methylation_intersection.csv``, ``proteomics_intersection.csv``, + ``copy_number_variation_gistic_intersection.csv`` - genes present in that omic across + every dataset listed in :data:`OMIC_DATASETS`. +* ``landmark_genes_proteomics.csv``, ``drug_target_genes_all_drugs_proteomics.csv``, + ``gene_list_paccmann_network_prop_proteomics.csv`` - each curated list restricted to the + proteomics intersection. +* ``landmark_genes_reduced.csv``, ``drug_target_genes_reduced.csv``, + ``gene_list_paccmann_network_prop_reduced.csv`` - each curated list restricted to the + genes shared by the copy-number, expression, mutation and proteomics intersections. + +Two quirks of the shipped files are kept on purpose, so re-running this script produces +the same layout: the ``*_intersection.csv`` files carry pandas' default integer index +column while the derived lists are written with ``index=False``, and row order follows +Python set iteration order, so it is not stable between runs (the set of symbols is). + +Usage +----- +``python _make_gene_lists.py --data-path /path/to/data`` +""" + +from __future__ import annotations + +import argparse +from collections.abc import Iterable + +import pandas as pd +from upath import UPath + +#: Every registered dataset, in the order the notebook listed them. +ALL_DATASETS: tuple[str, ...] = ("BeatAML2", "CTRPv1", "CTRPv2", "GDSC1", "GDSC2", "PDX_Bruna") + +#: Datasets that carry each omic. Only these are intersected for that omic, because a +#: dataset without the measurement would empty the intersection. +OMIC_DATASETS: dict[str, tuple[str, ...]] = { + "copy_number_variation_gistic": ("CTRPv1", "CTRPv2", "GDSC1", "GDSC2", "PDX_Bruna"), + "gene_expression": ALL_DATASETS, + "methylation": ("CTRPv1", "CTRPv2", "GDSC1", "GDSC2"), + "mutations": ("CTRPv1", "CTRPv2", "GDSC1", "GDSC2"), + # Proteomics was CCLE-only, and CCLE is not registered right now (see the module + # docstring). Left empty on purpose so a re-run fails loudly instead of writing an + # empty intersection over the shipped CSV. + "proteomics": (), +} + +#: Curated input list -> (proteomics-restricted output stem, reduced output stem). The +#: reduced drug-target file drops the ``_all_drugs`` part of its name; that asymmetry is +#: inherited from the shipped files and must be preserved. +DERIVED_STEMS: dict[str, tuple[str, str]] = { + "landmark_genes": ("landmark_genes_proteomics", "landmark_genes_reduced"), + "drug_target_genes_all_drugs": ("drug_target_genes_all_drugs_proteomics", "drug_target_genes_reduced"), + "gene_list_paccmann_network_prop": ( + "gene_list_paccmann_network_prop_proteomics", + "gene_list_paccmann_network_prop_reduced", + ), +} + +#: Omics whose intersections define the "reduced" gene universe. Methylation is excluded: +#: its columns are genomic ranges, not gene symbols. +REDUCED_OMICS: tuple[str, ...] = ("copy_number_variation_gistic", "gene_expression", "mutations", "proteomics") + + +def read_omic_genes(dataset: str, data_path: UPath, omic: str) -> set[str]: + """Return the gene columns of one dataset's omic table. + + :param dataset: Registered dataset name, e.g. ``"GDSC1"``. + :param data_path: Directory holding one subdirectory per dataset. + :param omic: Omic file stem, e.g. ``"gene_expression"``. + :returns: Gene symbols (or, for methylation, genomic ranges) measured in that dataset. + """ + frame = pd.read_csv(data_path / dataset / f"{omic}.csv", index_col="cell_line_name") + return {str(column) for column in frame.columns if column != "cellosaurus_id"} + + +def gene_intersection(datasets: Iterable[str], data_path: UPath, omic: str) -> set[str]: + """Intersect the genes measured for one omic across several datasets. + + :param datasets: Registered dataset names to intersect. + :param data_path: Directory holding one subdirectory per dataset. + :param omic: Omic file stem, e.g. ``"mutations"``. + :returns: Genes present in every given dataset. + :raises ValueError: If no dataset was given. + """ + shared: set[str] | None = None + for dataset in datasets: + print(f"Processing {dataset} ({omic})...") + genes = read_omic_genes(dataset, data_path, omic) + shared = genes if shared is None else shared & genes + if shared is None: + msg = f"No datasets given for omic {omic!r}" + raise ValueError(msg) + return shared + + +def write_symbols(symbols: Iterable[str], path: UPath, *, keep_index: bool) -> None: + """Write gene symbols as a one-column ``Symbol`` CSV. + + :param symbols: Gene symbols to write. + :param path: Destination CSV path. + :param keep_index: Whether to keep pandas' integer index column, as the shipped + ``*_intersection.csv`` files do. + """ + pd.DataFrame({"Symbol": list(symbols)}).to_csv(path, index=keep_index) + + +def read_curated_symbols(stem: str, gene_lists_dir: UPath) -> set[str]: + """Read the ``Symbol`` column of a curated gene list checked into this directory. + + :param stem: Filename stem without ``.csv``. + :param gene_lists_dir: Directory holding the curated lists. + :returns: Curated gene symbols. + """ + return {str(symbol) for symbol in pd.read_csv(gene_lists_dir / f"{stem}.csv")["Symbol"]} + + +def build_intersections(data_path: UPath, output_dir: UPath) -> dict[str, set[str]]: + """Write one ``_intersection.csv`` per omic and return the gene sets. + + :param data_path: Directory holding one subdirectory per dataset. + :param output_dir: Directory the CSVs are written to. + :returns: Mapping of omic name to the intersected gene set. + """ + intersections: dict[str, set[str]] = {} + for omic, datasets in OMIC_DATASETS.items(): + genes = gene_intersection(datasets, data_path, omic) + intersections[omic] = genes + write_symbols(genes, output_dir / f"{omic}_intersection.csv", keep_index=True) + print(f"{omic}: {len(genes)} genes shared by {', '.join(datasets)}") + return intersections + + +def build_derived_lists(intersections: dict[str, set[str]], gene_lists_dir: UPath, output_dir: UPath) -> None: + """Restrict each curated list to the proteomics and reduced gene universes. + + :param intersections: Per-omic gene sets from :func:`build_intersections`. + :param gene_lists_dir: Directory holding the curated input lists. + :param output_dir: Directory the derived CSVs are written to. + """ + proteomics = intersections["proteomics"] + reduced_universe = set.intersection(*(intersections[omic] for omic in REDUCED_OMICS)) + print(f"reduced universe: {len(reduced_universe)} genes shared by {', '.join(REDUCED_OMICS)}") + + for stem, (proteomics_stem, reduced_stem) in DERIVED_STEMS.items(): + curated = read_curated_symbols(stem, gene_lists_dir) + write_symbols(curated & proteomics, output_dir / f"{proteomics_stem}.csv", keep_index=False) + write_symbols(curated & reduced_universe, output_dir / f"{reduced_stem}.csv", keep_index=False) + print(f"{stem}: {len(curated & proteomics)} proteomics, {len(curated & reduced_universe)} reduced") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments. + + :param argv: Argument list, defaulting to ``sys.argv[1:]``. + :returns: Parsed arguments with ``data_path`` and ``output_dir``. + """ + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--data-path", + default="data", + help="Directory holding one subdirectory of raw omics CSVs per dataset (default: %(default)s).", + ) + parser.add_argument( + "--output-dir", + default=None, + help="Where to write the gene lists (default: the directory of this script).", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: + """Regenerate every gene list shipped in this directory. + + :param argv: Argument list, defaulting to ``sys.argv[1:]``. + """ + args = parse_args(argv) + gene_lists_dir = UPath(__file__).resolve().parent + output_dir = UPath(args.output_dir) if args.output_dir else gene_lists_dir + data_path = UPath(args.data_path) + + intersections = build_intersections(data_path, output_dir) + build_derived_lists(intersections, gene_lists_dir, output_dir) + + +if __name__ == "__main__": + main() diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/copy_number_variation_gistic_intersection.csv b/drevalpy/components/featurizers/cell_line/gene_lists/copy_number_variation_gistic_intersection.csv new file mode 100644 index 000000000..b3698b3ba --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/copy_number_variation_gistic_intersection.csv @@ -0,0 +1,2736 @@ +,Symbol +0,RRM1 +1,PLEKHJ1 +2,AASDH +3,FKBP4 +4,KDM5A +5,HSPA2 +6,SKIC8 +7,ASB8 +8,BID +9,TPRA1 +10,CNPY3 +11,PSMG1 +12,CLEC1A +13,ESYT2 +14,MFSD11 +15,POLR2G +16,XKR6 +17,ALDOB +18,HRG +19,GPX6 +20,NDUFA2 +21,TUBA1C +22,MAPK9 +23,KCNK1 +24,KRTAP1-5 +25,EGR1 +26,KLHL9 +27,USP38 +28,PRKAG2 +29,SHD +30,PIP4K2B +31,TSGA10 +32,RTF1 +33,PDX1 +34,RAB19 +35,ZNF276 +36,PLEKHA6 +37,NFATC2 +38,CCDC136 +39,SRC +40,ANKRD28 +41,ADRB2 +42,EIF5 +43,SETX +44,IQSEC3 +45,DMBT1 +46,CDC42BPB +47,SRA1 +48,RAB31 +49,MLLT11 +50,GTF2B +51,FAM219A +52,MVP +53,PSG4 +54,ZNF600 +55,EYS +56,ARNT2 +57,FGFBP1 +58,STX1A +59,PREB +60,LRIG3 +61,SNAPC4 +62,RAB21 +63,CDIP1 +64,BAMBI +65,LGALS8 +66,TES +67,SNAPIN +68,BAX +69,WFDC10B +70,GLRX5 +71,SCGB1D2 +72,NT5DC2 +73,MRPS2 +74,AURKAIP1 +75,ANXA7 +76,CCNE1 +77,OR4K13 +78,FAS +79,LRRC41 +80,VAV3 +81,STAMBP +82,PAK2 +83,TMCC3 +84,MRTFA +85,MTF2 +86,CCND1 +87,SART1 +88,ARPP19 +89,EXTL2 +90,DYRK3 +91,PIK3CD +92,ZEB1 +93,SENP6 +94,CSNK2A2 +95,SETD1A +96,HUNK +97,USP32 +98,NR1H2 +99,KNDC1 +100,SLC16A14 +101,PIK3CB +102,SCARB1 +103,B4GALT1 +104,MARK4 +105,RPA3 +106,MYCBP +107,LRP12 +108,KIF9 +109,KRT3 +110,TATDN2 +111,ZNF862 +112,IER3 +113,PYCR1 +114,NDUFAB1 +115,PIK3R5 +116,PTN +117,SMO +118,ANAPC10 +119,HEBP1 +120,CCL2 +121,ZNF586 +122,KLF6 +123,RNF11 +124,OVOL1 +125,DPP8 +126,TSPAN3 +127,MNX1 +128,SPTLC2 +129,NPEPL1 +130,CHUK +131,NTRK3 +132,CACNA1E +133,TBC1D9B +134,MCOLN1 +135,PSMD4 +136,STAR +137,PYGL +138,KNTC1 +139,ATP7B +140,SLC25A18 +141,FGFR2 +142,PIGB +143,CIRBP +144,CDR2L +145,NR2C2AP +146,SLC25A4 +147,FCGR2A +148,UBE2L6 +149,SPRYD7 +150,TDRD7 +151,RNF167 +152,QRICH1 +153,COPZ1 +154,TIMM22 +155,NRAS +156,PMP2 +157,CLN5 +158,MAN2C1 +159,TGFBR1 +160,PPP2R3C +161,PPIC +162,SLFN13 +163,SPR +164,KIF21A +165,IGFL3 +166,BCL7B +167,RAF1 +168,PDE10A +169,TNKS2 +170,SKP1 +171,PRKACA +172,IL12B +173,CES4A +174,MAPK1 +175,PPT1 +176,ARHGEF40 +177,ZDHHC6 +178,BPIFB1 +179,CAPZB +180,TMEM170B +181,MALT1 +182,SPATA31D1 +183,TNFRSF13B +184,RAB4A +185,TPGS2 +186,TBXA2R +187,RAD51D +188,MAP3K4 +189,ZNF202 +190,ATP6V1B2 +191,IL20RA +192,ATG12 +193,VCL +194,CD63 +195,MRPS26 +196,MBTPS1 +197,PEX13 +198,HES1 +199,NSMCE1 +200,CCL21 +201,INTS3 +202,MBNL2 +203,AEBP1 +204,CYP2W1 +205,ARIH2 +206,DENND2D +207,CRLS1 +208,RRP9 +209,VPS35 +210,BAD +211,EGFR +212,ADI1 +213,DESI1 +214,SUPT20H +215,AXL +216,CLK4 +217,GUCA2B +218,ZNF451 +219,IARS1 +220,UBE3B +221,PTPRK +222,KDM3A +223,PCBP2 +224,MAPKAPK2 +225,KRTAP22-1 +226,CDKN2A +227,PIK3CA +228,DCTN2 +229,S100A4 +230,TGFB1I1 +231,ANK1 +232,RUVBL1 +233,GNG2 +234,NR1H4 +235,ZNF395 +236,PTPN1 +237,TXNL4B +238,RNF152 +239,YTHDF1 +240,CHEK2 +241,AGPAT2 +242,WEE1 +243,COLEC12 +244,DYNC2LI1 +245,SCD +246,ALDH7A1 +247,PCDHB12 +248,MBNL1 +249,CCNF +250,ATRN +251,C2CD5 +252,OR1G1 +253,INSIG1 +254,HEATR6 +255,TRUB1 +256,ZNF318 +257,CCNA2 +258,BRIP1 +259,BRDT +260,SLC38A7 +261,MEF2C +262,IGF2R +263,EZH2 +264,POLE2 +265,SQSTM1 +266,TMC3 +267,TTC32 +268,FGF18 +269,OR2A12 +270,NDUFB1 +271,ZDHHC19 +272,BLVRA +273,BRAF +274,BPIFA1 +275,CCDC146 +276,NIPSNAP1 +277,YPEL2 +278,POLR1C +279,CLEC16A +280,NCAM1 +281,STARD4 +282,BRF2 +283,CISD1 +284,CCDC112 +285,AMIGO1 +286,ADH5 +287,MFAP2 +288,SPG7 +289,TCEA2 +290,CDKN2AIP +291,YKT6 +292,RPS6KB1 +293,NFATC4 +294,KCNA4 +295,TGIF1 +296,B9D1 +297,GSTP1 +298,CDH1 +299,ORC1 +300,AIM2 +301,UPP2 +302,OR8B8 +303,TP53 +304,BIRC5 +305,STEAP2 +306,KEAP1 +307,MAP4K1 +308,HEATR4 +309,SCYL3 +310,ERBB3 +311,EML3 +312,CORIN +313,ABHD6 +314,GPATCH8 +315,MAP2 +316,PRKCB +317,PLA2R1 +318,SEMA4A +319,TONSL +320,CSRP1 +321,KANK1 +322,CENPE +323,PROM2 +324,WBP2NL +325,HDAC5 +326,AHDC1 +327,STX3 +328,IFI27L2 +329,PDS5A +330,CPNE3 +331,TFDP1 +332,CDK13 +333,FNBP1 +334,DNAI2 +335,ENO2 +336,VWA5B1 +337,PAFAH1B1 +338,NUDT5 +339,STEAP3 +340,USP15 +341,PAICS +342,CD82 +343,IFT122 +344,LAMP3 +345,TYW1 +346,B3GALT1 +347,UBE3C +348,CYP3A5 +349,DIRAS1 +350,AP2A1 +351,ZNF556 +352,SMAD4 +353,PPIA +354,TOP2A +355,TMEM203 +356,SWSAP1 +357,IQUB +358,TMEM132B +359,HDAC7 +360,NDUFA10 +361,ALAS1 +362,OR5D14 +363,WIF1 +364,FAM210B +365,MYO18A +366,ECD +367,JMJD4 +368,NCOR2 +369,RPH3A +370,ANKRD34B +371,CSNK1A1 +372,TMBIM6 +373,RAPSN +374,PKIB +375,WDR75 +376,MSH2 +377,PLA2G15 +378,DDIT4L +379,TRADD +380,GUCA1C +381,RASA1 +382,ABCC8 +383,AURKC +384,GDF5 +385,DOCK4 +386,PSRC1 +387,MED13 +388,MYL6B +389,DNM1 +390,GTDC1 +391,HSD17B14 +392,BCAR3 +393,CETN3 +394,PDP1 +395,FAM76B +396,GATA6 +397,MRPL46 +398,TRPC4AP +399,HMG20B +400,LCK +401,AKAP12 +402,AMDHD2 +403,PCK2 +404,MAP2K5 +405,TAS2R42 +406,PNMA2 +407,NR4A3 +408,HGF +409,MAPK3 +410,PSTPIP2 +411,SDR9C7 +412,CDC123 +413,ZBTB44 +414,ATP6V1D +415,MDH1B +416,LGALSL +417,MRGPRD +418,ERBB4 +419,NR2F6 +420,ATF1 +421,SLC12A6 +422,NCSTN +423,BNC2 +424,TRIM37 +425,EXOSC4 +426,RING1 +427,GCG +428,ISOC1 +429,PTK2 +430,MAFK +431,INSL5 +432,HMGCR +433,PRMT7 +434,SNX11 +435,PITPNA +436,C1QA +437,MCM9 +438,SHROOM3 +439,FRMD1 +440,ITPKB +441,PRDM5 +442,CASP7 +443,OSGIN2 +444,ACAT2 +445,TIPARP +446,DUSP6 +447,IL6ST +448,ADCY10 +449,PEAR1 +450,RNF181 +451,IL17F +452,ENO3 +453,NNT +454,EPAS1 +455,ZC3HAV1 +456,MSH6 +457,CTCFL +458,KCNJ3 +459,COX7A2L +460,WDR47 +461,HDAC4 +462,FRS2 +463,HMX1 +464,CDK7 +465,SKIC2 +466,FIGN +467,WDR5 +468,FBXO8 +469,ATAD5 +470,DNAJB2 +471,HSPA4 +472,SIVA1 +473,CSRNP1 +474,RET +475,PROX2 +476,MCMBP +477,OR2C3 +478,IKBKB +479,TMEM64 +480,ZCCHC17 +481,PKP4 +482,RBKS +483,KRT32 +484,FZD1 +485,YBX2 +486,DEFB116 +487,HAPLN4 +488,SLC25A13 +489,HESX1 +490,BZW2 +491,ARHGEF19 +492,PTTG1IP +493,FMO2 +494,RNASE2 +495,IL9 +496,SGK2 +497,TMEM53 +498,PKIG +499,ACAA1 +500,MTMR9 +501,HYAL2 +502,GGPS1 +503,PNP +504,ANKRD49 +505,RPN2 +506,HIPK4 +507,PDE8A +508,LEP +509,PPCDC +510,CIB3 +511,PPIL1 +512,STXBP1 +513,ABL1 +514,NFATC3 +515,ZRANB1 +516,SUGP2 +517,SACS +518,PRDM10 +519,SLAMF1 +520,EXOC3 +521,MTHFD2 +522,RNASE3 +523,TRIM45 +524,IRX3 +525,MYL5 +526,AKAP8 +527,CDK19 +528,PAK4 +529,MCHR1 +530,OR51I2 +531,OTUD4 +532,CANT1 +533,KLHL36 +534,RFC5 +535,ARRB1 +536,UNC13D +537,CRKL +538,SLC25A20 +539,H2BC12 +540,S100A1 +541,ZNF7 +542,USP4 +543,ARFGAP1 +544,DECR2 +545,USP6 +546,PLS1 +547,BMP2 +548,CHIC2 +549,SURF6 +550,VNN3P +551,NUP85 +552,IFNA8 +553,KLK12 +554,SVOP +555,AARS2 +556,WFS1 +557,TACO1 +558,TXNDC9 +559,DFFA +560,ERBB2 +561,CATSPERD +562,BFAR +563,ZNF195 +564,SMARCB1 +565,DDX6 +566,GALC +567,SRSF12 +568,DSG1 +569,RAD51AP2 +570,DDR1 +571,MDM4 +572,APOE +573,RBP7 +574,NTN4 +575,PSG9 +576,TOR1AIP1 +577,LRP10 +578,SLC25A46 +579,STAT5B +580,STOML3 +581,IARS2 +582,TMEM33 +583,PYCARD +584,MED23 +585,SLC35F3 +586,ZNF71 +587,LRPAP1 +588,PDZD7 +589,B3GNT9 +590,F13A1 +591,DAXX +592,HERC6 +593,FAP +594,PHYHIP +595,ABCA8 +596,PAPSS2 +597,SPIC +598,EHMT2 +599,ATP6V1E2 +600,PDE9A +601,TTC8 +602,CREG1 +603,AURKA +604,SRGAP1 +605,CEP72 +606,TMEM18 +607,DBR1 +608,HSPA5 +609,ATF7 +610,FABP1 +611,EIF1B +612,ICA1L +613,DERA +614,IGFBP3 +615,REPS1 +616,HLA-DRA +617,PPP2R5A +618,EGLN1 +619,GNPDA1 +620,GPRIN1 +621,NXPH1 +622,RBL2 +623,KANSL1 +624,ODAD2 +625,QPCTL +626,DHRS7 +627,MON2 +628,SLC16A4 +629,MUC12 +630,GREM1 +631,RAC3 +632,NPVF +633,SLC2A6 +634,KDM2A +635,LRP4 +636,TRIQK +637,MAST3 +638,GP6 +639,CD8A +640,CLPX +641,HSPA1A +642,ACBD3 +643,UGDH +644,FEZ2 +645,TAP1 +646,APRT +647,HADH +648,KIF17 +649,TUBB6 +650,LYN +651,SLC5A6 +652,DHX29 +653,TSPAN4 +654,DCUN1D2 +655,TM9SF3 +656,ARID3C +657,SLC7A1 +658,BLNK +659,UGT2B15 +660,CALCA +661,SLC37A4 +662,OR2F2 +663,AK5 +664,PROK1 +665,BBS2 +666,ATF2 +667,IDH1 +668,DNAH14 +669,RPS5 +670,TMEM259 +671,MS4A12 +672,PACS1 +673,EIF2B5 +674,EFHC1 +675,ZNF704 +676,PKN1 +677,KRTAP11-1 +678,BRD1 +679,PROS1 +680,NET1 +681,DYNLT1 +682,SLC4A11 +683,STARD3 +684,VPS72 +685,FLNB +686,TMCC2 +687,ERCC5 +688,CKAP4 +689,PHKG2 +690,KRTAP20-4 +691,TLE1 +692,TCFL5 +693,KLHL3 +694,ACTB +695,COG5 +696,PDGFRA +697,UBASH3B +698,SNX21 +699,PKM +700,SPAG16 +701,ZIK1 +702,PXMP4 +703,PARVB +704,TRIM7 +705,NDN +706,IDE +707,NNAT +708,NUP93 +709,LIMD2 +710,KCTD5 +711,SET +712,PTGS2 +713,GRB10 +714,KLHDC2 +715,CTNND1 +716,DECR1 +717,SLBP +718,GNG8 +719,DDX60 +720,RASSF4 +721,TOR4A +722,FBXO21 +723,EXT1 +724,UFM1 +725,CCNA1 +726,NBL1 +727,ZBTB14 +728,PCBD1 +729,DNAJB1 +730,PEX6 +731,DNTTIP2 +732,OPTC +733,DEK +734,WDR24 +735,POLR3A +736,MAN2A1 +737,MLPH +738,CIAO3 +739,ELOVL6 +740,ITGB1BP1 +741,RNF122 +742,CATSPERB +743,OXCT1 +744,PRTN3 +745,TTC33 +746,GRWD1 +747,OSR1 +748,ST3GAL5 +749,RUNDC3B +750,MAP2K1 +751,DNAJC15 +752,MTFR1 +753,PRCP +754,TRPM5 +755,HMOX1 +756,LRRK2 +757,PDZD9 +758,RPL30 +759,DEPDC4 +760,PRM2 +761,PPM1M +762,CSF1R +763,ACTR6 +764,BRD4 +765,MAPKAPK3 +766,LARP4 +767,KIF25 +768,HAO2 +769,IGDCC3 +770,CLEC10A +771,NPDC1 +772,PGM2 +773,BUB3 +774,SLC27A5 +775,RGS5 +776,CD164L2 +777,ZDHHC5 +778,NPC1L1 +779,CYP26B1 +780,MSRB3 +781,PPIE +782,ADO +783,GPSM1 +784,SH2B1 +785,VARS2 +786,CTTN +787,P4HTM +788,BIRC3 +789,ZNF76 +790,CHKA +791,KCNQ4 +792,NDUFA13 +793,ATP6V1G1 +794,BNIP3L +795,RAB6A +796,PLSCR1 +797,RAD51C +798,HGD +799,PTER +800,SOX4 +801,ULK2 +802,CGRRF1 +803,CDK4 +804,PHF1 +805,PCOLCE2 +806,SLC25A45 +807,LYSMD2 +808,CCL20 +809,AGL +810,GNB5 +811,MDM2 +812,GLIPR2 +813,C2CD2L +814,AGAP3 +815,RSU1 +816,NR2F2 +817,HNRNPU +818,G3BP1 +819,EPB41L2 +820,VWC2 +821,SPAG4 +822,DNAJB5 +823,PARN +824,RFX5 +825,NAMPT +826,C1RL +827,ZNF253 +828,COG4 +829,GPC1 +830,MEGF8 +831,JAKMIP2 +832,ECH1 +833,CCDC127 +834,GPR160 +835,HRNR +836,SNX22 +837,RIT1 +838,NFKBID +839,ZFAND1 +840,CPNE7 +841,FBXO11 +842,MYCBP2 +843,FGF11 +844,SYPL1 +845,TBP +846,KLHL24 +847,ALDH3B2 +848,THUMPD3 +849,TEC +850,GPR146 +851,ITPRID2 +852,CTNNA3 +853,CIAPIN1 +854,SH3BP5 +855,ETS1 +856,MAP3K7 +857,CBLB +858,IFNB1 +859,DOK2 +860,LPAR2 +861,ZNF772 +862,ABCA5 +863,CEP97 +864,USP7 +865,FHIT +866,PARP1 +867,TAAR8 +868,MOSPD3 +869,SLC25A38 +870,SCUBE1 +871,HDAC1 +872,ZNF510 +873,FAM20B +874,SLC39A10 +875,STRA6 +876,PIK3C3 +877,PARP6 +878,CSE1L +879,ECHS1 +880,CHST11 +881,LYPD3 +882,CCDC92 +883,YBEY +884,CLCN2 +885,CAMK2B +886,TCIRG1 +887,BMP4 +888,CCDC90B +889,F13B +890,TFAP2A +891,PTK2B +892,DHX16 +893,NCAPD2 +894,SNRPA +895,USP30 +896,ARHGEF15 +897,MAPK11 +898,KLHL11 +899,TNFRSF9 +900,LPIN2 +901,ESR1 +902,OR6N2 +903,XAB2 +904,CALU +905,CLDN4 +906,NPRL2 +907,SIX4 +908,TLK2 +909,RUVBL2 +910,ALG8 +911,STAT3 +912,SRPK1 +913,EZH1 +914,ALK +915,GPR183 +916,KRT85 +917,PTPRM +918,LAT2 +919,TMED4 +920,PTMS +921,ARHGAP28 +922,PRKAA2 +923,PDE1C +924,SNX2 +925,PTPN11 +926,PSMA8 +927,GIGYF1 +928,SH3BGRL2 +929,TRAK2 +930,POLG2 +931,ZNF543 +932,DYRK1B +933,P2RY6 +934,APMAP +935,GIPC2 +936,GPR152 +937,BST2 +938,ESR2 +939,SLC35F2 +940,BTRC +941,MSRA +942,ULK1 +943,TOPAZ1 +944,LPL +945,TMEM86A +946,ANGPT1 +947,RPUSD3 +948,SPHK1 +949,ZNF527 +950,ILDR1 +951,PMAIP1 +952,SLC49A4 +953,NMUR2 +954,ZFHX2 +955,TUBA1A +956,SCRN1 +957,FGL1 +958,RAD9A +959,RXFP3 +960,CDH15 +961,GALNT13 +962,MXD4 +963,ELOVL4 +964,IDH2 +965,CAPN9 +966,INO80D +967,FANK1 +968,SLC35A3 +969,ZNF468 +970,C1QTNF8 +971,TNRC6C +972,FAIM +973,CPXM2 +974,LOXL1 +975,TNFSF10 +976,CCT5 +977,SERPINE1 +978,AFMID +979,TMEM97 +980,UQCC5 +981,EPHA7 +982,NEU4 +983,C2CD2 +984,CELF1 +985,CRB1 +986,GCC1 +987,HIF1AN +988,DCLK1 +989,ARHGAP44 +990,ZNF107 +991,FFAR2 +992,TRA2B +993,KIF5C +994,MUC5B +995,LRRC58 +996,CALR +997,ZNF333 +998,MYLK +999,YWHAQ +1000,STX4 +1001,MTA1 +1002,KPNA7 +1003,GRB7 +1004,ACSM4 +1005,ARF4 +1006,CASP3 +1007,LMO4 +1008,MYL9 +1009,TXNRD1 +1010,BCL2 +1011,LRP2 +1012,SCP2 +1013,LGR5 +1014,COPB2 +1015,DNAJB6 +1016,DNM1L +1017,CKAP2 +1018,FLT4 +1019,FAM186A +1020,PLEKHM3 +1021,TRIM2 +1022,SIRPB2 +1023,TAFA2 +1024,SOWAHA +1025,BAAT +1026,TFB2M +1027,RPP25L +1028,PHF14 +1029,RTN2 +1030,NUP107 +1031,TPPP +1032,TP53RK +1033,EMC10 +1034,SUPT16H +1035,UBTF +1036,ROS1 +1037,TMEM50A +1038,UBQLN1 +1039,TRAPPC3 +1040,TRIM55 +1041,SUGT1 +1042,ABCF1 +1043,GLI2 +1044,RSPO2 +1045,PLK1 +1046,PIK3CG +1047,WDTC1 +1048,DLEU7 +1049,RNF149 +1050,MAPK10 +1051,TOR3A +1052,UGGT2 +1053,PHRF1 +1054,HMGA2 +1055,CTNNAL1 +1056,BRD2 +1057,KRT2 +1058,OR10J5 +1059,WDR74 +1060,TBK1 +1061,ECHDC2 +1062,ZFP1 +1063,FAM120AOS +1064,FAM114A1 +1065,TTLL2 +1066,NFE2L2 +1067,ZNF626 +1068,NPLOC4 +1069,MOAP1 +1070,SYNJ2 +1071,GLYR1 +1072,DNAH3 +1073,IRAK4 +1074,TBCEL +1075,ELF2 +1076,NLE1 +1077,SMAGP +1078,ROCK2 +1079,GDPD5 +1080,HEG1 +1081,PARVA +1082,VPS37A +1083,RPS6 +1084,SLC2A9 +1085,KDM4B +1086,EED +1087,PSMB8 +1088,NENF +1089,HOXA7 +1090,SNRNP25 +1091,KLK7 +1092,COL15A1 +1093,NCKAP5 +1094,KAT6A +1095,LSG1 +1096,HSP90AA1 +1097,BDH1 +1098,ZNF619 +1099,TNFRSF21 +1100,SMOX +1101,CSTB +1102,CCR4 +1103,BCL9L +1104,DLD +1105,MAST2 +1106,IQCA1 +1107,MAN2B1 +1108,ANGPTL6 +1109,ASAH1 +1110,DLST +1111,NOL3 +1112,CYB5R4 +1113,MOK +1114,EBF1 +1115,SUZ12 +1116,EFCAB12 +1117,TEX264 +1118,RNF112 +1119,TEAD3 +1120,LRRC32 +1121,SPP1 +1122,CNOT4 +1123,XPNPEP1 +1124,RHBDL2 +1125,TTC17 +1126,DNMT1 +1127,PRDX6 +1128,IL37 +1129,SYK +1130,ARL11 +1131,PRR16 +1132,CCSER1 +1133,KIF14 +1134,GABRR1 +1135,CST1 +1136,SCYL2 +1137,CSF3 +1138,NDUFAF5 +1139,RABL3 +1140,KCNRG +1141,TBX4 +1142,SSBP2 +1143,ELK4 +1144,COL4A1 +1145,MKNK1 +1146,ADRB3 +1147,UNCX +1148,TRAF2 +1149,SNAP25 +1150,KRT72 +1151,CCNE2 +1152,PDIA5 +1153,SCCPDH +1154,GAPDH +1155,UNC45B +1156,ME2 +1157,ISL2 +1158,CYP1B1 +1159,NPM1 +1160,CYTH1 +1161,LSP1 +1162,TRAM2 +1163,FHL2 +1164,PSKH2 +1165,FEM1B +1166,EHMT1 +1167,WARS1 +1168,PFKL +1169,NAB2 +1170,IKZF1 +1171,KRTAP13-3 +1172,SLC35B1 +1173,TSC22D1 +1174,OSTN +1175,IL1B +1176,DPH2 +1177,PCGF3 +1178,PECR +1179,ZNF781 +1180,TERT +1181,SMC3 +1182,ST6GALNAC2 +1183,PMM2 +1184,LCE1E +1185,KLK1 +1186,FOSL1 +1187,RHD +1188,DNAJC2 +1189,DYDC2 +1190,PUS1 +1191,PDCD6 +1192,IGF1 +1193,MYL6 +1194,MRPL19 +1195,TWSG1 +1196,HOXC13 +1197,DCK +1198,DUSP4 +1199,CTRC +1200,ENPEP +1201,KLHL23 +1202,KCNE4 +1203,TICAM1 +1204,RB1 +1205,BCR +1206,TMEM109 +1207,PCMT1 +1208,PNLIP +1209,ZNF30 +1210,TOMM34 +1211,DCST2 +1212,FGFR1OP2 +1213,DTX2 +1214,NRIP1 +1215,S100A8 +1216,ZBTB7B +1217,ECSIT +1218,OR4C15 +1219,TESK1 +1220,NALCN +1221,MAT2B +1222,P4HA2 +1223,ARHGEF12 +1224,ZNF77 +1225,SESN1 +1226,MRTO4 +1227,CSNK1E +1228,TMED10 +1229,LETMD1 +1230,TBPL1 +1231,HOXA13 +1232,RXRB +1233,GNAI1 +1234,MYADM +1235,LPGAT1 +1236,ICMT +1237,RFC2 +1238,NAALAD2 +1239,BARX1 +1240,ATF5 +1241,ASTN1 +1242,GCNT1 +1243,ATP11B +1244,ZSCAN22 +1245,APBB2 +1246,RAE1 +1247,DDX49 +1248,INPP1 +1249,RPA1 +1250,SRP54 +1251,GAL3ST1 +1252,NTMT1 +1253,RIMS3 +1254,HMGCS1 +1255,MANEA +1256,ATP6V1C1 +1257,MYO18B +1258,DDC +1259,ZNF697 +1260,MC4R +1261,ATM +1262,HCN1 +1263,NSMCE2 +1264,COMMD1 +1265,MYO10 +1266,HOMER1 +1267,CASP10 +1268,NPW +1269,PDPK1 +1270,ASCC2 +1271,EPHA3 +1272,FBXO16 +1273,FGFR3 +1274,GRIK3 +1275,SLTM +1276,GRN +1277,ASNS +1278,TP53BP2 +1279,RNF170 +1280,IGF1R +1281,SLC35A1 +1282,F2 +1283,CHEK1 +1284,RHEB +1285,GSK3A +1286,BRCA1 +1287,STAT1 +1288,PPM1L +1289,ST7L +1290,CD44 +1291,ORAI2 +1292,HAVCR1 +1293,SASS6 +1294,CRELD2 +1295,CLTC +1296,IL24 +1297,ZBTB22 +1298,TUBB +1299,FGF22 +1300,DMXL1 +1301,STARD7 +1302,PPA2 +1303,RBM18 +1304,IMPA1 +1305,SLC30A10 +1306,MAPK13 +1307,ALDOA +1308,PIKFYVE +1309,PIH1D1 +1310,PRKCG +1311,ICAM1 +1312,CCDC85B +1313,USP47 +1314,MAST4 +1315,IRX5 +1316,DEFB134 +1317,MRPL22 +1318,CSK +1319,MLXIP +1320,APCS +1321,CALCB +1322,HSP90AB1 +1323,ATR +1324,DYRK1A +1325,SUGP1 +1326,RALB +1327,CTTNBP2 +1328,SLC5A9 +1329,GCFC2 +1330,SPRED2 +1331,EFNA5 +1332,OLIG1 +1333,GLS +1334,PSPH +1335,TIAM1 +1336,PER1 +1337,CEBPE +1338,CDK9 +1339,MZT2A +1340,TPH2 +1341,PDE5A +1342,ACAP1 +1343,MROH1 +1344,TGFB3 +1345,GPAM +1346,TEK +1347,RBM15B +1348,ENDOV +1349,SRP68 +1350,TFPI2 +1351,EIF2A +1352,PNKP +1353,PLEKHG1 +1354,GSK3B +1355,CTSB +1356,PAX8 +1357,SPATA12 +1358,ATN1 +1359,LRRC71 +1360,APPBP2 +1361,JAK2 +1362,NXPH2 +1363,NIPA1 +1364,CSDC2 +1365,KCNG2 +1366,TMEM229B +1367,RHBDD3 +1368,FLACC1 +1369,SLC32A1 +1370,CPOX +1371,MAPK8 +1372,BHLHE23 +1373,VAPB +1374,RHOB +1375,USP1 +1376,RARS2 +1377,CHN1 +1378,EDN1 +1379,PDHX +1380,FKBP1B +1381,GTF2E2 +1382,NBEA +1383,PXN +1384,TMEM151A +1385,TIE1 +1386,ANKRD30A +1387,TP53BP1 +1388,KATNIP +1389,LSR +1390,TFF1 +1391,GTF2A2 +1392,SLC6A18 +1393,MTHFD1L +1394,TUBB1 +1395,YY1AP1 +1396,SPDEF +1397,TBX18 +1398,BRSK2 +1399,SOCS1 +1400,CAST +1401,SLC25A26 +1402,PLOD3 +1403,FAM174A +1404,MCL1 +1405,ADAM15 +1406,SCAND1 +1407,CPLX1 +1408,FBXO7 +1409,TMEM50B +1410,ZKSCAN2 +1411,TGFBR2 +1412,KCNT1 +1413,MYT1L +1414,TPPP2 +1415,SENP1 +1416,ITGAE +1417,MRGBP +1418,CASP1 +1419,PPM1D +1420,SLC10A7 +1421,PCM1 +1422,CFLAR +1423,NOL7 +1424,PPARD +1425,ITPK1 +1426,CLUL1 +1427,RGS16 +1428,PAK6 +1429,HDLBP +1430,SEZ6L +1431,AKT2 +1432,BHLHE40 +1433,ATP10B +1434,RDH8 +1435,CSAD +1436,RILPL1 +1437,GATA3 +1438,KRTAP27-1 +1439,EBNA1BP2 +1440,ZCWPW1 +1441,CNOT10 +1442,OR8B12 +1443,ZNF280D +1444,ATG9A +1445,IRGQ +1446,KIF20A +1447,PEG10 +1448,RNF208 +1449,PPP2R5E +1450,ATP1B1 +1451,FGFR4 +1452,ZHX2 +1453,OLFML3 +1454,ACVR1B +1455,PCYOX1L +1456,TRDMT1 +1457,GNG5 +1458,IFRD2 +1459,MED10 +1460,UBL3 +1461,SIRT1 +1462,KCNK16 +1463,DIPK1A +1464,POLB +1465,SLC27A4 +1466,THRB +1467,NUDCD3 +1468,SGTB +1469,PLK2 +1470,PTH2 +1471,ZNF189 +1472,RHBDF1 +1473,DNAJC22 +1474,XBP1 +1475,DDX25 +1476,MAP7 +1477,CBR3 +1478,ZNF329 +1479,SMAD3 +1480,COX5B +1481,PEX11A +1482,LPXN +1483,CNDP2 +1484,NPFFR2 +1485,FOXO3 +1486,SAR1A +1487,ARHGEF4 +1488,A2M +1489,MFSD10 +1490,FBXL3 +1491,GADD45A +1492,RAB40B +1493,RAB27A +1494,PTEN +1495,CAMSAP2 +1496,KCNT2 +1497,DMXL2 +1498,RBP2 +1499,SHC2 +1500,PDE3B +1501,RAB7A +1502,SNX20 +1503,PAK1 +1504,CAMP +1505,RPL39L +1506,TIMP2 +1507,AKT1S1 +1508,GTPBP1 +1509,WBP11 +1510,RNF25 +1511,ENOSF1 +1512,TMEM38B +1513,UBE2L3 +1514,MS4A6E +1515,ZNF131 +1516,PIK3C2B +1517,NEUROD2 +1518,MGAT1 +1519,CAPN10 +1520,IGFBP5 +1521,MROH9 +1522,CRIP3 +1523,CDC45 +1524,TARBP1 +1525,SLC24A3 +1526,TPD52L2 +1527,NACC1 +1528,FBXO41 +1529,MRPL18 +1530,TMEM8B +1531,CASR +1532,RNPS1 +1533,NDUFB2 +1534,NCK2 +1535,THOP1 +1536,RALY +1537,XRCC3 +1538,NCOA2 +1539,VAT1 +1540,CLYBL +1541,PRR15L +1542,EME1 +1543,RTN4IP1 +1544,EVL +1545,APLP2 +1546,GNAI2 +1547,FBXO33 +1548,CDKN1B +1549,STAP2 +1550,GFPT1 +1551,CELF4 +1552,ACTR3B +1553,UQCRH +1554,TM9SF2 +1555,TMEM255B +1556,NELL2 +1557,ANKRD44 +1558,ITFG1 +1559,EEF2K +1560,RPA2 +1561,CORO1A +1562,KIF2C +1563,CEP152 +1564,NOLC1 +1565,GMNN +1566,UFC1 +1567,SEMA6C +1568,PIK3R4 +1569,SLC39A8 +1570,MMP2 +1571,S100A7L2 +1572,FSCN3 +1573,GARRE1 +1574,CEBPD +1575,DUSP11 +1576,SH3D21 +1577,NPBWR2 +1578,HOOK2 +1579,CLSTN1 +1580,PHOSPHO1 +1581,LRRTM1 +1582,CRK +1583,NTHL1 +1584,GEMIN5 +1585,KHDC3L +1586,SESN2 +1587,BANF2 +1588,ETFDH +1589,FAHD1 +1590,PLAGL1 +1591,CCND3 +1592,CRYZ +1593,ARHGAP1 +1594,IKBKE +1595,SNRNP35 +1596,ARNT +1597,RNF32 +1598,JAK1 +1599,LBR +1600,GHR +1601,NAT2 +1602,BLMH +1603,SLC35F1 +1604,CELSR3 +1605,LYPD6 +1606,TRIAP1 +1607,KALRN +1608,MPZL1 +1609,FPGS +1610,EIF2S1 +1611,THADA +1612,LGMN +1613,LORICRIN +1614,CDK5 +1615,EGF +1616,SMURF2 +1617,HSPA8 +1618,TCERG1 +1619,DHODH +1620,FKBP14 +1621,SNX6 +1622,MET +1623,POLR3G +1624,SNX7 +1625,SCAMP2 +1626,SDHA +1627,CCDC62 +1628,SHC1 +1629,GFOD1 +1630,COPZ2 +1631,GSTZ1 +1632,NLRP5 +1633,ZWILCH +1634,CRBN +1635,HMOX2 +1636,URB2 +1637,NUDT17 +1638,EPHB4 +1639,CAPG +1640,DSG2 +1641,R3HDML +1642,TMEM106A +1643,PAPLN +1644,LIPA +1645,SLC46A2 +1646,HDAC11 +1647,MIXL1 +1648,MICALL1 +1649,TXLNA +1650,ARHGEF33 +1651,DMTF1 +1652,CDC20 +1653,IQGAP1 +1654,CDC25B +1655,PSMF1 +1656,RSBN1 +1657,RSRC1 +1658,PARP2 +1659,NEUROG1 +1660,MRPL36 +1661,GNAS +1662,PCCB +1663,FLG +1664,S100A13 +1665,MMEL1 +1666,MAF +1667,CARD6 +1668,HOMER2 +1669,NUDT1 +1670,ETFB +1671,RALA +1672,PIK3AP1 +1673,TNFRSF8 +1674,TUBD1 +1675,DCTN5 +1676,OR2J3 +1677,ADAM10 +1678,RELN +1679,PARP9 +1680,IVD +1681,RPIA +1682,SRSF6 +1683,CRYBB1 +1684,ARFIP2 +1685,GOLT1B +1686,TEX30 +1687,DENND1B +1688,KPNB1 +1689,E2F2 +1690,ATXN7L3 +1691,ELMOD2 +1692,GFUS +1693,TOR2A +1694,G6PC3 +1695,TFF3 +1696,OSBPL5 +1697,FRMD6 +1698,UBXN7 +1699,PAF1 +1700,SLC25A1 +1701,NYAP2 +1702,RXRA +1703,ADRA1D +1704,ARHGAP9 +1705,TRIB1 +1706,DLX3 +1707,RGMB +1708,RBP4 +1709,SGCB +1710,NES +1711,GUK1 +1712,DTL +1713,QARS1 +1714,HEATR1 +1715,IQGAP2 +1716,MFSD3 +1717,HERPUD1 +1718,ZNHIT6 +1719,FAM163A +1720,CDCA4 +1721,ZMYM2 +1722,ADAMTSL1 +1723,LIG1 +1724,CLPS +1725,HCAR1 +1726,APEX1 +1727,DDIT4 +1728,PDLIM1 +1729,DDX42 +1730,CORO1B +1731,TKT +1732,ACSL3 +1733,ETV1 +1734,PCNA +1735,PSMB5 +1736,APOBEC1 +1737,SERTAD1 +1738,NKIRAS1 +1739,NLRC4 +1740,SAMD4B +1741,AKT3 +1742,PDCD11 +1743,IL2RA +1744,DEFB118 +1745,BLTP2 +1746,CDS2 +1747,MAT2A +1748,FAM110A +1749,BBS4 +1750,PAN2 +1751,IFIT1 +1752,TMEM235 +1753,HAND2 +1754,ST6GAL1 +1755,SYT8 +1756,SYMPK +1757,NOSIP +1758,INPP4B +1759,GNA15 +1760,FSTL1 +1761,RAB6B +1762,ABCB4 +1763,GABRB1 +1764,MAPK7 +1765,MIA2 +1766,ICAM3 +1767,NUDT9 +1768,ALOX12B +1769,ABHD4 +1770,ROR1 +1771,CYP1A2 +1772,GABARAPL2 +1773,BRD10 +1774,TNKS +1775,DNAJB8 +1776,PKD1 +1777,TSEN2 +1778,PAK1IP1 +1779,ASB2 +1780,EPHB2 +1781,TARS3 +1782,MME +1783,PACSIN3 +1784,NRSN1 +1785,ZBTB7C +1786,STK11 +1787,EDNRA +1788,PIM1 +1789,CTU1 +1790,PLEKHM1 +1791,HS2ST1 +1792,SLC22A8 +1793,BMP2K +1794,FSCB +1795,CYTL1 +1796,HIVEP1 +1797,WFDC10A +1798,GRIP1 +1799,LMTK3 +1800,CXCL6 +1801,RIPK1 +1802,RRS1 +1803,LONP2 +1804,KRTAP5-6 +1805,PDE7A +1806,CDC7 +1807,SMARCD2 +1808,SLC8A1 +1809,LIMS1 +1810,ELOVL7 +1811,VPS28 +1812,MFAP5 +1813,PNPLA6 +1814,MYBPHL +1815,GNA13 +1816,CLEC4D +1817,ZDHHC2 +1818,ENTPD8 +1819,DUSP3 +1820,CERK +1821,FUT1 +1822,LANCL1 +1823,ANKRD10 +1824,MGMT +1825,GH2 +1826,IGFL1 +1827,NAB1 +1828,NDUFAF2 +1829,NUP133 +1830,IGHMBP2 +1831,SERTAD2 +1832,METTL3 +1833,CYP27C1 +1834,ATP6V0B +1835,ANKRD40 +1836,S100A16 +1837,LY6H +1838,HS6ST3 +1839,IFIH1 +1840,CEL +1841,KCTD13 +1842,LRRC1 +1843,ARHGAP25 +1844,DYNLT4 +1845,ALLC +1846,ZNF530 +1847,CCDC116 +1848,MADD +1849,TBX2 +1850,ERI1 +1851,DDX1 +1852,GPBP1L1 +1853,DNER +1854,FSD1 +1855,CLMN +1856,ENTPD2 +1857,MACF1 +1858,MTX3 +1859,EHD3 +1860,BPHL +1861,SYCN +1862,AKR7A2 +1863,PRRX2 +1864,ZNF330 +1865,ZNF473 +1866,PPOX +1867,MUC13 +1868,GLOD4 +1869,HDC +1870,GRPEL1 +1871,CDKN2AIPNL +1872,CHST14 +1873,CYB5B +1874,F12 +1875,RAB11FIP2 +1876,UQCR11 +1877,IPO7 +1878,ZDHHC13 +1879,SDHB +1880,COG2 +1881,MESP2 +1882,ID2 +1883,SPTAN1 +1884,FAM3D +1885,GALR2 +1886,MYBPC2 +1887,GEMIN7 +1888,TESC +1889,ZNF426 +1890,SMARCA2 +1891,PRKRIP1 +1892,KCNB1 +1893,PYGB +1894,AGRN +1895,PTPN12 +1896,NR3C1 +1897,TRIM13 +1898,AK7 +1899,MED19 +1900,NTRK1 +1901,LRRC66 +1902,HHLA2 +1903,DNASE2B +1904,DIXDC1 +1905,RIC3 +1906,AAAS +1907,NEU1 +1908,AMFR +1909,HIPK2 +1910,RBM45 +1911,PAQR9 +1912,FANCM +1913,NUP153 +1914,SNX17 +1915,MPEG1 +1916,LIN28B +1917,SGK3 +1918,HPSE2 +1919,MT1H +1920,WDR7 +1921,ZNF345 +1922,MMS22L +1923,DDB2 +1924,CER1 +1925,VPS41 +1926,NCALD +1927,ORMDL1 +1928,ZNF462 +1929,NRXN2 +1930,ZKSCAN3 +1931,TYMS +1932,CDK5R1 +1933,OXSR1 +1934,ZNF620 +1935,PRR4 +1936,SLC16A1 +1937,TLCD3A +1938,CIMAP1D +1939,IGSF21 +1940,NFKBIE +1941,GALNT8 +1942,SLC6A13 +1943,GABRG3 +1944,NANOGNB +1945,S1PR1 +1946,ARHGEF2 +1947,KRT84 +1948,RSPH3 +1949,SLC25A36 +1950,TFAP4 +1951,CCDC102A +1952,C9 +1953,MYOG +1954,APCDD1 +1955,HIF3A +1956,ATF6 +1957,RTP4 +1958,FBN2 +1959,STON2 +1960,CSF2RB +1961,HAS1 +1962,ZC2HC1C +1963,GJC2 +1964,SMNDC1 +1965,MYBL1 +1966,CLTB +1967,PPP1CB +1968,ABCC4 +1969,PYY +1970,HDAC2 +1971,TTC19 +1972,PSIP1 +1973,PALB2 +1974,PPP1R3G +1975,NCOA3 +1976,PBX1 +1977,CAMK1G +1978,USP14 +1979,CHRNB1 +1980,TPM1 +1981,ROCK1 +1982,MBOAT7 +1983,POR +1984,GATA2 +1985,ELAVL1 +1986,DCBLD1 +1987,CA12 +1988,ZNF274 +1989,DCUN1D4 +1990,PELP1 +1991,RABEP2 +1992,LMO1 +1993,FCHO1 +1994,ROM1 +1995,OR10Z1 +1996,GPR45 +1997,ZBTB46 +1998,ATP2C1 +1999,EFCAB14 +2000,DNAJA3 +2001,SYT11 +2002,ACTA1 +2003,ZMIZ1 +2004,TERF2IP +2005,TRIB3 +2006,CPSF4 +2007,STK25 +2008,CRTAM +2009,BAG3 +2010,CYP2A13 +2011,TLR4 +2012,RHOH +2013,ATP6V0A1 +2014,STK10 +2015,MRPL40 +2016,PRKCQ +2017,DUSP22 +2018,NISCH +2019,TENM2 +2020,CDC25A +2021,THAP11 +2022,CDYL +2023,SAMD9 +2024,BCL2L12 +2025,ACVR1C +2026,CDK6 +2027,GCLC +2028,SLC11A2 +2029,NTRK2 +2030,SYNGR2 +2031,SDC4 +2032,DRAP1 +2033,TMEM132A +2034,GRIA4 +2035,CDK5R2 +2036,FREM1 +2037,GABRB2 +2038,SLC1A4 +2039,NDUFA3 +2040,PHLDA1 +2041,ATG5 +2042,PENK +2043,CDC42 +2044,FTMT +2045,KRAS +2046,PAX2 +2047,PHGDH +2048,ZNF589 +2049,MPP4 +2050,SNRPD1 +2051,HAL +2052,TIMELESS +2053,NRG3 +2054,DHX9 +2055,TEX19 +2056,RBM6 +2057,ZBTB20 +2058,RAP1A +2059,FOS +2060,WDR77 +2061,LIMK1 +2062,SLC29A2 +2063,RRP12 +2064,MS4A5 +2065,ATP6V1C2 +2066,TRAPPC6A +2067,WDR1 +2068,PIK3R3 +2069,RARA +2070,BHMT +2071,GALE +2072,SLC22A7 +2073,SWAP70 +2074,CXCR4 +2075,TSKU +2076,TMCO1 +2077,KCNG4 +2078,PLK3 +2079,AGTPBP1 +2080,DSPP +2081,KIAA0753 +2082,ARVCF +2083,PDGFRB +2084,COG7 +2085,GET1 +2086,COX5A +2087,CMPK1 +2088,CNOT2 +2089,CSDE1 +2090,STC1 +2091,RELB +2092,OR51E1 +2093,CEACAM19 +2094,MTOR +2095,CHMP5 +2096,KLK2 +2097,ARL4C +2098,PRRC2B +2099,ZNF347 +2100,PSMB7 +2101,SLC16A3 +2102,FBXL12 +2103,SMC4 +2104,SWT1 +2105,FBXO34 +2106,ANO10 +2107,ZNF35 +2108,HS3ST5 +2109,SYNM +2110,MAP4K4 +2111,ABTB1 +2112,AXIN1 +2113,GATAD1 +2114,GADD45B +2115,SCAP +2116,SNX13 +2117,RAC1 +2118,CCDC102B +2119,CSHL1 +2120,ERCC1 +2121,FAM151B +2122,KCND3 +2123,TMEM198 +2124,GNA11 +2125,CD40 +2126,HKDC1 +2127,TMEM106B +2128,BARD1 +2129,FUCA2 +2130,SULF1 +2131,ZKSCAN5 +2132,CYP2S1 +2133,HYOU1 +2134,PFN3 +2135,SMARCA4 +2136,SMU1 +2137,MAP2K2 +2138,RNF207 +2139,IDUA +2140,PWP1 +2141,KANSL1L +2142,MYC +2143,GLRX +2144,KIT +2145,CCL28 +2146,PLCXD3 +2147,LEFTY1 +2148,POLDIP3 +2149,RAB42 +2150,PCDHB15 +2151,F11R +2152,MAP4K2 +2153,STXBP2 +2154,NRL +2155,NR4A2 +2156,EFTUD2 +2157,RRP1B +2158,ZHX3 +2159,CCDC86 +2160,MRPS16 +2161,ATG16L1 +2162,RFPL2 +2163,JMJD6 +2164,SRSF1 +2165,LAP3 +2166,SMARCC1 +2167,SDC1 +2168,KTN1 +2169,CCNH +2170,CREB1 +2171,CAB39 +2172,EIF4EBP1 +2173,RAI14 +2174,SYNGR3 +2175,GLS2 +2176,MED29 +2177,BIRC2 +2178,ATXN7L3B +2179,H2AZ2 +2180,KRTAP6-2 +2181,EFCC1 +2182,ASPH +2183,ARHGEF18 +2184,NMNAT3 +2185,ATAD2 +2186,HLA-DRB5 +2187,PRLR +2188,COL1A1 +2189,CPEB2 +2190,TSPAN9 +2191,SERPINA6 +2192,PRKDC +2193,RHOA +2194,FZD7 +2195,GRB14 +2196,COASY +2197,HABP2 +2198,PSME1 +2199,NOP10 +2200,FDFT1 +2201,SEMA4F +2202,MYH4 +2203,KCNK18 +2204,IL4R +2205,SIRT3 +2206,TRAP1 +2207,DIPK1C +2208,VGLL4 +2209,ST3GAL6 +2210,PRKCA +2211,NFKBIA +2212,LTBP3 +2213,MRPL23 +2214,RALGDS +2215,XPO7 +2216,SIGLEC6 +2217,OR51V1 +2218,MOXD1 +2219,ZNF789 +2220,FBXO47 +2221,MIEN1 +2222,SHH +2223,PTPRC +2224,SAMD15 +2225,ERGIC1 +2226,ZNF736 +2227,SOX15 +2228,MYOZ3 +2229,DYSF +2230,SLC2A1 +2231,METAP2 +2232,LAYN +2233,SEC24C +2234,MLEC +2235,FANCF +2236,BNIP3 +2237,CCP110 +2238,COPS7A +2239,RAB11FIP3 +2240,CDK1 +2241,TSN +2242,TP53INP2 +2243,STAB2 +2244,ODF4 +2245,ARID5B +2246,SRSF7 +2247,MAU2 +2248,SPAG7 +2249,RRP8 +2250,OR13A1 +2251,ZBTB9 +2252,EPN2 +2253,SLC30A5 +2254,USP45 +2255,TAF4B +2256,TOPBP1 +2257,STK4 +2258,YWHAZ +2259,CYB561 +2260,SORBS3 +2261,TBX19 +2262,HEPACAM +2263,WIPF2 +2264,CPPED1 +2265,JAK3 +2266,CD320 +2267,PTPN6 +2268,SLC25A32 +2269,TRIM66 +2270,ZFP36 +2271,MEX3B +2272,CHST4 +2273,FRZB +2274,WDR70 +2275,KCNN3 +2276,ITK +2277,TM7SF3 +2278,RNF168 +2279,CRY2 +2280,CCDC170 +2281,PRMT5 +2282,POU4F1 +2283,ACTR1A +2284,NPC1 +2285,PGM1 +2286,INCENP +2287,PIN1 +2288,MAPKAPK5 +2289,ZNF471 +2290,CDH9 +2291,RDH12 +2292,MANBAL +2293,GABPB1 +2294,POLR2I +2295,ZNF3 +2296,PIAS1 +2297,GPR63 +2298,AMER2 +2299,NFKBIB +2300,NEFH +2301,GATA5 +2302,ABCB5 +2303,HTRA1 +2304,ADA +2305,NELL1 +2306,RUNDC3A +2307,QSOX1 +2308,ZNF415 +2309,LGR4 +2310,CHAC1 +2311,SEC31B +2312,CDK2 +2313,LTK +2314,MAPK14 +2315,EIF2AK3 +2316,TIAM2 +2317,NUCB2 +2318,AURKB +2319,OPRK1 +2320,MARCHF3 +2321,IL26 +2322,AFAP1L1 +2323,OR2T1 +2324,SORCS2 +2325,RASD2 +2326,CCN4 +2327,GBP2 +2328,TENT4A +2329,UBQLN4 +2330,MEGF11 +2331,IFNAR1 +2332,MDFIC +2333,FAM118A +2334,ENOPH1 +2335,POLE +2336,TTPAL +2337,NUSAP1 +2338,ELAC2 +2339,NEUROD4 +2340,PRKCD +2341,NR1H3 +2342,KCNH7 +2343,MESP1 +2344,RIN2 +2345,PGAM2 +2346,SERPINB1 +2347,RGS9 +2348,SLC15A3 +2349,UBE3A +2350,DOCK8 +2351,CCDC34 +2352,TNFSF11 +2353,GCKR +2354,FCRL4 +2355,TMEM179B +2356,CEBPZ +2357,ZNF92 +2358,SLC24A2 +2359,POLG +2360,ACVR2B +2361,ANXA4 +2362,MYBPC1 +2363,SLC23A2 +2364,CP +2365,DLK2 +2366,EDEM1 +2367,ANGEL2 +2368,CD2BP2 +2369,DHTKD1 +2370,FYN +2371,MUC1 +2372,EID2 +2373,HNRNPA3 +2374,MRPL9 +2375,INCA1 +2376,DNMT3A +2377,ATG3 +2378,IGF2BP2 +2379,SLC10A6 +2380,TG +2381,TMPRSS9 +2382,KAT6B +2383,SUPV3L1 +2384,HOXA11 +2385,PRKAA1 +2386,OR51S1 +2387,GDNF +2388,FAIM2 +2389,SPRTN +2390,RPS18 +2391,DDX18 +2392,PARPBP +2393,CDH3 +2394,TOR1A +2395,PLA2G1B +2396,SFN +2397,NGFR +2398,LLGL2 +2399,DAG1 +2400,ZNF135 +2401,IFT172 +2402,B4GALT4 +2403,LSM6 +2404,BECN1 +2405,NUAK2 +2406,DHX32 +2407,ITIH3 +2408,RND2 +2409,TGFB1 +2410,NOTCH1 +2411,EBAG9 +2412,REEP5 +2413,KDM5B +2414,GPR39 +2415,FAM114A2 +2416,KLF10 +2417,TEX10 +2418,MPC2 +2419,ZNF404 +2420,DCTD +2421,SYNE2 +2422,ZNF502 +2423,FLRT1 +2424,ERMN +2425,NUP88 +2426,PDE4B +2427,SSH1 +2428,FAM204A +2429,MAP1A +2430,C5 +2431,PPARG +2432,HELB +2433,CALM3 +2434,POP4 +2435,CARD11 +2436,DENND4B +2437,UBE2J1 +2438,VSTM1 +2439,FASLG +2440,ASRGL1 +2441,LCE3C +2442,MORC2 +2443,ZNF284 +2444,ADAM2 +2445,FAM216B +2446,ARHGAP32 +2447,EMC7 +2448,RPS6KA1 +2449,SLC38A11 +2450,MYBL2 +2451,CASC3 +2452,MAGI1 +2453,RAP1GAP +2454,ARSI +2455,EFCAB5 +2456,CD300A +2457,SPA17 +2458,NPHP3 +2459,HAT1 +2460,TLL2 +2461,MPZL3 +2462,PEPD +2463,KCTD6 +2464,ATP2A2 +2465,KIF11 +2466,CBR1 +2467,NTF3 +2468,ATMIN +2469,ATOH8 +2470,STIM1 +2471,FYCO1 +2472,ABCC5 +2473,PRPF4 +2474,FOXJ3 +2475,MEPE +2476,GIMAP6 +2477,NIT1 +2478,RBM43 +2479,ASCC3 +2480,FJX1 +2481,STMN1 +2482,TNIP1 +2483,PHF12 +2484,CHN2 +2485,FUCA1 +2486,MEST +2487,TBX20 +2488,TNNI3 +2489,NUAK1 +2490,SLC36A1 +2491,ACP5 +2492,GDA +2493,ILK +2494,BLCAP +2495,FOXN2 +2496,GLIS2 +2497,NMT1 +2498,GTPBP8 +2499,STK11IP +2500,NEBL +2501,TIMM9 +2502,DAPK3 +2503,GSR +2504,PAFAH1B3 +2505,KPNA4 +2506,POLR2K +2507,CRCP +2508,RPN1 +2509,FAT1 +2510,LRRC40 +2511,FIS1 +2512,USP6NL +2513,EBF2 +2514,DGAT1 +2515,ZFC3H1 +2516,MCM3 +2517,TTC39C +2518,FGFR1 +2519,YME1L1 +2520,LETM1 +2521,CCDC73 +2522,STRA8 +2523,PPP2R2D +2524,WNT5A +2525,PSCA +2526,HDAC3 +2527,SMTNL2 +2528,KLF12 +2529,DEFA5 +2530,RNMT +2531,ZNF414 +2532,TOP1 +2533,ADAT1 +2534,BCLAF1 +2535,TTBK1 +2536,ARHGDIB +2537,LDHAL6B +2538,TRPV4 +2539,SNCA +2540,ITGB5 +2541,HDAC9 +2542,PRSS23 +2543,CHMP2B +2544,NR2C2 +2545,FAH +2546,HINT2 +2547,GSDMB +2548,SF3A1 +2549,TPRX1 +2550,BUB1 +2551,RAB33B +2552,ZP1 +2553,SOX8 +2554,SOCS2 +2555,FGFBP3 +2556,ITGB2 +2557,RGS2 +2558,JUN +2559,HK1 +2560,SUV39H2 +2561,ZBTB43 +2562,NFIL3 +2563,PHKB +2564,APP +2565,DVL2 +2566,SPATA32 +2567,FAM111B +2568,ACRV1 +2569,L3MBTL3 +2570,ZNF490 +2571,SLC25A37 +2572,NFKB2 +2573,GJB4 +2574,ACSM2A +2575,PHOX2A +2576,MAP3K8 +2577,DUSP14 +2578,OXA1L +2579,ELL2 +2580,UCHL5 +2581,SAMD5 +2582,AARS1 +2583,KCNG1 +2584,SLC22A5 +2585,DENND2A +2586,PLCH2 +2587,SEMA3D +2588,RIMS4 +2589,AXDND1 +2590,HIVEP2 +2591,CLIC4 +2592,SPEN +2593,IAPP +2594,EAPP +2595,CAPN1 +2596,RPRD1B +2597,CCNB1 +2598,PTGDR2 +2599,BRD3 +2600,LRRC55 +2601,PIM3 +2602,AFG2B +2603,CAT +2604,PLXNA2 +2605,GAA +2606,BRD9 +2607,IGSF9B +2608,TFCP2L1 +2609,CEACAM4 +2610,GJA4 +2611,SACM1L +2612,USP16 +2613,CREBBP +2614,ADCY3 +2615,UBR2 +2616,UBE2C +2617,CASP2 +2618,SMCR8 +2619,GTF2F2 +2620,CHMP1A +2621,ACD +2622,DFFB +2623,UBR7 +2624,NEXN +2625,PLG +2626,HSPD1 +2627,DDX10 +2628,FAM135B +2629,DDX31 +2630,BEND4 +2631,FNTA +2632,ACLY +2633,TCTN1 +2634,MIER1 +2635,AKR1E2 +2636,PTPRF +2637,CEP350 +2638,OSBPL3 +2639,PDK1 +2640,GDE1 +2641,CRTAP +2642,DOT1L +2643,ATP10A +2644,SLC27A3 +2645,FARP2 +2646,CSNK1A1L +2647,PIGM +2648,RNH1 +2649,EPRS1 +2650,FLT3 +2651,EEIG2 +2652,NUMBL +2653,SDCCAG8 +2654,ACBD5 +2655,NOS3 +2656,USP22 +2657,FXN +2658,IDI1 +2659,PEX2 +2660,DHDDS +2661,MFAP3L +2662,VWDE +2663,SLC29A3 +2664,RRAGA +2665,GPR137 +2666,PDGFA +2667,CLECL1P +2668,TLCD3B +2669,KDR +2670,CCDC122 +2671,GFOD2 +2672,AKAP8L +2673,PLA2G4A +2674,PRMT6 +2675,MELK +2676,RFNG +2677,GGH +2678,VEZF1 +2679,GPRC5C +2680,SLC25A30 +2681,REC8 +2682,MKNK2 +2683,RANBP3 +2684,CEP57 +2685,BCL2L1 +2686,HCLS1 +2687,CHST1 +2688,SLC16A7 +2689,WASF3 +2690,IPO13 +2691,VENTX +2692,MMP1 +2693,ACADVL +2694,ST7 +2695,TTK +2696,IFT57 +2697,LAMA3 +2698,FLT1 +2699,TJP1 +2700,PLCB3 +2701,SSTR1 +2702,BACE2 +2703,PPP1R13B +2704,SSRP1 +2705,KLHL21 +2706,BRD8 +2707,SH2B3 +2708,CLEC4E +2709,AKT1 +2710,CAD +2711,ARID4B +2712,CHD9 +2713,PTPRS +2714,ETV3 +2715,LYRM1 +2716,TEKT1 +2717,TCP10L3 +2718,PUF60 +2719,GFPT2 +2720,F3 +2721,SPO11 +2722,TSPAN19 +2723,MAPK1IP1L +2724,RD3 +2725,CXCL2 +2726,SREBF1 +2727,POC5 +2728,CHMP6 +2729,ACTRT2 +2730,SERINC5 +2731,NVL +2732,WDR27 +2733,IMP3 +2734,BLTP3B diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/drug_target_genes_all_drugs.csv b/drevalpy/components/featurizers/cell_line/gene_lists/drug_target_genes_all_drugs.csv new file mode 100644 index 000000000..48eaab80d --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/drug_target_genes_all_drugs.csv @@ -0,0 +1,243 @@ +Symbol +EGFR +MTOR +KIT +FLT3 +RET +CSF1R +MET +CAPN1 +BCL2 +TUBB1 +SMO +BRAF +AURKA +AURKB +AURKC +ALK +ROS1 +SRC +KIF11 +NCSTN +ABL1 +CDK1 +CDK2 +CDK5 +CDK7 +CDK9 +CDK4 +LCK +FYN +PLK1 +PLK2 +PLK3 +IGF1R +ITK +AKT1 +AKT2 +AKT3 +HDAC1 +HDAC3 +IKBKB +PIK3CB +MAPK7 +BRD2 +BRD4 +EIF2A +ERBB2 +ROCK1 +ROCK2 +TOP2A +POLE +PTPN6 +PTPN11 +ARFGAP1 +ATM +FLT1 +GSK3A +GSK3B +PDGFRA +FGFR1 +MAPK9 +MAPK10 +PTK2 +PTK2B +BRD3 +BRDT +HDAC2 +FNTA +PDK1 +PDPK1 +PKM +PPARG +PPARD +CASP3 +CASP7 +PAK1 +SGK2 +SGK3 +SYK +TYMS +ATP2A2 +MCL1 +RXRA +HSP90AB1 +SDHA +PRKCA +PDGFRB +TUBA1A +TUBB +CHUK +APP +JAK1 +JAK2 +MAPK8 +RAF1 +CDK6 +MAPK14 +MAPK11 +PIK3CA +PIK3CG +PRKCB +RPS6KA1 +PIK3CD +EHMT2 +FLT4 +AXL +NTRK2 +LTK +ATG5 +MAP4K2 +MAP3K7 +MAPK1 +MAPK3 +EDNRA +BIRC5 +MDM4 +HDAC9 +RARA +MAP2K5 +ANGPT1 +JAK3 +MAP3K8 +IDUA +EPHB4 +POLR1C +DAPK3 +CLK4 +PIM3 +HIPK2 +TGFBR1 +KDR +PIKFYVE +HSP90AA1 +DDR1 +RIPK1 +CAMK2B +NR1H4 +NR1H3 +SIRT1 +ERBB4 +LIMK1 +PKD1 +SPHK1 +EEF2K +METAP2 +DLD +SCD +ADA +FAS +SSTR1 +MKNK1 +MKNK2 +TP53 +ACVR1B +ACVR1C +TGIF1 +BMP2 +S1PR1 +BAX +IDH2 +KRAS +SHH +PRKAA1 +TOP1 +POLG +MAP2K1 +PARP1 +PARP2 +TEC +CRBN +CHEK1 +CHEK2 +NTRK1 +NTRK3 +TBK1 +PRKDC +PIM1 +WEE1 +MDM2 +FGFR2 +FGFR3 +MAP2K2 +PPM1D +RAC1 +RAC3 +ERCC1 +GSTP1 +RPS6KB1 +BRSK2 +MARK4 +PRKCD +SRPK1 +ATR +ESR1 +EGLN1 +DOT1L +DHX9 +NAMPT +TNFSF10 +L3MBTL3 +EHMT1 +NMT1 +TNKS2 +MGMT +DYRK1B +ERBB3 +FGFR4 +GLS +BIRC3 +IGF1 +SLC16A1 +SLC16A4 +PARP6 +TTK +CSF3 +SMARCA2 +SMARCA4 +DHODH +NUAK1 +NUAK2 +EIF2AK3 +EZH2 +USP1 +IRAK4 +PAK2 +ULK1 +PIK3C3 +GSR +RRM1 +CDK5R1 +TGFB1 +BRD1 +CDC42BPB +IDH1 +ESR2 +LRRK2 +BRD9 +TERT +USP7 +USP47 +BCL2L1 +BRCA1 +PRMT5 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/drug_target_genes_all_drugs_proteomics.csv b/drevalpy/components/featurizers/cell_line/gene_lists/drug_target_genes_all_drugs_proteomics.csv new file mode 100644 index 000000000..b98d7e8b2 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/drug_target_genes_all_drugs_proteomics.csv @@ -0,0 +1,51 @@ +Symbol +RRM1 +METAP2 +SLC16A1 +GSTP1 +RAC1 +BRD4 +CAPN1 +PAK2 +EIF2A +USP7 +TOP2A +PARP1 +AURKB +PRMT5 +CDK1 +MAPK3 +MAPK1 +HDAC1 +SRPK1 +SMARCA4 +DHX9 +IDH2 +NAMPT +HSP90AB1 +PKM +TOP1 +CASP3 +HDAC2 +EHMT1 +POLR1C +IDH1 +CDK5 +PTPN11 +NCSTN +DHODH +ARFGAP1 +BAX +HSP90AA1 +TUBB +PRKDC +NMT1 +GLS +SDHA +GSR +PRKAA1 +DLD +PTK2 +ATP2A2 +KIF11 +EHMT2 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/drug_target_genes_reduced.csv b/drevalpy/components/featurizers/cell_line/gene_lists/drug_target_genes_reduced.csv new file mode 100644 index 000000000..16353ac01 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/drug_target_genes_reduced.csv @@ -0,0 +1,50 @@ +Symbol +RRM1 +METAP2 +SLC16A1 +GSTP1 +RAC1 +BRD4 +CAPN1 +PAK2 +EIF2A +USP7 +TOP2A +AURKB +PRMT5 +CDK1 +MAPK3 +MAPK1 +HDAC1 +SRPK1 +SMARCA4 +DHX9 +IDH2 +NAMPT +HSP90AB1 +PKM +TOP1 +CASP3 +HDAC2 +EHMT1 +POLR1C +IDH1 +CDK5 +PTPN11 +NCSTN +DHODH +ARFGAP1 +BAX +HSP90AA1 +TUBB +PRKDC +NMT1 +GLS +SDHA +GSR +PRKAA1 +DLD +PTK2 +ATP2A2 +KIF11 +EHMT2 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/gene_expression_intersection.csv b/drevalpy/components/featurizers/cell_line/gene_lists/gene_expression_intersection.csv new file mode 100644 index 000000000..9e7d55e38 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/gene_expression_intersection.csv @@ -0,0 +1,2271 @@ +,Symbol +0,RRM1 +1,PLEKHJ1 +2,AASDH +3,FKBP4 +4,KDM5A +5,HSPA2 +6,SKIC8 +7,ASB8 +8,BID +9,TPRA1 +10,CNPY3 +11,PSMG1 +12,CLEC1A +13,ESYT2 +14,POLR2G +15,XKR6 +16,NDUFA2 +17,TUBA1C +18,MAPK9 +19,KCNK1 +20,EGR1 +21,KLHL9 +22,USP38 +23,PRKAG2 +24,SHD +25,PIP4K2B +26,TSGA10 +27,RTF1 +28,RAB19 +29,ZNF276 +30,PLEKHA6 +31,NFATC2 +32,CCDC136 +33,SRC +34,ANKRD28 +35,ADRB2 +36,EIF5 +37,SETX +38,IQSEC3 +39,CDC42BPB +40,SRA1 +41,RAB31 +42,MLLT11 +43,GTF2B +44,FAM219A +45,MVP +46,EYS +47,ARNT2 +48,STX1A +49,PREB +50,SNAPC4 +51,RAB21 +52,CDIP1 +53,BAMBI +54,LGALS8 +55,TES +56,SNAPIN +57,BAX +58,GLRX5 +59,NT5DC2 +60,MRPS2 +61,AURKAIP1 +62,ANXA7 +63,CCNE1 +64,FAS +65,LRRC41 +66,VAV3 +67,STAMBP +68,PAK2 +69,TMCC3 +70,MRTFA +71,MTF2 +72,CCND1 +73,SART1 +74,ARPP19 +75,EXTL2 +76,DYRK3 +77,PIK3CD +78,ZEB1 +79,SENP6 +80,CSNK2A2 +81,SETD1A +82,USP32 +83,NR1H2 +84,KNDC1 +85,SLC16A14 +86,PIK3CB +87,B4GALT1 +88,MARK4 +89,RPA3 +90,LRP12 +91,KIF9 +92,TATDN2 +93,ZNF862 +94,IER3 +95,PYCR1 +96,NDUFAB1 +97,PIK3R5 +98,SMO +99,CCL2 +100,ANAPC10 +101,HEBP1 +102,ZNF586 +103,KLF6 +104,RNF11 +105,OVOL1 +106,DPP8 +107,TSPAN3 +108,MNX1 +109,SPTLC2 +110,NPEPL1 +111,NTRK3 +112,CACNA1E +113,TBC1D9B +114,MCOLN1 +115,PSMD4 +116,STAR +117,PYGL +118,KNTC1 +119,ATP7B +120,FGFR2 +121,PIGB +122,CIRBP +123,CDR2L +124,NR2C2AP +125,SLC25A4 +126,FCGR2A +127,UBE2L6 +128,TDRD7 +129,RNF167 +130,QRICH1 +131,COPZ1 +132,TIMM22 +133,NRAS +134,MAN2C1 +135,TGFBR1 +136,PPP2R3C +137,PPIC +138,SPR +139,KIF21A +140,BCL7B +141,RAF1 +142,TNKS2 +143,SKP1 +144,PRKACA +145,IL12B +146,CES4A +147,MAPK1 +148,PPT1 +149,ARHGEF40 +150,ZDHHC6 +151,CAPZB +152,TMEM170B +153,TNFRSF13B +154,RAB4A +155,TPGS2 +156,TBXA2R +157,RAD51D +158,MAP3K4 +159,ZNF202 +160,ATP6V1B2 +161,VCL +162,CD63 +163,MRPS26 +164,MBTPS1 +165,PEX13 +166,HES1 +167,NSMCE1 +168,INTS3 +169,MBNL2 +170,AEBP1 +171,ARIH2 +172,DENND2D +173,CRLS1 +174,RRP9 +175,VPS35 +176,BAD +177,EGFR +178,ADI1 +179,DESI1 +180,SUPT20H +181,AXL +182,CLK4 +183,ZNF451 +184,IARS1 +185,UBE3B +186,PTPRK +187,KDM3A +188,PCBP2 +189,MAPKAPK2 +190,CDKN2A +191,PIK3CA +192,DCTN2 +193,S100A4 +194,TGFB1I1 +195,ANK1 +196,RUVBL1 +197,GNG2 +198,ZNF395 +199,PTPN1 +200,TXNL4B +201,RNF152 +202,YTHDF1 +203,CHEK2 +204,AGPAT2 +205,WEE1 +206,COLEC12 +207,DYNC2LI1 +208,SCD +209,ALDH7A1 +210,MBNL1 +211,ATRN +212,C2CD5 +213,INSIG1 +214,HEATR6 +215,TRUB1 +216,ZNF318 +217,CCNA2 +218,BRIP1 +219,SLC38A7 +220,MEF2C +221,IGF2R +222,EZH2 +223,POLE2 +224,SQSTM1 +225,TTC32 +226,FGF18 +227,NDUFB1 +228,ZDHHC19 +229,BLVRA +230,BRAF +231,CCDC146 +232,NIPSNAP1 +233,YPEL2 +234,POLR1C +235,CLEC16A +236,NCAM1 +237,STARD4 +238,BRF2 +239,CISD1 +240,CCDC112 +241,AMIGO1 +242,ADH5 +243,MFAP2 +244,SPG7 +245,TCEA2 +246,CDKN2AIP +247,YKT6 +248,RPS6KB1 +249,TGIF1 +250,B9D1 +251,GSTP1 +252,CDH1 +253,ORC1 +254,AIM2 +255,UPP2 +256,TP53 +257,BIRC5 +258,KEAP1 +259,MAP4K1 +260,SCYL3 +261,ERBB3 +262,EML3 +263,CORIN +264,ABHD6 +265,GPATCH8 +266,MAP2 +267,PRKCB +268,PLA2R1 +269,SEMA4A +270,TONSL +271,CSRP1 +272,KANK1 +273,CENPE +274,PROM2 +275,HDAC5 +276,AHDC1 +277,STX3 +278,IFI27L2 +279,PDS5A +280,CPNE3 +281,TFDP1 +282,CDK13 +283,FNBP1 +284,DNAI2 +285,ENO2 +286,PAFAH1B1 +287,NUDT5 +288,STEAP3 +289,USP15 +290,PAICS +291,CD82 +292,IFT122 +293,LAMP3 +294,TYW1 +295,B3GALT1 +296,UBE3C +297,DIRAS1 +298,AP2A1 +299,SMAD4 +300,PPIA +301,TOP2A +302,TMEM203 +303,SWSAP1 +304,IQUB +305,HDAC7 +306,NDUFA10 +307,ALAS1 +308,FAM210B +309,MYO18A +310,ECD +311,JMJD4 +312,NCOR2 +313,RPH3A +314,ANKRD34B +315,CSNK1A1 +316,TMBIM6 +317,RAPSN +318,PKIB +319,WDR75 +320,MSH2 +321,PLA2G15 +322,DDIT4L +323,TRADD +324,RASA1 +325,AURKC +326,DOCK4 +327,PSRC1 +328,MED13 +329,MYL6B +330,DNM1 +331,GTDC1 +332,HSD17B14 +333,BCAR3 +334,CETN3 +335,PDP1 +336,FAM76B +337,GATA6 +338,MRPL46 +339,TRPC4AP +340,HMG20B +341,LCK +342,AKAP12 +343,AMDHD2 +344,PCK2 +345,MAP2K5 +346,NR4A3 +347,HGF +348,MAPK3 +349,PSTPIP2 +350,CDC123 +351,ZBTB44 +352,ATP6V1D +353,MDH1B +354,LGALSL +355,NR2F6 +356,ATF1 +357,SLC12A6 +358,NCSTN +359,BNC2 +360,TRIM37 +361,EXOSC4 +362,RING1 +363,ISOC1 +364,PTK2 +365,MAFK +366,PRMT7 +367,SNX11 +368,PITPNA +369,C1QA +370,MCM9 +371,SHROOM3 +372,ITPKB +373,PRDM5 +374,CASP7 +375,ACAT2 +376,TIPARP +377,DUSP6 +378,IL6ST +379,ADCY10 +380,RNF181 +381,ENO3 +382,NNT +383,EPAS1 +384,ZC3HAV1 +385,MSH6 +386,COX7A2L +387,HDAC4 +388,WDR47 +389,FRS2 +390,CDK7 +391,SKIC2 +392,FIGN +393,WDR5 +394,FBXO8 +395,ATAD5 +396,DNAJB2 +397,HSPA4 +398,SIVA1 +399,CSRNP1 +400,RET +401,PROX2 +402,MCMBP +403,IKBKB +404,TMEM64 +405,ZCCHC17 +406,PKP4 +407,RBKS +408,FZD1 +409,YBX2 +410,SLC25A13 +411,HESX1 +412,BZW2 +413,ARHGEF19 +414,PTTG1IP +415,RNASE2 +416,SGK2 +417,TMEM53 +418,PKIG +419,ACAA1 +420,MTMR9 +421,HYAL2 +422,GGPS1 +423,PNP +424,ANKRD49 +425,RPN2 +426,HIPK4 +427,PDE8A +428,PPCDC +429,CIB3 +430,PPIL1 +431,STXBP1 +432,ABL1 +433,NFATC3 +434,ZRANB1 +435,SUGP2 +436,SACS +437,PRDM10 +438,SLAMF1 +439,EXOC3 +440,MTHFD2 +441,RNASE3 +442,TRIM45 +443,IRX3 +444,MYL5 +445,AKAP8 +446,CDK19 +447,PAK4 +448,MCHR1 +449,OTUD4 +450,CANT1 +451,KLHL36 +452,RFC5 +453,ARRB1 +454,UNC13D +455,CRKL +456,SLC25A20 +457,H2BC12 +458,ZNF7 +459,USP4 +460,ARFGAP1 +461,DECR2 +462,USP6 +463,PLS1 +464,BMP2 +465,CHIC2 +466,SURF6 +467,VNN3P +468,NUP85 +469,AARS2 +470,WFS1 +471,TACO1 +472,TXNDC9 +473,DFFA +474,ERBB2 +475,CATSPERD +476,BFAR +477,ZNF195 +478,SMARCB1 +479,DDX6 +480,GALC +481,SRSF12 +482,DDR1 +483,APOE +484,RBP7 +485,NTN4 +486,LRP10 +487,TOR1AIP1 +488,SLC25A46 +489,STAT5B +490,STOML3 +491,IARS2 +492,TMEM33 +493,PYCARD +494,MED23 +495,SLC35F3 +496,LRPAP1 +497,PDZD7 +498,B3GNT9 +499,F13A1 +500,DAXX +501,PHYHIP +502,ABCA8 +503,EHMT2 +504,ATP6V1E2 +505,PDE9A +506,TTC8 +507,CREG1 +508,AURKA +509,SRGAP1 +510,CEP72 +511,TMEM18 +512,DBR1 +513,HSPA5 +514,EIF1B +515,ICA1L +516,DERA +517,IGFBP3 +518,REPS1 +519,HLA-DRA +520,PPP2R5A +521,EGLN1 +522,GNPDA1 +523,GPRIN1 +524,RBL2 +525,KANSL1 +526,ODAD2 +527,QPCTL +528,DHRS7 +529,MON2 +530,SLC16A4 +531,MUC12 +532,RAC3 +533,SLC2A6 +534,KDM2A +535,LRP4 +536,TRIQK +537,GP6 +538,CD8A +539,CLPX +540,HSPA1A +541,ACBD3 +542,UGDH +543,FEZ2 +544,TAP1 +545,APRT +546,HADH +547,KIF17 +548,TUBB6 +549,LYN +550,SLC5A6 +551,DHX29 +552,TSPAN4 +553,DCUN1D2 +554,TM9SF3 +555,SLC7A1 +556,BLNK +557,SLC37A4 +558,AK5 +559,BBS2 +560,ATF2 +561,IDH1 +562,DNAH14 +563,RPS5 +564,TMEM259 +565,PACS1 +566,EIF2B5 +567,EFHC1 +568,ZNF704 +569,PKN1 +570,BRD1 +571,PROS1 +572,NET1 +573,DYNLT1 +574,SLC4A11 +575,STARD3 +576,VPS72 +577,FLNB +578,TMCC2 +579,ERCC5 +580,CKAP4 +581,PHKG2 +582,TLE1 +583,TCFL5 +584,KLHL3 +585,ACTB +586,COG5 +587,PDGFRA +588,UBASH3B +589,SNX21 +590,PKM +591,SPAG16 +592,ZIK1 +593,PXMP4 +594,PARVB +595,TRIM7 +596,NDN +597,IDE +598,NNAT +599,NUP93 +600,LIMD2 +601,KCTD5 +602,SET +603,PTGS2 +604,GRB10 +605,KLHDC2 +606,CTNND1 +607,DECR1 +608,SLBP +609,DDX60 +610,RASSF4 +611,TOR4A +612,FBXO21 +613,EXT1 +614,UFM1 +615,CCNA1 +616,ZBTB14 +617,PCBD1 +618,DNAJB1 +619,PEX6 +620,DNTTIP2 +621,DEK +622,WDR24 +623,POLR3A +624,MAN2A1 +625,CIAO3 +626,ELOVL6 +627,ITGB1BP1 +628,RNF122 +629,CATSPERB +630,OXCT1 +631,PRTN3 +632,TTC33 +633,GRWD1 +634,ST3GAL5 +635,RUNDC3B +636,MAP2K1 +637,MTFR1 +638,PRCP +639,TRPM5 +640,HMOX1 +641,LRRK2 +642,RPL30 +643,DEPDC4 +644,PPM1M +645,CSF1R +646,ACTR6 +647,BRD4 +648,MAPKAPK3 +649,LARP4 +650,PGM2 +651,CLEC10A +652,NPDC1 +653,BUB3 +654,SLC27A5 +655,RGS5 +656,ZDHHC5 +657,NPC1L1 +658,MSRB3 +659,PPIE +660,ADO +661,GPSM1 +662,SH2B1 +663,VARS2 +664,CTTN +665,P4HTM +666,BIRC3 +667,ZNF76 +668,CHKA +669,KCNQ4 +670,NDUFA13 +671,ATP6V1G1 +672,BNIP3L +673,RAB6A +674,PLSCR1 +675,RAD51C +676,HGD +677,PTER +678,SOX4 +679,CGRRF1 +680,CDK4 +681,PHF1 +682,PCOLCE2 +683,SLC25A45 +684,LYSMD2 +685,CCL20 +686,AGL +687,GNB5 +688,MDM2 +689,GLIPR2 +690,C2CD2L +691,AGAP3 +692,NR2F2 +693,HNRNPU +694,G3BP1 +695,EPB41L2 +696,SPAG4 +697,DNAJB5 +698,PARN +699,RFX5 +700,NAMPT +701,COG4 +702,ZNF253 +703,GPC1 +704,JAKMIP2 +705,ECH1 +706,CCDC127 +707,NFKBID +708,SNX22 +709,RIT1 +710,CPNE7 +711,FBXO11 +712,MYCBP2 +713,FGF11 +714,SYPL1 +715,TBP +716,KLHL24 +717,THUMPD3 +718,TEC +719,GPR146 +720,ITPRID2 +721,CTNNA3 +722,CIAPIN1 +723,SH3BP5 +724,ETS1 +725,MAP3K7 +726,CBLB +727,DOK2 +728,LPAR2 +729,ABCA5 +730,USP7 +731,MOSPD3 +732,SLC25A38 +733,SCUBE1 +734,HDAC1 +735,FAM20B +736,SLC39A10 +737,PIK3C3 +738,PARP6 +739,CSE1L +740,ECHS1 +741,CHST11 +742,CCDC92 +743,YBEY +744,CLCN2 +745,CAMK2B +746,TCIRG1 +747,BMP4 +748,CCDC90B +749,TFAP2A +750,PTK2B +751,DHX16 +752,NCAPD2 +753,SNRPA +754,USP30 +755,MAPK11 +756,KLHL11 +757,TNFRSF9 +758,LPIN2 +759,ESR1 +760,XAB2 +761,CALU +762,CLDN4 +763,NPRL2 +764,TLK2 +765,RUVBL2 +766,ALG8 +767,STAT3 +768,SRPK1 +769,EZH1 +770,GPR183 +771,PTPRM +772,LAT2 +773,TMED4 +774,PTMS +775,PDE1C +776,SNX2 +777,PTPN11 +778,PSMA8 +779,GIGYF1 +780,TRAK2 +781,POLG2 +782,ZNF543 +783,DYRK1B +784,P2RY6 +785,APMAP +786,GIPC2 +787,BST2 +788,ESR2 +789,SLC35F2 +790,BTRC +791,MSRA +792,ULK1 +793,LPL +794,ANGPT1 +795,RPUSD3 +796,SPHK1 +797,ZNF527 +798,PMAIP1 +799,SLC49A4 +800,SCRN1 +801,ZFHX2 +802,TUBA1A +803,RAD9A +804,MXD4 +805,ELOVL4 +806,IDH2 +807,INO80D +808,FANK1 +809,SLC35A3 +810,TNRC6C +811,FAIM +812,LOXL1 +813,TNFSF10 +814,CCT5 +815,SERPINE1 +816,AFMID +817,TMEM97 +818,NEU4 +819,C2CD2 +820,CELF1 +821,CRB1 +822,GCC1 +823,ARHGAP44 +824,ZNF107 +825,FFAR2 +826,TRA2B +827,KIF5C +828,LRRC58 +829,CALR +830,ZNF333 +831,MYLK +832,YWHAQ +833,STX4 +834,MTA1 +835,ARF4 +836,CASP3 +837,LMO4 +838,MYL9 +839,TXNRD1 +840,BCL2 +841,LRP2 +842,SCP2 +843,COPB2 +844,DNAJB6 +845,DNM1L +846,CKAP2 +847,FLT4 +848,PLEKHM3 +849,TRIM2 +850,SIRPB2 +851,TAFA2 +852,TFB2M +853,RPP25L +854,PHF14 +855,RTN2 +856,NUP107 +857,TPPP +858,TP53RK +859,EMC10 +860,SUPT16H +861,UBTF +862,TMEM50A +863,UBQLN1 +864,TRAPPC3 +865,SUGT1 +866,ABCF1 +867,GLI2 +868,RSPO2 +869,PLK1 +870,WDTC1 +871,DLEU7 +872,RNF149 +873,MAPK10 +874,TOR3A +875,UGGT2 +876,PHRF1 +877,HMGA2 +878,CTNNAL1 +879,BRD2 +880,KRT2 +881,WDR74 +882,TBK1 +883,ECHDC2 +884,FAM114A1 +885,FAM120AOS +886,NFE2L2 +887,ZNF626 +888,NPLOC4 +889,MOAP1 +890,SYNJ2 +891,GLYR1 +892,DNAH3 +893,TBCEL +894,ELF2 +895,NLE1 +896,SMAGP +897,ROCK2 +898,GDPD5 +899,HEG1 +900,PARVA +901,VPS37A +902,RPS6 +903,SLC2A9 +904,KDM4B +905,EED +906,PSMB8 +907,HOXA7 +908,SNRNP25 +909,COL15A1 +910,KAT6A +911,HSP90AA1 +912,BDH1 +913,ZNF619 +914,TNFRSF21 +915,SMOX +916,CSTB +917,CCR4 +918,BCL9L +919,DLD +920,MAST2 +921,IQCA1 +922,MAN2B1 +923,ANGPTL6 +924,ASAH1 +925,DLST +926,NOL3 +927,CYB5R4 +928,MOK +929,EBF1 +930,SUZ12 +931,EFCAB12 +932,TEX264 +933,RNF112 +934,TEAD3 +935,LRRC32 +936,SPP1 +937,CNOT4 +938,XPNPEP1 +939,TTC17 +940,DNMT1 +941,PRDX6 +942,SYK +943,PRR16 +944,ARL11 +945,CCSER1 +946,KIF14 +947,SCYL2 +948,CSF3 +949,NDUFAF5 +950,RABL3 +951,KCNRG +952,SSBP2 +953,ELK4 +954,COL4A1 +955,UNCX +956,TRAF2 +957,KRT72 +958,CCNE2 +959,PDIA5 +960,SCCPDH +961,GAPDH +962,ME2 +963,ISL2 +964,CYP1B1 +965,NPM1 +966,CYTH1 +967,LSP1 +968,TRAM2 +969,FHL2 +970,EHMT1 +971,FEM1B +972,WARS1 +973,PFKL +974,NAB2 +975,SLC35B1 +976,TSC22D1 +977,IL1B +978,DPH2 +979,PECR +980,ZNF781 +981,TERT +982,SMC3 +983,ST6GALNAC2 +984,PMM2 +985,KLK1 +986,FOSL1 +987,RHD +988,DNAJC2 +989,PUS1 +990,PDCD6 +991,IGF1 +992,MYL6 +993,MRPL19 +994,TWSG1 +995,DCK +996,DUSP4 +997,CTRC +998,ENPEP +999,TICAM1 +1000,RB1 +1001,BCR +1002,TMEM109 +1003,PCMT1 +1004,ZNF30 +1005,TOMM34 +1006,DCST2 +1007,FGFR1OP2 +1008,DTX2 +1009,NRIP1 +1010,S100A8 +1011,ZBTB7B +1012,ECSIT +1013,TESK1 +1014,MAT2B +1015,P4HA2 +1016,ARHGEF12 +1017,ZNF77 +1018,SESN1 +1019,MRTO4 +1020,CSNK1E +1021,TMED10 +1022,LETMD1 +1023,TBPL1 +1024,HOXA13 +1025,RXRB +1026,GNAI1 +1027,MYADM +1028,LPGAT1 +1029,ICMT +1030,RFC2 +1031,ATF5 +1032,GCNT1 +1033,ATP11B +1034,RAE1 +1035,DDX49 +1036,INPP1 +1037,RPA1 +1038,SRP54 +1039,GAL3ST1 +1040,NTMT1 +1041,RIMS3 +1042,HMGCS1 +1043,MANEA +1044,ATP6V1C1 +1045,MYO18B +1046,ZNF697 +1047,ATM +1048,NSMCE2 +1049,COMMD1 +1050,MYO10 +1051,HOMER1 +1052,CASP10 +1053,PDPK1 +1054,ASCC2 +1055,FBXO16 +1056,FGFR3 +1057,SLTM +1058,GRN +1059,ASNS +1060,TP53BP2 +1061,RNF170 +1062,IGF1R +1063,SLC35A1 +1064,CHEK1 +1065,RHEB +1066,GSK3A +1067,BRCA1 +1068,STAT1 +1069,PPM1L +1070,ST7L +1071,CD44 +1072,ORAI2 +1073,HAVCR1 +1074,SASS6 +1075,CRELD2 +1076,CLTC +1077,TUBB +1078,ZBTB22 +1079,DMXL1 +1080,STARD7 +1081,PPA2 +1082,RBM18 +1083,IMPA1 +1084,ALDOA +1085,PIKFYVE +1086,PIH1D1 +1087,PRKCG +1088,ICAM1 +1089,USP47 +1090,IRX5 +1091,MRPL22 +1092,CSK +1093,MLXIP +1094,CALCB +1095,HSP90AB1 +1096,ATR +1097,DYRK1A +1098,SUGP1 +1099,RALB +1100,CTTNBP2 +1101,SLC5A9 +1102,SPRED2 +1103,EFNA5 +1104,OLIG1 +1105,GLS +1106,PSPH +1107,TIAM1 +1108,PER1 +1109,CEBPE +1110,CDK9 +1111,MZT2A +1112,PDE5A +1113,ACAP1 +1114,MROH1 +1115,TGFB3 +1116,GPAM +1117,TEK +1118,RBM15B +1119,SRP68 +1120,TFPI2 +1121,EIF2A +1122,PNKP +1123,PLEKHG1 +1124,GSK3B +1125,CTSB +1126,PAX8 +1127,ATN1 +1128,APPBP2 +1129,JAK2 +1130,NIPA1 +1131,KCNG2 +1132,TMEM229B +1133,RHBDD3 +1134,FLACC1 +1135,MAPK8 +1136,CPOX +1137,BHLHE23 +1138,VAPB +1139,RHOB +1140,USP1 +1141,RARS2 +1142,CHN1 +1143,EDN1 +1144,PDHX +1145,FKBP1B +1146,GTF2E2 +1147,NBEA +1148,TIE1 +1149,TP53BP1 +1150,KATNIP +1151,LSR +1152,GTF2A2 +1153,MTHFD1L +1154,TUBB1 +1155,YY1AP1 +1156,SPDEF +1157,TBX18 +1158,BRSK2 +1159,SOCS1 +1160,CAST +1161,SLC25A26 +1162,PLOD3 +1163,FAM174A +1164,MCL1 +1165,ADAM15 +1166,SCAND1 +1167,CPLX1 +1168,FBXO7 +1169,TMEM50B +1170,ZKSCAN2 +1171,TGFBR2 +1172,SENP1 +1173,ITGAE +1174,MRGBP +1175,CASP1 +1176,PPM1D +1177,SLC10A7 +1178,PCM1 +1179,NOL7 +1180,PPARD +1181,RGS16 +1182,ITPK1 +1183,PAK6 +1184,HDLBP +1185,SEZ6L +1186,AKT2 +1187,BHLHE40 +1188,CSAD +1189,RILPL1 +1190,GATA3 +1191,EBNA1BP2 +1192,ZCWPW1 +1193,CNOT10 +1194,ZNF280D +1195,ATG9A +1196,IRGQ +1197,KIF20A +1198,PEG10 +1199,RNF208 +1200,PPP2R5E +1201,ATP1B1 +1202,FGFR4 +1203,ZHX2 +1204,OLFML3 +1205,ACVR1B +1206,TRDMT1 +1207,GNG5 +1208,IFRD2 +1209,MED10 +1210,UBL3 +1211,SIRT1 +1212,DIPK1A +1213,POLB +1214,SLC27A4 +1215,THRB +1216,NUDCD3 +1217,SGTB +1218,PLK2 +1219,ZNF189 +1220,RHBDF1 +1221,XBP1 +1222,MAP7 +1223,CBR3 +1224,ZNF329 +1225,SMAD3 +1226,COX5B +1227,PEX11A +1228,LPXN +1229,CNDP2 +1230,FOXO3 +1231,SAR1A +1232,ARHGEF4 +1233,A2M +1234,MFSD10 +1235,FBXL3 +1236,GADD45A +1237,RAB40B +1238,RAB27A +1239,PTEN +1240,CAMSAP2 +1241,DMXL2 +1242,SHC2 +1243,SNX20 +1244,PDE3B +1245,RAB7A +1246,PAK1 +1247,CAMP +1248,RPL39L +1249,TIMP2 +1250,AKT1S1 +1251,GTPBP1 +1252,WBP11 +1253,RNF25 +1254,ENOSF1 +1255,TMEM38B +1256,UBE2L3 +1257,MS4A6E +1258,ZNF131 +1259,PIK3C2B +1260,MGAT1 +1261,CAPN10 +1262,IGFBP5 +1263,CRIP3 +1264,CDC45 +1265,TARBP1 +1266,SLC24A3 +1267,TPD52L2 +1268,NACC1 +1269,FBXO41 +1270,MRPL18 +1271,TMEM8B +1272,RNPS1 +1273,NDUFB2 +1274,NCK2 +1275,THOP1 +1276,RALY +1277,XRCC3 +1278,NCOA2 +1279,VAT1 +1280,CLYBL +1281,EME1 +1282,RTN4IP1 +1283,EVL +1284,APLP2 +1285,GNAI2 +1286,FBXO33 +1287,CDKN1B +1288,STAP2 +1289,GFPT1 +1290,ACTR3B +1291,UQCRH +1292,TM9SF2 +1293,TMEM255B +1294,NELL2 +1295,ANKRD44 +1296,ITFG1 +1297,RPA2 +1298,CORO1A +1299,KIF2C +1300,CEP152 +1301,NOLC1 +1302,GMNN +1303,UFC1 +1304,SEMA6C +1305,PIK3R4 +1306,SLC39A8 +1307,MMP2 +1308,FSCN3 +1309,GARRE1 +1310,CEBPD +1311,DUSP11 +1312,SH3D21 +1313,HOOK2 +1314,CLSTN1 +1315,PHOSPHO1 +1316,CRK +1317,NTHL1 +1318,GEMIN5 +1319,SESN2 +1320,ETFDH +1321,FAHD1 +1322,PLAGL1 +1323,CCND3 +1324,CRYZ +1325,ARHGAP1 +1326,IKBKE +1327,SNRNP35 +1328,ARNT +1329,RNF32 +1330,JAK1 +1331,LBR +1332,GHR +1333,BLMH +1334,CELSR3 +1335,LYPD6 +1336,TRIAP1 +1337,KALRN +1338,MPZL1 +1339,FPGS +1340,THADA +1341,LGMN +1342,CDK5 +1343,EGF +1344,SMURF2 +1345,HSPA8 +1346,TCERG1 +1347,DHODH +1348,FKBP14 +1349,MET +1350,POLR3G +1351,SNX7 +1352,SCAMP2 +1353,SDHA +1354,CCDC62 +1355,SHC1 +1356,GFOD1 +1357,COPZ2 +1358,GSTZ1 +1359,ZWILCH +1360,CRBN +1361,HMOX2 +1362,URB2 +1363,NUDT17 +1364,EPHB4 +1365,CAPG +1366,DSG2 +1367,TMEM106A +1368,PAPLN +1369,LIPA +1370,SLC46A2 +1371,HDAC11 +1372,MIXL1 +1373,MICALL1 +1374,TXLNA +1375,ARHGEF33 +1376,DMTF1 +1377,CDC20 +1378,IQGAP1 +1379,CDC25B +1380,PSMF1 +1381,RSBN1 +1382,RSRC1 +1383,PARP2 +1384,MRPL36 +1385,GNAS +1386,PCCB +1387,S100A13 +1388,MMEL1 +1389,MAF +1390,HOMER2 +1391,NUDT1 +1392,ETFB +1393,RALA +1394,PIK3AP1 +1395,TNFRSF8 +1396,TUBD1 +1397,ADAM10 +1398,RELN +1399,PARP9 +1400,IVD +1401,RPIA +1402,SRSF6 +1403,CRYBB1 +1404,GOLT1B +1405,TEX30 +1406,DENND1B +1407,KPNB1 +1408,E2F2 +1409,ATXN7L3 +1410,ELMOD2 +1411,GFUS +1412,TOR2A +1413,G6PC3 +1414,TFF3 +1415,OSBPL5 +1416,FRMD6 +1417,UBXN7 +1418,PAF1 +1419,SLC25A1 +1420,ARHGAP9 +1421,RXRA +1422,TRIB1 +1423,RGMB +1424,RBP4 +1425,SGCB +1426,NES +1427,GUK1 +1428,DTL +1429,QARS1 +1430,HEATR1 +1431,IQGAP2 +1432,MFSD3 +1433,HERPUD1 +1434,ZNHIT6 +1435,CDCA4 +1436,ZMYM2 +1437,LIG1 +1438,HCAR1 +1439,APEX1 +1440,DDIT4 +1441,PDLIM1 +1442,DDX42 +1443,CORO1B +1444,TKT +1445,ACSL3 +1446,ETV1 +1447,PCNA +1448,PSMB5 +1449,SERTAD1 +1450,NKIRAS1 +1451,NLRC4 +1452,SAMD4B +1453,AKT3 +1454,PDCD11 +1455,IL2RA +1456,BLTP2 +1457,CDS2 +1458,MAT2A +1459,FAM110A +1460,BBS4 +1461,PAN2 +1462,IFIT1 +1463,ST6GAL1 +1464,SYMPK +1465,NOSIP +1466,INPP4B +1467,GNA15 +1468,FSTL1 +1469,ABCB4 +1470,MAPK7 +1471,ICAM3 +1472,NUDT9 +1473,ALOX12B +1474,ABHD4 +1475,ROR1 +1476,GABARAPL2 +1477,BRD10 +1478,TNKS +1479,PKD1 +1480,PAK1IP1 +1481,ASB2 +1482,EPHB2 +1483,TARS3 +1484,MME +1485,PACSIN3 +1486,ZBTB7C +1487,STK11 +1488,PIM1 +1489,CTU1 +1490,PLEKHM1 +1491,HS2ST1 +1492,BMP2K +1493,CYTL1 +1494,HIVEP1 +1495,GRIP1 +1496,LMTK3 +1497,CXCL6 +1498,RIPK1 +1499,RRS1 +1500,PDE7A +1501,CDC7 +1502,SMARCD2 +1503,SLC8A1 +1504,LIMS1 +1505,ELOVL7 +1506,VPS28 +1507,MFAP5 +1508,PNPLA6 +1509,GNA13 +1510,CLEC4D +1511,ZDHHC2 +1512,ENTPD8 +1513,DUSP3 +1514,CERK +1515,FUT1 +1516,LANCL1 +1517,ANKRD10 +1518,MGMT +1519,NAB1 +1520,NDUFAF2 +1521,IGHMBP2 +1522,SERTAD2 +1523,METTL3 +1524,ATP6V0B +1525,ANKRD40 +1526,S100A16 +1527,IFIH1 +1528,CEL +1529,KCTD13 +1530,LRRC1 +1531,ARHGAP25 +1532,DYNLT4 +1533,ZNF530 +1534,CCDC116 +1535,MADD +1536,ERI1 +1537,DDX1 +1538,GPBP1L1 +1539,DNER +1540,FSD1 +1541,CLMN +1542,ENTPD2 +1543,MACF1 +1544,MTX3 +1545,EHD3 +1546,BPHL +1547,ZNF473 +1548,AKR7A2 +1549,ZNF330 +1550,PPOX +1551,GLOD4 +1552,HDC +1553,GRPEL1 +1554,CHST14 +1555,CYB5B +1556,F12 +1557,RAB11FIP2 +1558,IPO7 +1559,ZDHHC13 +1560,SDHB +1561,COG2 +1562,MESP2 +1563,ID2 +1564,SPTAN1 +1565,GALR2 +1566,MYBPC2 +1567,GEMIN7 +1568,TESC +1569,ZNF426 +1570,SMARCA2 +1571,PYGB +1572,AGRN +1573,PTPN12 +1574,NR3C1 +1575,TRIM13 +1576,AK7 +1577,MED19 +1578,NTRK1 +1579,LRRC66 +1580,DIXDC1 +1581,RIC3 +1582,AAAS +1583,NEU1 +1584,AMFR +1585,HIPK2 +1586,RBM45 +1587,PAQR9 +1588,FANCM +1589,NUP153 +1590,SNX17 +1591,MPEG1 +1592,SGK3 +1593,WDR7 +1594,ZNF345 +1595,MMS22L +1596,DDB2 +1597,VPS41 +1598,NCALD +1599,ORMDL1 +1600,ZNF462 +1601,NRXN2 +1602,ZKSCAN3 +1603,TYMS +1604,CDK5R1 +1605,OXSR1 +1606,SLC16A1 +1607,TLCD3A +1608,SLC6A13 +1609,S1PR1 +1610,ARHGEF2 +1611,RSPH3 +1612,SLC25A36 +1613,TFAP4 +1614,CCDC102A +1615,APCDD1 +1616,RTP4 +1617,ATF6 +1618,FBN2 +1619,STON2 +1620,CSF2RB +1621,ZC2HC1C +1622,GJC2 +1623,SMNDC1 +1624,MYBL1 +1625,CLTB +1626,PPP1CB +1627,ABCC4 +1628,HDAC2 +1629,PSIP1 +1630,PALB2 +1631,PPP1R3G +1632,NCOA3 +1633,PBX1 +1634,USP14 +1635,TPM1 +1636,ROCK1 +1637,MBOAT7 +1638,POR +1639,GATA2 +1640,ELAVL1 +1641,DCBLD1 +1642,CA12 +1643,ZNF274 +1644,DCUN1D4 +1645,PELP1 +1646,RABEP2 +1647,FCHO1 +1648,ROM1 +1649,ZBTB46 +1650,ATP2C1 +1651,EFCAB14 +1652,DNAJA3 +1653,SYT11 +1654,ACTA1 +1655,ZMIZ1 +1656,TERF2IP +1657,TRIB3 +1658,CPSF4 +1659,STK25 +1660,CRTAM +1661,BAG3 +1662,RHOH +1663,ATP6V0A1 +1664,STK10 +1665,MRPL40 +1666,PRKCQ +1667,DUSP22 +1668,NISCH +1669,TENM2 +1670,CDC25A +1671,THAP11 +1672,CDYL +1673,BCL2L12 +1674,ACVR1C +1675,CDK6 +1676,GCLC +1677,SLC11A2 +1678,NTRK2 +1679,SYNGR2 +1680,SDC4 +1681,DRAP1 +1682,TMEM132A +1683,FREM1 +1684,SLC1A4 +1685,NDUFA3 +1686,PHLDA1 +1687,ATG5 +1688,PENK +1689,CDC42 +1690,KRAS +1691,ZNF589 +1692,PHGDH +1693,SNRPD1 +1694,HAL +1695,TIMELESS +1696,DHX9 +1697,RBM6 +1698,ZBTB20 +1699,RAP1A +1700,FOS +1701,WDR77 +1702,LIMK1 +1703,RRP12 +1704,ATP6V1C2 +1705,TRAPPC6A +1706,WDR1 +1707,PIK3R3 +1708,RARA +1709,GALE +1710,SWAP70 +1711,CXCR4 +1712,TSKU +1713,TMCO1 +1714,PLK3 +1715,AGTPBP1 +1716,KIAA0753 +1717,ARVCF +1718,PDGFRB +1719,COG7 +1720,GET1 +1721,COX5A +1722,CMPK1 +1723,CNOT2 +1724,CSDE1 +1725,RELB +1726,CEACAM19 +1727,MTOR +1728,CHMP5 +1729,KLK2 +1730,ARL4C +1731,PRRC2B +1732,ZNF347 +1733,PSMB7 +1734,SLC16A3 +1735,FBXL12 +1736,SMC4 +1737,SWT1 +1738,FBXO34 +1739,ANO10 +1740,ZNF35 +1741,HS3ST5 +1742,SYNM +1743,MAP4K4 +1744,ABTB1 +1745,AXIN1 +1746,GATAD1 +1747,GADD45B +1748,SCAP +1749,SNX13 +1750,RAC1 +1751,CCDC102B +1752,ERCC1 +1753,FAM151B +1754,KCND3 +1755,TMEM198 +1756,GNA11 +1757,CD40 +1758,HKDC1 +1759,TMEM106B +1760,BARD1 +1761,FUCA2 +1762,CYP2S1 +1763,ZKSCAN5 +1764,HYOU1 +1765,SMARCA4 +1766,SMU1 +1767,MAP2K2 +1768,RNF207 +1769,IDUA +1770,PWP1 +1771,KANSL1L +1772,MYC +1773,GLRX +1774,KIT +1775,CCL28 +1776,POLDIP3 +1777,PCDHB15 +1778,F11R +1779,MAP4K2 +1780,STXBP2 +1781,NR4A2 +1782,EFTUD2 +1783,RRP1B +1784,ZHX3 +1785,CCDC86 +1786,ATG16L1 +1787,RFPL2 +1788,JMJD6 +1789,SRSF1 +1790,LAP3 +1791,SMARCC1 +1792,SDC1 +1793,KTN1 +1794,CCNH +1795,CREB1 +1796,CAB39 +1797,EIF4EBP1 +1798,RAI14 +1799,SYNGR3 +1800,GLS2 +1801,BIRC2 +1802,ATXN7L3B +1803,H2AZ2 +1804,EFCC1 +1805,ASPH +1806,ARHGEF18 +1807,NMNAT3 +1808,ATAD2 +1809,PRLR +1810,COL1A1 +1811,CPEB2 +1812,TSPAN9 +1813,PRKDC +1814,RHOA +1815,FZD7 +1816,COASY +1817,PSME1 +1818,NOP10 +1819,FDFT1 +1820,SEMA4F +1821,IL4R +1822,SIRT3 +1823,TRAP1 +1824,VGLL4 +1825,ST3GAL6 +1826,PRKCA +1827,NFKBIA +1828,LTBP3 +1829,MRPL23 +1830,RALGDS +1831,XPO7 +1832,MOXD1 +1833,ZNF789 +1834,MIEN1 +1835,SHH +1836,PTPRC +1837,SAMD15 +1838,ERGIC1 +1839,ZNF736 +1840,SOX15 +1841,MYOZ3 +1842,DYSF +1843,SLC2A1 +1844,METAP2 +1845,LAYN +1846,SEC24C +1847,MLEC +1848,BNIP3 +1849,CCP110 +1850,COPS7A +1851,RAB11FIP3 +1852,CDK1 +1853,TSN +1854,TP53INP2 +1855,STAB2 +1856,ARID5B +1857,SRSF7 +1858,MAU2 +1859,SPAG7 +1860,RRP8 +1861,EPN2 +1862,ZBTB9 +1863,SLC30A5 +1864,TAF4B +1865,TOPBP1 +1866,STK4 +1867,YWHAZ +1868,CYB561 +1869,SORBS3 +1870,WIPF2 +1871,CPPED1 +1872,JAK3 +1873,CD320 +1874,PTPN6 +1875,SLC25A32 +1876,TRIM66 +1877,ZFP36 +1878,MEX3B +1879,FRZB +1880,WDR70 +1881,KCNN3 +1882,ITK +1883,TM7SF3 +1884,RNF168 +1885,CRY2 +1886,CCDC170 +1887,PRMT5 +1888,POU4F1 +1889,ACTR1A +1890,NPC1 +1891,PGM1 +1892,INCENP +1893,PIN1 +1894,MAPKAPK5 +1895,ZNF471 +1896,CDH9 +1897,MANBAL +1898,GABPB1 +1899,POLR2I +1900,ZNF3 +1901,PIAS1 +1902,GPR63 +1903,NFKBIB +1904,NEFH +1905,GATA5 +1906,HTRA1 +1907,ADA +1908,QSOX1 +1909,ZNF415 +1910,LGR4 +1911,CHAC1 +1912,SEC31B +1913,CDK2 +1914,LTK +1915,MAPK14 +1916,EIF2AK3 +1917,TIAM2 +1918,NUCB2 +1919,AURKB +1920,MARCHF3 +1921,SORCS2 +1922,RASD2 +1923,GBP2 +1924,TENT4A +1925,UBQLN4 +1926,IFNAR1 +1927,FAM118A +1928,ENOPH1 +1929,POLE +1930,TTPAL +1931,NUSAP1 +1932,ELAC2 +1933,PRKCD +1934,NR1H3 +1935,MESP1 +1936,RIN2 +1937,SERPINB1 +1938,RGS9 +1939,SLC15A3 +1940,UBE3A +1941,DOCK8 +1942,CCDC34 +1943,TNFSF11 +1944,TMEM179B +1945,CEBPZ +1946,ZNF92 +1947,POLG +1948,ACVR2B +1949,ANXA4 +1950,SLC23A2 +1951,CP +1952,EDEM1 +1953,ANGEL2 +1954,CD2BP2 +1955,DHTKD1 +1956,FYN +1957,MUC1 +1958,EID2 +1959,HNRNPA3 +1960,MRPL9 +1961,INCA1 +1962,DNMT3A +1963,ATG3 +1964,IGF2BP2 +1965,TG +1966,TMPRSS9 +1967,KAT6B +1968,SUPV3L1 +1969,HOXA11 +1970,PRKAA1 +1971,SPRTN +1972,RPS18 +1973,DDX18 +1974,PARPBP +1975,CDH3 +1976,TOR1A +1977,PLA2G1B +1978,SFN +1979,NGFR +1980,LLGL2 +1981,DAG1 +1982,ZNF135 +1983,IFT172 +1984,B4GALT4 +1985,LSM6 +1986,BECN1 +1987,NUAK2 +1988,DHX32 +1989,RND2 +1990,TGFB1 +1991,NOTCH1 +1992,EBAG9 +1993,REEP5 +1994,KDM5B +1995,FAM114A2 +1996,KLF10 +1997,TEX10 +1998,MPC2 +1999,ZNF404 +2000,DCTD +2001,SYNE2 +2002,ZNF502 +2003,FLRT1 +2004,ERMN +2005,NUP88 +2006,PDE4B +2007,SSH1 +2008,FAM204A +2009,MAP1A +2010,C5 +2011,PPARG +2012,HELB +2013,CALM3 +2014,CARD11 +2015,DENND4B +2016,VSTM1 +2017,FASLG +2018,ASRGL1 +2019,MORC2 +2020,ZNF284 +2021,ARHGAP32 +2022,EMC7 +2023,RPS6KA1 +2024,SLC38A11 +2025,MYBL2 +2026,CASC3 +2027,MAGI1 +2028,RAP1GAP +2029,ARSI +2030,EFCAB5 +2031,CD300A +2032,SPA17 +2033,NPHP3 +2034,HAT1 +2035,MPZL3 +2036,PEPD +2037,KCTD6 +2038,ATP2A2 +2039,KIF11 +2040,CBR1 +2041,ATMIN +2042,ATOH8 +2043,STIM1 +2044,FYCO1 +2045,ABCC5 +2046,PRPF4 +2047,FOXJ3 +2048,GIMAP6 +2049,NIT1 +2050,RBM43 +2051,ASCC3 +2052,FJX1 +2053,STMN1 +2054,TNIP1 +2055,PHF12 +2056,CHN2 +2057,FUCA1 +2058,MEST +2059,TNNI3 +2060,NUAK1 +2061,SLC36A1 +2062,ACP5 +2063,GDA +2064,ILK +2065,BLCAP +2066,FOXN2 +2067,GLIS2 +2068,NMT1 +2069,GTPBP8 +2070,STK11IP +2071,NEBL +2072,TIMM9 +2073,DAPK3 +2074,GSR +2075,PAFAH1B3 +2076,KPNA4 +2077,CRCP +2078,RPN1 +2079,FAT1 +2080,LRRC40 +2081,FIS1 +2082,USP6NL +2083,DGAT1 +2084,ZFC3H1 +2085,MCM3 +2086,TTC39C +2087,FGFR1 +2088,YME1L1 +2089,LETM1 +2090,CCDC73 +2091,PPP2R2D +2092,WNT5A +2093,PSCA +2094,HDAC3 +2095,KLF12 +2096,ZNF414 +2097,TOP1 +2098,BCLAF1 +2099,ARHGDIB +2100,TRPV4 +2101,SNCA +2102,ITGB5 +2103,HDAC9 +2104,CHMP2B +2105,NR2C2 +2106,FAH +2107,HINT2 +2108,GSDMB +2109,SF3A1 +2110,BUB1 +2111,RAB33B +2112,SOCS2 +2113,SOX8 +2114,ITGB2 +2115,RGS2 +2116,JUN +2117,HK1 +2118,ZBTB43 +2119,NFIL3 +2120,PHKB +2121,APP +2122,DVL2 +2123,SPATA32 +2124,L3MBTL3 +2125,SLC25A37 +2126,NFKB2 +2127,MAP3K8 +2128,DUSP14 +2129,OXA1L +2130,ELL2 +2131,UCHL5 +2132,AARS1 +2133,KCNG1 +2134,SLC22A5 +2135,PLCH2 +2136,SEMA3D +2137,AXDND1 +2138,HIVEP2 +2139,CLIC4 +2140,SPEN +2141,EAPP +2142,CAPN1 +2143,RPRD1B +2144,CCNB1 +2145,PTGDR2 +2146,BRD3 +2147,PIM3 +2148,AFG2B +2149,CAT +2150,PLXNA2 +2151,GAA +2152,BRD9 +2153,IGSF9B +2154,SACM1L +2155,CEACAM4 +2156,GJA4 +2157,USP16 +2158,CREBBP +2159,ADCY3 +2160,UBR2 +2161,UBE2C +2162,CASP2 +2163,SMCR8 +2164,GTF2F2 +2165,CHMP1A +2166,ACD +2167,DFFB +2168,UBR7 +2169,NEXN +2170,HSPD1 +2171,DDX10 +2172,DDX31 +2173,BEND4 +2174,FNTA +2175,ACLY +2176,TCTN1 +2177,MIER1 +2178,AKR1E2 +2179,PTPRF +2180,CEP350 +2181,OSBPL3 +2182,BLTP3B +2183,PDK1 +2184,GDE1 +2185,CRTAP +2186,DOT1L +2187,ATP10A +2188,SLC27A3 +2189,FARP2 +2190,CSNK1A1L +2191,PIGM +2192,RNH1 +2193,EPRS1 +2194,FLT3 +2195,EEIG2 +2196,NUMBL +2197,SDCCAG8 +2198,ACBD5 +2199,NOS3 +2200,USP22 +2201,FXN +2202,PEX2 +2203,DHDDS +2204,MFAP3L +2205,VWDE +2206,SLC29A3 +2207,RRAGA +2208,GPR137 +2209,PDGFA +2210,CLECL1P +2211,TLCD3B +2212,KDR +2213,CCDC122 +2214,GFOD2 +2215,AKAP8L +2216,PLA2G4A +2217,PRMT6 +2218,MELK +2219,RFNG +2220,GGH +2221,VEZF1 +2222,GPRC5C +2223,SLC25A30 +2224,REC8 +2225,MKNK2 +2226,RANBP3 +2227,CEP57 +2228,BCL2L1 +2229,HCLS1 +2230,CHST1 +2231,SLC16A7 +2232,WASF3 +2233,IPO13 +2234,VENTX +2235,MMP1 +2236,ACADVL +2237,ST7 +2238,TTK +2239,LAMA3 +2240,FLT1 +2241,TJP1 +2242,PLCB3 +2243,BACE2 +2244,PPP1R13B +2245,SSRP1 +2246,KLHL21 +2247,BRD8 +2248,SH2B3 +2249,CLEC4E +2250,AKT1 +2251,CAD +2252,ARID4B +2253,CHD9 +2254,PTPRS +2255,ETV3 +2256,LYRM1 +2257,PUF60 +2258,GFPT2 +2259,F3 +2260,MAPK1IP1L +2261,CXCL2 +2262,SREBF1 +2263,POC5 +2264,CHMP6 +2265,SERINC5 +2266,NVL +2267,WDR27 +2268,IMP3 +2269,PCGF3 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/gene_list_paccmann_network_prop.csv b/drevalpy/components/featurizers/cell_line/gene_lists/gene_list_paccmann_network_prop.csv new file mode 100644 index 000000000..29e4b4b25 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/gene_list_paccmann_network_prop.csv @@ -0,0 +1,1958 @@ +Symbol +HDAC1 +FLACC1 +BFAR +ZCWPW1 +ZP1 +PDZD7 +S100A4 +RALY +RABL3 +ZNF490 +MRPL9 +DNAH14 +OR51S1 +SYNJ2 +PRDX6 +BAAT +PTPN11 +UBE3B +DNAJC22 +NCOA2 +AMFR +TMEM229B +RBM45 +ZNF502 +FZD1 +CELSR3 +NR4A3 +PDGFRB +ALAS1 +REPS1 +CCL20 +DDX31 +CCDC102A +PSG9 +DIRAS1 +ATG12 +TNKS2 +TNFRSF9 +MAGI1 +GPR160 +ASB2 +MRPL23 +AURKC +INO80D +TFB2M +TOR1AIP1 +GABRG3 +LEFTY1 +SRGAP1 +PRKRIP1 +PCBP2 +GCFC2 +GUCA1C +NBL1 +SURF6 +TEAD3 +CYP2S1 +SORBS3 +TGFB1I1 +OSGIN2 +TERF2IP +PDE7A +SRC +ANXA4 +GPR146 +BPHL +ZNF556 +AIM2 +ARHGAP25 +BRIP1 +TFAP4 +CLK4 +PROM2 +DLK2 +UQCR11 +SLC35F1 +AXDND1 +OR4C15 +LIN28B +SLC2A9 +SLC49A4 +NUP153 +ZNF333 +PARP1 +GRPEL1 +ADRA1D +SVOP +PLEKHA6 +NEFH +TTC17 +ISL2 +ATOH8 +C1RL +TEX19 +ZSCAN22 +SAMD5 +MRPS26 +NANOGNB +CNOT10 +FEM1B +GTF2F2 +OR51E1 +HKDC1 +IRX5 +TTC8 +CLEC4D +CLEC4E +ADRB3 +UBTF +MYBL1 +CDKN2AIP +NCAM1 +PACS1 +IFT57 +MYLK +KCNG4 +CAPN10 +YBX2 +GIGYF1 +ZC3HAV1 +CSDE1 +MEGF11 +GPBP1L1 +UGGT2 +USP4 +PDK1 +LMO1 +IQUB +MLXIP +AKT3 +LTBP3 +IDH2 +THADA +KCNT1 +PAK6 +IMPA1 +SDCCAG8 +SLC25A18 +PIGM +CYB5B +MARK4 +ARVCF +ALG8 +MAPK8 +PRMT5 +SNX22 +IVD +BNC2 +SSRP1 +RANBP3 +STIM1 +ZNF202 +RNF170 +PGM2 +GPR45 +KRTAP5-6 +CIB3 +CPXM2 +ZNF30 +GIPC2 +ATP10B +ASCC2 +SDHA +XKR6 +L3MBTL3 +NPVF +AFG2B +MROH1 +NELL2 +LDHAL6B +PTN +CYP26B1 +CLUL1 +EXTL2 +STEAP3 +ECHDC2 +BLNK +LRRC55 +AFAP1L1 +KANK1 +MPZL3 +CCDC136 +CRY2 +ELF2 +GDE1 +MYBPHL +ACSM4 +TNKS +CDIP1 +FUCA1 +NPBWR2 +CCDC102B +FTMT +BEND4 +TG +CCDC122 +AGTPBP1 +OSBPL5 +NPLOC4 +NEBL +FAIM2 +OR10Z1 +ADAM15 +SLC2A6 +G6PC3 +CDK5R2 +CST1 +SAMD15 +DHX32 +TNFSF11 +LGR4 +CMPK1 +BRAF +MED13 +PSMA8 +DNAJB5 +RHBDF1 +CARD6 +IER3 +IQSEC3 +FAHD1 +RPP25L +GATAD1 +NDUFA2 +EHMT1 +HAS1 +C1QA +SDR9C7 +MAU2 +LANCL1 +ZNF330 +SRSF12 +RIT1 +RBP7 +ALK +GFOD2 +NTN4 +ZNF619 +PLCXD3 +EDNRA +BMP2 +CRTAM +GUCA2B +TRPM5 +PTK2B +TARS3 +SCAMP2 +CPEB2 +SDC4 +NDN +ARFGAP1 +TRPC4AP +MOK +TRPV4 +PSMB7 +EPAS1 +TRUB1 +TRAF2 +MPP4 +IL20RA +SIRPB2 +SART1 +LIMD2 +TEKT1 +ZCCHC17 +FXN +ARNT +PAQR9 +GRIK3 +ARIH2 +FAM76B +PIM1 +CAMP +NDUFA13 +SMARCB1 +ILDR1 +CASP3 +UFC1 +HOXA7 +ANK1 +CYP1A2 +PACSIN3 +SPATA12 +TMED4 +MAPK7 +TMEM50B +CDK4 +TPH2 +KLHL23 +STARD7 +STK4 +DNER +SNX20 +CALCA +HNRNPA3 +NALCN +TWSG1 +PSMB5 +SNRNP25 +HMOX2 +CXCL6 +CAMK1G +TMEM53 +NNAT +C9 +CELF4 +F13B +LTK +NR2C2 +TMEM151A +SLC10A6 +GP6 +HAO2 +BHMT +MCHR1 +PSPH +CCDC62 +AP5M1 +HRG +HDAC5 +SERPINA6 +KTN1 +RBL2 +ACTR3B +GRIP1 +WDR75 +PAK1IP1 +GNA13 +MON2 +HOXC13 +SRSF1 +ABCB4 +PSMF1 +NAB2 +SLC39A8 +DNAJC2 +STK10 +KCNE4 +ENPEP +BID +TRIM45 +SCCPDH +GSK3B +ZDHHC19 +RNF149 +CEP152 +PRMT6 +ABHD6 +SERINC5 +RAB6B +CKAP4 +ADAM10 +OR4K13 +TBX18 +FREM1 +PRDM10 +URB2 +GRB7 +ABCA8 +S100A7L2 +FLT3 +SLC27A5 +POLR3A +HDAC2 +OR2J3 +MYT1L +UNC13D +SYNGR2 +LPXN +MTOR +BMP2K +RAB33B +PRKCQ +VWC2 +AGRN +RILPL1 +MACF1 +CEP97 +POR +LPL +GALNT13 +PTPRS +SPIC +AP2A1 +GATA5 +SASS6 +PEX6 +DOCK8 +SUPT16H +IGFL3 +FNTA +APP +ASTN1 +PHOX2A +CYP27C1 +RPUSD3 +CHMP6 +MYH4 +ARSI +RDH8 +ARHGAP28 +EHD3 +NUDT1 +YY1AP1 +PRKAA2 +PRMT7 +GNG8 +RBM18 +HDAC7 +KLK7 +CCDC116 +CEL +HGF +HDAC9 +SCYL2 +PSKH2 +IFIT1 +SERPINB1 +PIK3R5 +AASDH +ZBTB22 +CD2BP2 +TIE1 +GTPBP1 +STX3 +UCHL5 +BPIFB1 +PRKCA +IL6ST +MANBAL +S100A16 +ZKSCAN5 +TBX20 +KLF12 +HEPACAM +CAMK2B +DDX1 +ATAD5 +PAX2 +RNF208 +PHKG2 +VPS41 +CLCN2 +CSDC2 +LETMD1 +PMM2 +KRTAP20-4 +SLBP +SSH1 +RAB7A +SLC38A7 +DNAI2 +SGK3 +WDR5 +IPO7 +BRDT +MS4A12 +ATRN +TMEM33 +F12 +RAD51D +RIMS4 +TAS2R42 +ACP5 +GALR2 +CTNNA3 +SNAPIN +SLC16A14 +PIAS1 +CHKA +MIXL1 +ROCK2 +TTC39C +CTCFL +CHD9 +RXRB +LRRC32 +ATP7B +MET +ST6GAL1 +PTH2 +CRIP3 +MFAP3L +SUV39H2 +NDUFB1 +PARVB +BARX1 +CYP1B1 +SLC4A11 +KRTAP13-3 +QSOX1 +DTL +FBXL3 +DHTKD1 +ZBTB7C +CIMAP1D +SLC27A4 +TRIM66 +TOR4A +ELOVL4 +ZNF471 +LYPD3 +LONP2 +MAPK10 +CSF1R +CASP7 +MOSPD3 +OR5D14 +CDK6 +YWHAZ +TCIRG1 +GPX6 +KPNA7 +SPO11 +PPIL1 +EIF2B5 +ARNT2 +USP45 +SPA17 +RPRD1B +EAPP +NRG3 +PROK1 +LCK +MOAP1 +SHROOM3 +TMCC2 +STEAP2 +ENTPD2 +HELB +GABRR1 +DDIT4L +CYTL1 +SLC8A1 +COPZ2 +OR6N2 +SNX2 +TTK +PARP2 +HOMER1 +ZBTB44 +RAB40B +SNX7 +CPPED1 +PIK3CG +FLRT1 +HEG1 +TTC19 +PLG +AEBP1 +PPA2 +MSRB3 +CPOX +GBP2 +LEP +OPRK1 +CCNE1 +TMEM132B +COX5B +NTRK1 +PMP2 +ACTR6 +GNG5 +AKR7A2 +FGFR3 +SUPV3L1 +SLC29A2 +USP6 +ATF2 +MRPL22 +DCTN5 +UBQLN1 +PTK2 +ACSM2A +SLC16A7 +MAF +SYK +UQCC5 +JAKMIP2 +IAPP +SYMPK +NCALD +SLC10A7 +PYCARD +COX5A +CP +NDUFA3 +ULK2 +CAPN1 +SLTM +HINT2 +TSC22D1 +KANSL1L +RASSF4 +NTRK2 +TMBIM6 +BTRC +ARF4 +LRRTM1 +AK5 +OVOL1 +DMXL1 +FAM204A +ZNHIT6 +RXFP3 +CYB5R4 +HIPK2 +TNFRSF13B +DNMT1 +NAT2 +MYADM +FSCB +TOP1 +INCENP +TRIB3 +HDAC11 +LGALSL +CD300A +SRSF6 +PNLIP +TRA2B +RARA +SERTAD1 +PTER +PIKFYVE +OSTN +RHD +MPEG1 +DENND4B +HIVEP1 +TSPAN9 +TSEN2 +MYL6 +NAMPT +XAB2 +EYS +CAD +IGHMBP2 +TJP1 +NTF3 +CEACAM4 +MAP4K2 +GDA +NTRK3 +OR2F2 +KLK1 +ATP6V1E2 +PPT1 +HHLA2 +MRPL18 +KCTD6 +MMEL1 +RAB11FIP3 +FGF18 +TRIM55 +CCR4 +CDKN2AIPNL +WDR27 +DYDC2 +TBK1 +ASAH1 +ATP6V1G1 +SNRPD1 +KCNK18 +ANGPTL6 +KIF11 +WDR70 +BST2 +SIX4 +KRT85 +SMTNL2 +PPARG +OR8B12 +CDK7 +IFNB1 +HUNK +PROX2 +SPATA31D1 +GCLC +EIF2A +ZBTB14 +ODAD2 +NPRL2 +RHBDD3 +PCDHB12 +RASD2 +NCOR2 +IFIH1 +SLC2A1 +SETX +BARD1 +LRP4 +EPHA7 +SNRNP35 +FMO2 +STRA8 +CREG1 +AK7 +KCNJ3 +PTPRM +TPPP2 +QARS1 +BBS4 +ZBTB46 +DRAXIN +DCLK1 +KATNIP +ZNF626 +ZNF107 +ZNF77 +MFSD11 +DCST2 +IQCA1 +PIM3 +ESYT2 +SRP54 +PKIB +DLEU7 +MTHFD1L +BRD3 +CCDC112 +UNCX +HCAR1 +GIMAP6 +CNOT2 +FBXO34 +GLYR1 +IGF1R +MT1H +TUBA1C +VWA5B1 +TAAR8 +PYY +RSBN1 +SEC24C +ZNF543 +ZKSCAN3 +DECR2 +ZNF189 +TTC32 +SIVA1 +ZNF280D +SNX17 +ZNF415 +SLC16A3 +DPP8 +PCOLCE2 +MAP2 +PRRX2 +PDE4B +TSN +ARHGEF4 +PARP9 +CHST11 +SLC25A37 +FAM118A +FAM114A1 +ANKRD28 +TMEM235 +ZFP36 +TMPRSS9 +ZHX3 +WDR24 +IFI27L2 +SRP68 +PDGFRA +PIK3AP1 +TP53RK +TTBK1 +FAM114A2 +ATP10A +FOXN2 +GALNT8 +ST3GAL6 +AFMID +INCA1 +RNF32 +MDFIC +GPR137 +LY6H +IGSF9B +DUSP4 +ATN1 +ROR1 +MANEA +CTRC +HPSE2 +PSCA +KDM4B +SNX21 +LPIN2 +MAST4 +FUCA2 +IL37 +SMU1 +TOR3A +SLC24A2 +VCL +FSCN3 +IGFBP5 +BCL2 +RGS9 +SULF1 +TBXA2R +SENP1 +APLP2 +PAPLN +HIF1AN +CD164L2 +CACNA1E +DGAT1 +PIK3C3 +GGPS1 +ACTA1 +ADCY10 +DOCK4 +OSBPL3 +PIK3CD +ZKSCAN2 +BCR +LRRC66 +SLC46A2 +SHD +GCG +PAPSS2 +PPIA +FGFBP3 +AMIGO1 +UBASH3B +COMMD1 +CDC123 +BRF2 +ZNF468 +ZNF195 +AKT1 +PLK3 +SETD1A +KNDC1 +ZNF697 +FGF22 +BUB1 +CDK1 +LARP4 +COPZ1 +POLG +PCNA +ASPH +CEACAM19 +AURKB +KCNH7 +ARHGAP9 +CHMP5 +ABCB5 +EGFR +SERPINE1 +DMBT1 +KCND3 +LRRC1 +GCNT1 +FGFR1OP2 +PPM1D +ADA +GJB4 +TMEM86A +IL9 +MED23 +ATR +HSD17B14 +NFKBID +TEX30 +GNA11 +ATM +SF3A1 +ABCA5 +CRYBB1 +FAM151B +PHF1 +DTX2 +CARD11 +DDX6 +F3 +MADD +SMAD4 +HSPA2 +MAST3 +BCL9L +NBEA +NAALAD2 +APCS +ATXN7L3B +EXOC3 +HOXA11 +MDM2 +NR4A2 +ZNF704 +MS4A5 +TOR2A +TNNI3 +S100A8 +WBP11 +GCKR +LAMP3 +TARBP1 +VENTX +RPS6KA1 +TMEM106A +MEGF8 +KANSL1 +REC8 +ZNF347 +GHR +SLC39A10 +NKIRAS1 +FGFR2 +ITGAE +CRB1 +PDE5A +WDR77 +DEPDC4 +BLTP3B +BCAR3 +B4GALT4 +PENK +TAFA2 +GRIA4 +ABCC8 +ZDHHC5 +MYO18A +ARHGAP44 +LMO4 +TMEM170B +LRP2 +EBF2 +POU4F1 +FCGR2A +WIF1 +HIPK4 +GCC1 +KLHL11 +CCDC73 +MAPK1 +PDZD9 +SUGT1 +ARID3C +NLRC4 +ENO2 +CYP2A13 +OR1G1 +INSL5 +SORCS2 +GLRX5 +LETM1 +MAPK3 +SLC38A11 +CRBN +FKBP1B +GADD45B +NES +FGF11 +PKP4 +FJX1 +AMER2 +FAM135B +AURKAIP1 +VSTM1 +SEMA4F +GPR152 +RABEP2 +NFATC2 +NACC1 +SESN1 +MAP1A +RNF11 +IL2RA +RNF181 +PPP1CB +FCRL4 +KCNG2 +PNPLA6 +SUPT20H +F2 +LSG1 +MYL5 +NPW +MYCBP2 +KLHL3 +NR1H4 +HSPA5 +POLDIP3 +CIAO3 +NUDT5 +HOXA13 +ASB8 +TAP1 +ZNF92 +ODF4 +CEP350 +ZNF35 +POC5 +RIN2 +STARD4 +ST7L +LORICRIN +FBXO41 +KRT72 +NOP10 +STK11IP +KIT +PRKCG +TGFBR1 +TMEM255B +APOBEC1 +ANAPC10 +WDR74 +NDUFAF5 +TM7SF3 +SLC24A3 +CSF2RB +SESN2 +FFAR2 +ZWILCH +RELN +EGLN1 +PLK1 +DSPP +ANKRD30A +GEMIN7 +FRMD1 +STK11 +CER1 +PEPD +CHUK +TENM2 +GJA4 +LRRC71 +TEX264 +ZFC3H1 +STC1 +IRX3 +TRIM7 +GH2 +ITPKB +AGAP3 +STARD3 +GPRIN1 +CYP2W1 +ZFAND1 +POLR1G +KLHL36 +KRTAP6-2 +ORAI2 +CDH15 +TMEM203 +IARS1 +FARP2 +GPRC5C +ZNF135 +ANGPT1 +PRR16 +DVL2 +ALDOB +PHF12 +PFKL +KLHL24 +BRD4 +AKR1E2 +HS2ST1 +UPP2 +LRRC58 +CLYBL +FBXO47 +PTEN +ROM1 +HEATR4 +RBP4 +PYGB +SERTAD2 +MNX1 +KDM2A +CD8A +FIGN +ACVR2B +ZBTB20 +KPNB1 +ZDHHC2 +AKAP12 +AKT1S1 +KRT3 +SPTLC2 +CSHL1 +KIF17 +HCN1 +MRPS16 +CATSPERB +CORIN +GREM1 +ZFHX2 +RAD51AP2 +SCAP +HIF3A +FHIT +CHST14 +NDUFAF2 +KIF21A +MTX3 +COG5 +DEFA5 +HRNR +LAYN +CSRP1 +CHMP2B +CDK5 +PLCH2 +YPEL2 +SLC5A9 +TLL2 +ATF7 +CREBBP +CDH1 +ROS1 +DCK +ELMOD2 +LRRC40 +ARHGEF33 +ZNF284 +DMXL2 +ZNF473 +PIH1D1 +SUGP1 +HDAC3 +CCL28 +ZHX2 +MRTFA +ALOX12B +CTU1 +KRTAP11-1 +BCL2L12 +BZW2 +TFF1 +SAMD4B +GNG2 +ACTB +DDC +FABP1 +MAP4K1 +VEZF1 +DDX18 +USP1 +STON2 +FLT4 +LSR +GABRB1 +CCSER1 +PPM1M +LRIG3 +ZNF345 +PDCD6 +PBX1 +MAP3K8 +NSMCE2 +NMT1 +GSK3A +MRPL36 +JAK3 +STRA6 +PER1 +GPAM +TONSL +CDC7 +SPRYD7 +NUMBL +ABTB1 +TEK +SLC30A10 +NMUR2 +SEMA3D +SYNM +ZNF76 +CLEC1A +R3HDML +CELF1 +NLE1 +CSTB +TIAM2 +EFCAB12 +NXPH2 +IGDCC3 +PTGDR2 +SDC1 +UBE3A +B9D1 +COLEC12 +ECHS1 +PARVA +ARL11 +PHF14 +KRT2 +SLC25A26 +CLN5 +SIRT1 +MAP2K1 +ITGB2 +MAPK11 +ERMN +POLR3G +CLECL1P +GATA6 +RRAGA +NDUFB2 +ALLC +ERGIC1 +MAN2A1 +AARD +PHOSPHO1 +TBCEL +AXL +EHMT2 +SOX15 +EEIG2 +MGAT1 +WFDC10B +ANKRD10 +ELL2 +ATG9A +KIF5C +TVP23A +TMC3 +TRIQK +EFHC1 +CLEC16A +MZT2A +THOP1 +SLC12A6 +RHBDL2 +USP32 +RNF25 +GDF5 +CHST1 +SLC22A8 +GPSM1 +NRSN1 +TBX4 +DDR1 +APMAP +SLFN13 +UBQLN4 +TRADD +SWAP70 +PEAR1 +GABRB2 +DOK2 +HEATR6 +CLPS +PHLDA1 +DEK +NRL +PTMS +OLFML3 +RAB6A +CSAD +MMP1 +ZNF3 +HYAL2 +SLC30A5 +NSMCE1 +USP38 +ZFP1 +ENO3 +MIA2 +GPR183 +BRD10 +MRPL46 +USP16 +NRXN2 +RPS18 +ASRGL1 +ZNF736 +TMEM106B +GABARAPL2 +DIXDC1 +PFN3 +DNAJC15 +VPS37A +CKAP2 +CAPG +PUS1 +SLC5A6 +NEUROG1 +OR2C3 +MOXD1 +PDP1 +GTF2B +ZRANB1 +RIC3 +MYOZ3 +MME +MDM4 +ARHGDIB +SRSF7 +KIF9 +RNF112 +IGSF21 +SLC25A38 +RET +CRCP +SLC25A20 +CATSPERD +JAK1 +TOPAZ1 +DEFB116 +ERCC5 +MORC2 +TENT4A +VNN3P +RNF168 +STAR +PLEKHG1 +FLG +PRKDC +MAPK14 +METTL3 +RTN4IP1 +MAFK +EZH1 +SLC25A1 +DENND1B +RNASE2 +ZBTB43 +TCP10L3 +NEU4 +DCTN2 +NGFR +NPHP3 +MRGBP +WDR1 +TSGA10 +ICA1L +MTARC2 +WFDC10A +DYNLT1 +CD320 +CPNE7 +GTDC1 +KNTC1 +WEE1 +VARS2 +SLC23A2 +RFPL2 +CDR2L +SEMA4A +NOL7 +PLAGL1 +KCNT2 +CCDC127 +ACAP1 +ZNF414 +USP15 +UNC45B +ZNF462 +OR10J5 +DYNC2LI1 +TOR1A +ACTR1A +HNRNPU +DEFB118 +FGFBP1 +EFTUD2 +MC4R +KRTAP22-1 +NR2C2AP +ZNF620 +ERI1 +IL17F +MUC5B +DYNLT4 +HCLS1 +RBP2 +YBEY +KLF6 +NEU1 +ZNF426 +GPR39 +SCGB1D2 +GEMIN5 +RPL30 +PPM1L +DBR1 +PALB2 +GLIPR2 +TESC +JAK2 +TPPP +APCDD1 +LLGL2 +B3GALT1 +TNFSF10 +FGFR1 +MFAP5 +GGH +KLK12 +RDH12 +ECSIT +HAPLN4 +MYL6B +SLC22A7 +UBE2C +UQCRH +CCDC34 +DENND2A +PDE1C +SYCN +SEMA6C +F11R +ICAM3 +ATP6V0A1 +RNF152 +MAP3K4 +RTF1 +RSPO2 +ATP6V1C2 +HLA-DRB5 +PRDM5 +ZNF772 +PPP2R2D +SLAMF1 +FYN +ZBTB7B +HAVCR1 +EIF1B +CHRNB1 +COL15A1 +DDX49 +RBM43 +CALCB +IGFL1 +MMP2 +FAM163A +TMEM18 +GLIS2 +CYP3A5 +UBL3 +HAND2 +CHEK1 +FBN2 +MEPE +GFPT2 +PDE3B +NOLC1 +NPM1 +MRPL40 +ADCY3 +DCUN1D2 +CTTNBP2 +DEFB134 +MS4A6E +ACADVL +TNRC6C +KCNRG +ANKRD49 +USP30 +CHN2 +BHLHE23 +NDUFAB1 +SLC25A36 +ZNF329 +RUNDC3B +ZNF781 +ABL1 +SWSAP1 +JMJD6 +DNAJB8 +BBS2 +KLK2 +ZNF600 +DYRK1A +NMNAT3 +TLCD3B +UBR2 +SLC25A45 +IQGAP2 +PRKCD +SNAP25 +MUC12 +RNF207 +RGMB +ROCK1 +PPARD +PIK3CA +TMEM38B +PHYHIP +ERBB2 +PHRF1 +DLST +IRGQ +RSRC1 +ATAD2 +TDRD7 +ITK +TMEM179B +TFF3 +FLNB +SRA1 +ETFB +IFNA8 +PLA2G1B +PARN +ORMDL1 +FBXO8 +TPGS2 +AAAS +HDAC4 +LAT2 +SEZ6L +MESP1 +RHOH +SNAPC4 +SLC15A3 +ACTRT2 +NAB1 +KCNN3 +TESK1 +HABP2 +SAR1A +EFCAB5 +NPC1L1 +PSTPIP2 +TOP2A +IKBKB +S100A1 +PELP1 +WNT5A +OXA1L +CBR3 +DCBLD1 +SLC25A30 +DDB2 +BANF2 +MTF2 +CDK2 +RTP4 +ZNF71 +CAPN9 +MAP2K2 +PLEKHM3 +RGS16 +PXMP4 +ACBD5 +OR51I2 +C1QTNF8 +FAM120AOS +TMEM132A +BPIFA1 +TMEM64 +B4GALT1 +PTPN6 +MDH1B +PLA2R1 +UGT2B15 +KRT84 +GALC +PAK1 +RD3 +ITPK1 +EID2 +RAB19 +YWHAQ +NEUROD4 +ASNS +PGAM2 +SLC32A1 +WDR47 +FAM216B +ATP6V1C1 +RGS5 +HTRA1 +GDNF +SMCR8 +PARPBP +ZNF530 +ZNF527 +ARRB1 +CCL21 +AARS2 +SREBF1 +IFT122 +NLRP5 +SH3BGRL2 +MED19 +TUBD1 +SUGP2 +DIPK1C +CCT5 +RAP1A +KRTAP1-5 +BRD2 +RRP9 +DESI1 +BRSK2 +SLC16A4 +CHMP1A +AURKA +SCUBE1 +ANKRD44 +CD82 +ZNF510 +TACO1 +RARS2 +RSPH3 +IL24 +KCNK16 +HESX1 +FAM174A +PCDHB15 +MFSD3 +PLXNA2 +NIPA1 +CES4A +ITPRID2 +FAM110A +ANGEL2 +ZNF276 +STPG1 +KCNQ4 +GPR63 +PITPNA +GSDMB +CTSB +MXD4 +STAB2 +WBP2NL +DNASE2B +TIPARP +OR2T1 +LCE1E +CASP1 +XRCC3 +ENTPD8 +ENDOV +RNF122 +PSG4 +SEC31B +KCNG1 +MTMR9 +PREB +RSU1 +AHDC1 +SPATA32 +SOCS1 +PTTG1IP +SLC22A5 +LMTK3 +TPRA1 +TMEM8B +SH2B3 +CEBPE +PRLR +ELOVL7 +LSP1 +PNMA2 +ETFDH +CCDC170 +TMCC3 +MROH9 +EMC7 +ZNF253 +FAM3D +ABCC4 +ELK4 +PPP1R3G +MCL1 +MEX3B +RPS6KB1 +ZNF7 +OPTC +GRB14 +SGK2 +ARHGAP32 +SLC35F3 +ZBTB9 +TMEM259 +SLC36A1 +KPNA4 +TTC33 +ATXN7L3 +ANKRD34B +SHC2 +RIPK1 +LIMS1 +F13A1 +HS6ST3 +ARHGEF15 +RAB42 +MRTO4 +ZC2HC1C +PHGDH +PDE8A +ARHGEF18 +RPN2 +SNRPA +EFCC1 +SIGLEC6 +APEX1 +CCND1 +KLF10 +FSTL1 +DYSF +LAMA3 +HSP90AA1 +FAP +THUMPD3 +ACRV1 +HDC +MLEC +RND2 +DNAH3 +ANKRD40 +KCTD13 +CDK9 +SLC6A13 +PDX1 +PDCD11 +COX7A2L +EBF1 +FBXO33 +A2M +RING1 +GUK1 +NPFFR2 +MAPK9 +KCNA4 +TNFRSF8 +FRMD6 +OTUD4 +SET +BCLAF1 +EIF5 +SPAG16 +SLC6A18 +MYBPC2 +RIMS3 +CSE1L +ZNF789 +EIF2S1 +CDH9 +CEP72 +IFT172 +TYW1 +ZIK1 +HIVEP2 +MCM9 +MARCHF3 +PIK3CB +QPCTL +LRP12 +RPH3A +SLC29A3 +NXPH1 +SYT11 +NUDT17 +GAL3ST1 +TSPAN19 +SQSTM1 +RHOB +OR13A1 +SMOX +TBX19 +TMEM198 +NDUFA10 +CORO1B +CHST4 +FAM210B +PCGF3 +PRTN3 +PRKCB +LYPD6 +GLS2 +OR2A12 +SPG7 +MAN2C1 +HAL +KALRN +TP53INP2 +DSG1 +CDS2 +CCN4 +FAM186A +VWDE +ZNF862 +KRT32 +ZDHHC13 +ALDH3B2 +TAF4B +CA12 +PRR4 +TTPAL +P2RY6 +RAPSN +PSMG1 +RUVBL2 +EME1 +PEG10 +IMP3 +FASLG +FAM219A +BRD8 +ZNF404 +NUP107 +QRICH1 +FBXO16 +ADAMTSL1 +LIMK1 +DDX25 +OR51V1 +DHX16 +JMJD4 +NYAP2 +CRLS1 +ITIH3 +WARS1 +DDX60 +LYSMD2 +SYT8 +SGTB +EPHB4 +MIER1 +FLT1 +MLPH +MYO18B +KRTAP27-1 +ETV3 +HMX1 +IDI1 +CCDC90B +B3GNT9 +WDTC1 +SLC7A1 +TFPI2 +HSP90AB1 +SH3D21 +RUNDC3A +TRDMT1 +CAPZB +MED29 +PKN1 +OLIG1 +UBE3C +SMURF2 +SLC25A32 +OR8B8 +HDLBP +MUC13 +DOT1L +SPRTN +DAPK3 +CCDC146 +CLDN4 +TFCP2L1 +TKT +OSR1 +SMO +TRIM37 +CD63 +NTHL1 +PEX2 +FYCO1 +ARHGEF40 +PPCDC +PRRC2B +IL26 +CLEC10A +ESR1 +NELL1 +PDE9A +EFNA5 +CALR +KHDC3L +PDE10A +MAT2B +FANCM +NTMT1 +MFAP2 +TTLL2 +SMAGP +ATG16L1 +MAP2K5 +PRM2 +CSRNP1 +LCE3C +MSH2 +POLR2G +FNBP1 +KIF25 +KDR +IL12B +MYBPC1 +ACSL3 +UBE2L3 +MMS22L +DLX3 +AKT2 +LGR5 +SOX8 +HGD +UBXN7 +BUB3 +CSNK1A1L +MED10 +HS3ST5 +TPRX1 +CLMN +PEX13 +PDGFA +FGL1 +RAC3 +BIRC2 +STOML3 +MIEN1 +CCNA1 +SOWAHA +NEUROD2 +SH2B1 +FRZB +TEC +TRIAP1 +PLK2 +PCYOX1L +FANCF +MRGPRD +EMC10 +SACS +GJC2 +KCNB1 +AGPAT2 +THRB +FANK1 +ZEB1 +NR2F2 +ADAM2 +MCMBP +CPLX1 +NEXN +SAMD9 +MESP2 +APRT +CDK13 +ARHGEF19 +EBAG9 +ABCF1 +CDYL +VPS35 +ATP6V1B2 +SWT1 +MYOG +FAM111B +RNASE3 +CASR +NCKAP5 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/gene_list_paccmann_network_prop_proteomics.csv b/drevalpy/components/featurizers/cell_line/gene_lists/gene_list_paccmann_network_prop_proteomics.csv new file mode 100644 index 000000000..c7b584e4b --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/gene_list_paccmann_network_prop_proteomics.csv @@ -0,0 +1,277 @@ +Symbol +COPZ1 +PPIL1 +APEX1 +SLC2A1 +NDUFB2 +FLNB +RAB33B +NUDT5 +THOP1 +HSPA2 +RPS18 +SEC24C +SRP68 +MLEC +CKAP4 +DDX18 +RPP25L +SUGP2 +CORO1B +NUP107 +EIF2A +COX5A +BID +AP2A1 +EMC10 +SUPT16H +CTSB +ACSL3 +UBTF +TOP2A +CMPK1 +PARP1 +CSDE1 +CNOT2 +PPIA +PCNA +CDK1 +PPT1 +UBQLN1 +DNAJC2 +PUS1 +MAPK1 +TSN +NDUFA10 +HDAC1 +PDCD11 +SUGT1 +ESYT2 +PDCD6 +AAAS +UQCRH +SLC39A10 +ABCF1 +AMFR +CAPZB +MYO18A +NDUFA2 +SRSF7 +CPOX +EBAG9 +TUBA1C +NCOR2 +CSE1L +ECHS1 +PYGB +PSMB7 +UFC1 +OXA1L +YWHAZ +ATP6V1B2 +MSH2 +SET +UCHL5 +ARFGAP1 +RTF1 +WDR74 +MRPS26 +VCL +SYMPK +DHX16 +SNRPA +SURF6 +NPLOC4 +PDP1 +RRP9 +MAT2B +WDR70 +VPS35 +EIF5 +AARS2 +MTHFD1L +XAB2 +MRPL46 +MRTO4 +TACO1 +GNA11 +CAPN1 +RPRD1B +NLE1 +GEMIN5 +AKAP12 +RUVBL2 +SMARCB1 +DEK +DDX6 +FAHD1 +PRMT5 +PCBP2 +MAPK3 +GTF2B +SMU1 +ACTR1A +DCTN2 +EMC7 +PPP1CB +MRGBP +TOR1AIP1 +PREB +INCENP +HDAC2 +NOL7 +GTF2F2 +CHMP1A +TMED4 +EIF2S1 +PTMS +HDLBP +POLDIP3 +SNX2 +PTPN11 +CDK5 +F11R +LSG1 +SRP54 +NTMT1 +SNAPIN +HSP90AA1 +POR +GLRX5 +RING1 +EFTUD2 +APMAP +SDHA +SCAMP2 +CSTB +PEPD +ATP6V1C1 +PELP1 +PTK2 +KIF11 +IRGQ +EHMT2 +MRPS16 +RPL30 +TRUB1 +CREG1 +STIM1 +HMOX2 +SRSF1 +BRD4 +GNA13 +LARP4 +TERF2IP +KTN1 +HSPA5 +DLST +PGM2 +BUB3 +ASCC2 +TEX264 +AURKB +SQSTM1 +ATP6V0A1 +SART1 +MRPL40 +GNG5 +SLTM +FXN +IDI1 +IDH2 +NDUFB1 +DNMT1 +PRDX6 +REPS1 +ASNS +UBQLN4 +PSMF1 +NDUFAF2 +ZC3HAV1 +SLC27A4 +RABL3 +ASPH +CCT5 +NDUFA13 +ATAD2 +PSMB5 +GGH +NMT1 +PRKDC +ATP6V1G1 +IMPA1 +PPA2 +CBR3 +NUDT1 +WDR5 +CELF1 +ETFB +CDKN2AIP +COX5B +KPNA4 +KDM2A +RANBP3 +ADAM10 +PIH1D1 +IVD +TRA2B +SRSF6 +LRRC40 +NDUFAB1 +NOP10 +APRT +SAR1A +CALR +KPNB1 +RSU1 +PHGDH +TJP1 +DDX1 +SCCPDH +YWHAQ +HNRNPU +MRPL22 +SNRPD1 +LETM1 +SLC7A1 +SSRP1 +NPM1 +ANXA4 +NAMPT +RAB7A +HSP90AB1 +RAP1A +CAD +TOP1 +SLC25A1 +ARF4 +GTPBP1 +CASP3 +CD2BP2 +AKR7A2 +EHMT1 +WBP11 +WDR77 +GUK1 +MRPL23 +HNRNPA3 +MRPL9 +BZW2 +UBE2L3 +GRPEL1 +CHMP2B +CYB5B +HINT2 +WDR1 +PKN1 +PSPH +CHMP6 +ERGIC1 +SUPV3L1 +IPO7 +SF3A1 +NACC1 +RPN2 +IMP3 +SWAP70 +MRPL18 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/gene_list_paccmann_network_prop_reduced.csv b/drevalpy/components/featurizers/cell_line/gene_lists/gene_list_paccmann_network_prop_reduced.csv new file mode 100644 index 000000000..19539c96b --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/gene_list_paccmann_network_prop_reduced.csv @@ -0,0 +1,271 @@ +Symbol +COPZ1 +PPIL1 +APEX1 +SLC2A1 +NDUFB2 +FLNB +RAB33B +NUDT5 +THOP1 +HSPA2 +RPS18 +SEC24C +SRP68 +MLEC +CKAP4 +DDX18 +RPP25L +SUGP2 +CORO1B +NUP107 +EIF2A +BID +COX5A +AP2A1 +EMC10 +SUPT16H +CTSB +ACSL3 +UBTF +TOP2A +CMPK1 +PPIA +CSDE1 +CNOT2 +PCNA +UBQLN1 +CDK1 +PPT1 +PSMB5 +DNAJC2 +PUS1 +MAPK1 +TSN +NDUFA10 +HDAC1 +PDCD11 +SUGT1 +ESYT2 +PDCD6 +AAAS +UQCRH +SLC39A10 +ABCF1 +AMFR +CAPZB +MYO18A +NDUFA2 +SRSF7 +CPOX +EBAG9 +TUBA1C +NCOR2 +CSE1L +ECHS1 +PYGB +PSMB7 +UFC1 +OXA1L +YWHAZ +ATP6V1B2 +MSH2 +SET +UCHL5 +ARFGAP1 +RTF1 +WDR74 +MRPS26 +VCL +SYMPK +DHX16 +SNRPA +SURF6 +NPLOC4 +PDP1 +RRP9 +MAT2B +WDR70 +VPS35 +EIF5 +AARS2 +MTHFD1L +XAB2 +MRPL46 +MRTO4 +TACO1 +GNA11 +CAPN1 +RPRD1B +NLE1 +GEMIN5 +AKAP12 +RUVBL2 +SMARCB1 +DEK +DDX6 +FAHD1 +PRMT5 +PCBP2 +MAPK3 +GTF2B +SMU1 +ACTR1A +DCTN2 +EMC7 +PPP1CB +MRGBP +TOR1AIP1 +PREB +INCENP +HDAC2 +NOL7 +GTF2F2 +CHMP1A +TMED4 +PTMS +HDLBP +POLDIP3 +SNX2 +PTPN11 +CDK5 +F11R +SRP54 +NTMT1 +SNAPIN +HSP90AA1 +POR +GLRX5 +RING1 +EFTUD2 +APMAP +SDHA +SCAMP2 +CSTB +PEPD +ATP6V1C1 +PELP1 +PTK2 +KIF11 +IRGQ +EHMT2 +RPL30 +TRUB1 +CREG1 +STIM1 +HMOX2 +SRSF1 +BRD4 +GNA13 +LARP4 +TERF2IP +KTN1 +HSPA5 +DLST +PGM2 +BUB3 +ASCC2 +TEX264 +AURKB +SQSTM1 +ATP6V0A1 +SART1 +MRPL40 +GNG5 +SLTM +FXN +REPS1 +IDH2 +NDUFB1 +DNMT1 +PRDX6 +ASNS +UBQLN4 +PSMF1 +NDUFAF2 +ZC3HAV1 +SLC27A4 +RABL3 +ASPH +CCT5 +NDUFA13 +ATAD2 +GGH +NMT1 +PRKDC +ATP6V1G1 +IMPA1 +PPA2 +CBR3 +NUDT1 +WDR5 +CELF1 +ETFB +CDKN2AIP +COX5B +KPNA4 +KDM2A +RANBP3 +ADAM10 +PIH1D1 +IVD +TRA2B +SRSF6 +LRRC40 +NDUFAB1 +NOP10 +APRT +SAR1A +CALR +KPNB1 +PHGDH +TJP1 +DDX1 +SCCPDH +YWHAQ +HNRNPU +MRPL22 +SNRPD1 +LETM1 +SLC7A1 +SSRP1 +NPM1 +ANXA4 +NAMPT +RAB7A +HSP90AB1 +RAP1A +CAD +TOP1 +SLC25A1 +ARF4 +GTPBP1 +CASP3 +CD2BP2 +AKR7A2 +EHMT1 +WBP11 +WDR77 +GUK1 +MRPL23 +HNRNPA3 +MRPL9 +BZW2 +UBE2L3 +GRPEL1 +CHMP2B +CYB5B +HINT2 +WDR1 +PKN1 +PSPH +CHMP6 +ERGIC1 +SUPV3L1 +IPO7 +SF3A1 +NACC1 +RPN2 +IMP3 +SWAP70 +MRPL18 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/landmark_genes.csv b/drevalpy/components/featurizers/cell_line/gene_lists/landmark_genes.csv new file mode 100644 index 000000000..55b6ded20 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/landmark_genes.csv @@ -0,0 +1,868 @@ +Entrez ID,Symbol,Name,Gene Family,Type,RNA-Seq Correlation,RNA-Seq Correlation Self-Rank +3638,INSIG1,insulin induced gene 1,,landmark,, +2309,FOXO3,forkhead box O3,Forkhead boxes,landmark,, +1001,CDH3,cadherin 3,Type I classical cadherins,landmark,, +4998,ORC1,origin recognition complex subunit 1,"AAA ATPases, Origin recognition complex ",landmark,, +3682,ITGAE,integrin subunit alpha E,"CD molecules, Integrin alpha subunits",landmark,, +1022,CDK7,cyclin dependent kinase 7,Cyclin dependent kinases,landmark,, +2353,FOS,"Fos proto-oncogene, AP-1 transcription factor subunit","Basic leucine zipper proteins, Fos transcription factor family",landmark,, +1021,CDK6,cyclin dependent kinase 6,Cyclin dependent kinases,landmark,, +1019,CDK4,cyclin dependent kinase 4,Cyclin dependent kinases,landmark,, +1017,CDK2,cyclin dependent kinase 2,Cyclin dependent kinases,landmark,, +3693,ITGB5,integrin subunit beta 5,Integrin beta subunits,landmark,, +1029,CDKN2A,cyclin dependent kinase inhibitor 2A,,landmark,, +1027,CDKN1B,cyclin dependent kinase inhibitor 1B,,landmark,, +2356,FPGS,folylpolyglutamate synthase,,landmark,, +1052,CEBPD,CCAAT/enhancer binding protein delta,"Basic leucine zipper proteins, CCAAT/enhancer binding proteins ",landmark,, +1062,CENPE,centromere protein E,"Kinesins, Protein phosphatase 1 regulatory subunits",landmark,, +85377,MICALL1,MICAL like 1,LIM domain containing,landmark,, +1070,CETN3,centrin 3,EF-hand domain containing,landmark,, +11065,UBE2C,ubiquitin conjugating enzyme E2 C,Ubiquitin conjugating enzymes E2,landmark,, +11072,DUSP14,dual specificity phosphatase 14,Atypical dual specificity phosphatases,landmark,, +11073,TOPBP1,topoisomerase (DNA) II binding protein 1,BRCA1 B complex,landmark,, +11044,TENT4A,"poly(A) RNA polymerase D7, non-canonical",Non-canonical poly(A) polymerases,landmark,, +23038,WDTC1,WD and tetratricopeptide repeats 1,"WD repeat domain containing, DDB1 and CUL4 associated factors, Tetratricopeptide repeat domain containing",landmark,, +23039,XPO7,exportin 7,Exportins,landmark,, +26993,AKAP8L,A-kinase anchoring protein 8 like,,landmark,, +11031,RAB31,"RAB31, member RAS oncogene family","RAB, member RAS oncogene GTPases",landmark,, +23013,SPEN,spen family transcriptional repressor,RNA binding motif containing,landmark,, +23014,FBXO21,F-box protein 21,F-boxes other,landmark,, +23011,RAB21,"RAB21, member RAS oncogene family","RAB, member RAS oncogene GTPases",landmark,, +11000,SLC27A3,solute carrier family 27 member 3,"Acyl-CoA synthetase family, Solute carriers",landmark,, +11004,KIF2C,kinesin family member 2C,Kinesins,landmark,, +11007,CCDC85B,coiled-coil domain containing 85B,,landmark,, +11011,TLK2,tousled like kinase 2,,landmark,, +23097,CDK19,cyclin dependent kinase 19,"Cyclin dependent kinases, Mediator complex",landmark,, +3725,JUN,"Jun proto-oncogene, AP-1 transcription factor subunit","Basic leucine zipper proteins, Jun transcription factor family",landmark,, +23061,TBC1D9B,TBC1 domain family member 9B,"EF-hand domain containing, GRAM domain containing",landmark,, +11098,PRSS23,"protease, serine 23","Proteases, serine",landmark,, +23076,RRP1B,ribosomal RNA processing 1B,Protein phosphatase 1 regulatory subunits,landmark,, +23077,MYCBP2,"MYC binding protein 2, E3 ubiquitin protein ligase",,landmark,, +1111,CHEK1,checkpoint kinase 1,,landmark,, +3775,KCNK1,potassium two pore domain channel subfamily K member 1,Potassium two pore domain channel subfamily K,landmark,, +1123,CHN1,chimerin 1,"Rho GTPase activating proteins, SH2 domain containing",landmark,, +1153,CIRBP,cold inducible RNA binding protein,RNA binding motif containing,landmark,, +50813,COPS7A,COP9 signalosome subunit 7A,COP9 signalosome,landmark,, +23200,ATP11B,ATPase phospholipid transporting 11B (putative),ATPase phospholipid transporting,landmark,, +23210,JMJD6,arginine demethylase and lysine hydroxylase,,landmark,, +23212,RRS1,ribosome biogenesis regulator homolog,,landmark,, +25874,MPC2,mitochondrial pyruvate carrier 2,,landmark,, +84159,ARID5B,AT-rich interaction domain 5B,AT-rich interaction domain containing,landmark,, +11232,POLG2,"DNA polymerase gamma 2, accessory subunit",DNA polymerases,landmark,, +11200,CHEK2,checkpoint kinase 2,,landmark,, +25825,BACE2,beta-site APP-cleaving enzyme 2,,landmark,, +25839,COG4,component of oligomeric golgi complex 4,Components of oligomeric golgi complex,landmark,, +25803,SPDEF,SAM pointed domain containing ETS transcription factor,ETS transcription factor family,landmark,, +25805,BAMBI,BMP and activin membrane bound inhibitor,,landmark,, +200734,SPRED2,sprouty related EVH1 domain containing 2,,landmark,, +11182,SLC2A6,solute carrier family 2 member 6,Solute carriers,landmark,, +23161,SNX13,sorting nexin 13,Sorting nexins,landmark,, +11188,NISCH,nischarin,,landmark,, +16,AARS1,alanyl-tRNA synthetase,"Aminoacyl tRNA synthetases, Class II",landmark,, +23,ABCF1,ATP binding cassette subfamily F member 1,ATP binding cassette subfamily F,landmark,, +25,ABL1,"ABL proto-oncogene 1, non-receptor tyrosine kinase",SH2 domain containing,landmark,, +23149,FCHO1,FCH domain only 1,F-BAR domain containing,landmark,, +23142,DCUN1D4,defective in cullin neddylation 1 domain containing 4,,landmark,, +11168,PSIP1,PC4 and SFRS1 interacting protein 1,PWWP domain containing,landmark,, +30,ACAA1,acetyl-CoA acyltransferase 1,,landmark,, +39,ACAT2,acetyl-CoA acetyltransferase 2,,landmark,, +47,ACLY,ATP citrate lyase,,landmark,, +11142,PKIG,cAMP-dependent protein kinase inhibitor gamma,,landmark,, +11151,CORO1A,coronin 1A,"WD repeat domain containing, Coronins",landmark,, +23139,MAST2,microtubule associated serine/threonine kinase 2,PDZ domain containing,landmark,, +23131,GPATCH8,G-patch domain containing 8,G-patch domain containing,landmark,, +25793,FBXO7,F-box protein 7,"F-boxes other, Parkinson disease associated genes",landmark,, +11157,LSM6,"LSM6 homolog, U6 small nuclear RNA and mRNA degradation associated",,landmark,, +11137,PWP1,"PWP1 homolog, endonuclein",WD repeat domain containing,landmark,, +3800,KIF5C,kinesin family member 5C,Kinesins,landmark,, +3815,KIT,KIT proto-oncogene receptor tyrosine kinase,"Receptor Tyrosine Kinases, CD molecules, Immunoglobulin like domain containing",landmark,, +2523,FUT1,fucosyltransferase 1 (H blood group),"Fucosyltransferases, Blood group antigens",landmark,, +2534,FYN,"FYN proto-oncogene, Src family tyrosine kinase","SH2 domain containing, Src family tyrosine kinases",landmark,, +2548,GAA,"glucosidase alpha, acid",,landmark,, +1213,CLTC,clathrin heavy chain,,landmark,, +1212,CLTB,clathrin light chain B,,landmark,, +2542,SLC37A4,solute carrier family 37 member 4,Solute carriers,landmark,, +2553,GABPB1,GA binding protein transcription factor beta subunit 1,Ankyrin repeat domain containing,landmark,, +3895,KTN1,kinectin 1,,landmark,, +2582,GALE,UDP-galactose-4-epimerase,Short chain dehydrogenase/reductase superfamily,landmark,, +2597,GAPDH,glyceraldehyde-3-phosphate dehydrogenase,,landmark,, +1282,COL4A1,collagen type IV alpha 1 chain,Collagens,landmark,, +1277,COL1A1,collagen type I alpha 1 chain,Collagens,landmark,, +23321,TRIM2,tripartite motif containing 2,"Ring finger proteins, Tripartite motif containing",landmark,, +25987,TSKU,"tsukushi, small leucine rich proteoglycan",,landmark,, +23326,USP22,ubiquitin specific peptidase 22,"Ubiquitin specific peptidases, SAGA complex",landmark,, +23335,WDR7,WD repeat domain 7,WD repeat domain containing,landmark,, +23300,ATMIN,ATM interactor,Zinc fingers C2H2-type,landmark,, +25966,C2CD2,C2 calcium dependent domain containing 2,C2 domain containing,landmark,, +11325,DDX42,DEAD-box helicase 42,DEAD-box helicases,landmark,, +25976,TIPARP,TCDD inducible poly(ADP-ribose) polymerase,Poly(ADP-ribose) polymerases,landmark,, +10007,GNPDA1,glucosamine-6-phosphate deaminase 1,,landmark,, +11319,ECD,ecdysoneless cell cycle regulator,,landmark,, +25932,CLIC4,chloride intracellular channel 4,Chloride intracellular channels,landmark,, +11284,PNKP,polynucleotide kinase 3'-phosphatase,HAD Asp-based non-protein phosphatases,landmark,, +23271,CAMSAP2,calmodulin regulated spectrin associated protein family member 2,,landmark,, +23244,PDS5A,PDS5 cohesin associated factor A,,landmark,, +23223,RRP12,ribosomal RNA processing 12 homolog,,landmark,, +23224,SYNE2,spectrin repeat containing nuclear envelope protein 2,Spectrin repeat containing nuclear envelope family,landmark,, +3909,LAMA3,laminin subunit alpha 3,Laminin subunits,landmark,, +3925,STMN1,stathmin 1,Stathmins,landmark,, +3930,LBR,lamin B receptor,Tudor domain containing,landmark,, +50865,HEBP1,heme binding protein 1,Endogenous ligands,landmark,, +2625,GATA3,GATA binding protein 3,GATA zinc finger domain containing,landmark,, +2624,GATA2,GATA binding protein 2,GATA zinc finger domain containing,landmark,, +3964,LGALS8,galectin 8,Galectins,landmark,, +3978,LIG1,DNA ligase 1,"DNA ligases, Nucleotide excision repair",landmark,, +3988,LIPA,"lipase A, lysosomal acid type",Lipases,landmark,, +2673,GFPT1,glutamine--fructose-6-phosphate transaminase 1,,landmark,, +2690,GHR,growth hormone receptor,Fibronectin type III domain containing,landmark,, +1385,CREB1,cAMP responsive element binding protein 1,Basic leucine zipper proteins,landmark,, +116832,RPL39L,ribosomal protein L39 like,L ribosomal proteins,landmark,, +1399,CRKL,"CRK like proto-oncogene, adaptor protein",SH2 domain containing,landmark,, +1398,CRK,"CRK proto-oncogene, adaptor protein",SH2 domain containing,landmark,, +23443,SLC35A3,solute carrier family 35 member A3,Solute carriers,landmark,, +10131,TRAP1,TNF receptor associated protein 1,Heat shock 90kDa proteins,landmark,, +10146,G3BP1,G3BP stress granule assembly factor 1,RNA binding motif containing,landmark,, +10112,KIF20A,kinesin family member 20A,Kinesins,landmark,, +10123,ARL4C,ADP ribosylation factor like GTPase 4C,ARF GTPase family,landmark,, +23410,SIRT3,sirtuin 3,Sirtuins,landmark,, +10099,TSPAN3,tetraspanin 3,Tetraspanins,landmark,, +23386,NUDCD3,NudC domain containing 3,NudC family,landmark,, +10051,SMC4,structural maintenance of chromosomes 4,Structural maintenance of chromosomes proteins,landmark,, +23365,ARHGEF12,Rho guanine nucleotide exchange factor 12,"Rho guanine nucleotide exchange factors, PDZ domain containing",landmark,, +23368,PPP1R13B,protein phosphatase 1 regulatory subunit 13B,"Ankyrin repeat domain containing, Protein phosphatase 1 regulatory subunits",landmark,, +10057,ABCC5,ATP binding cassette subfamily C member 5,ATP binding cassette subfamily C,landmark,, +10059,DNM1L,dynamin 1 like,,landmark,, +23378,RRP8,"ribosomal RNA processing 8, methyltransferase, homolog (yeast)",Seven-beta-strand methyltransferase motif containing,landmark,, +10038,PARP2,poly(ADP-ribose) polymerase 2,Poly(ADP-ribose) polymerases,landmark,, +10049,DNAJB6,DnaJ heat shock protein family (Hsp40) member B6,DNAJ (HSP40) heat shock proteins,landmark,, +2736,GLI2,GLI family zinc finger 2,Zinc fingers C2H2-type,landmark,, +2745,GLRX,glutaredoxin,,landmark,, +1429,CRYZ,crystallin zeta,,landmark,, +2771,GNAI2,G protein subunit alpha i2,,landmark,, +2770,GNAI1,G protein subunit alpha i1,,landmark,, +2767,GNA11,G protein subunit alpha 11,,landmark,, +2769,GNA15,G protein subunit alpha 15,,landmark,, +2778,GNAS,GNAS complex locus,Granins,landmark,, +1445,CSK,"CSK, non-receptor tyrosine kinase",SH2 domain containing,landmark,, +1459,CSNK2A2,casein kinase 2 alpha 2,,landmark,, +1454,CSNK1E,casein kinase 1 epsilon,,landmark,, +1452,CSNK1A1,casein kinase 1 alpha 1,,landmark,, +1465,CSRP1,cysteine and glycine rich protein 1,LIM domain containing,landmark,, +10237,SLC35B1,solute carrier family 35 member B1,Solute carriers,landmark,, +23522,KAT6B,lysine acetyltransferase 6B,"Zinc fingers C2HC-type, PHD finger proteins, Lysine acetyltransferases",landmark,, +23530,NNT,nicotinamide nucleotide transhydrogenase,,landmark,, +23536,ADAT1,"adenosine deaminase, tRNA specific 1",Adenosine deaminases acting on RNA,landmark,, +10227,MFSD10,major facilitator superfamily domain containing 10,,landmark,, +10221,TRIB1,tribbles pseudokinase 1,,landmark,, +23512,SUZ12,SUZ12 polycomb repressive complex 2 subunit,"Zinc fingers C2H2-type, Polycomb repressive complex 2",landmark,, +10206,TRIM13,tripartite motif containing 13,"Ring finger proteins, Tripartite motif containing",landmark,, +10190,TXNDC9,thioredoxin domain containing 9,,landmark,, +10174,SORBS3,sorbin and SH3 domain containing 3,,landmark,, +10180,RBM6,RNA binding motif protein 6,"G-patch domain containing, RNA binding motif containing",landmark,, +23499,MACF1,microtubule-actin crosslinking factor 1,"EF-hand domain containing, Plakins",landmark,, +10150,MBNL2,muscleblind like splicing regulator 2,Zinc fingers CCCH-type,landmark,, +23463,ICMT,isoprenylcysteine carboxyl methyltransferase,,landmark,, +10153,CEBPZ,CCAAT/enhancer binding protein zeta,CCAAT/enhancer binding proteins,landmark,, +2810,SFN,stratifin,14-3-3 phospho-serine/phospho-threonine binding proteins,landmark,, +10165,SLC25A13,solute carrier family 25 member 13,"Solute carriers, EF-hand domain containing",landmark,, +2817,GPC1,glypican 1,Glypicans,landmark,, +1500,CTNND1,catenin delta 1,Armadillo repeat containing,landmark,, +1534,CYB561,cytochrome b561,Cytochrome b561,landmark,, +2887,GRB10,growth factor receptor bound protein 10,"Pleckstrin homology domain containing, SH2 domain containing",landmark,, +2886,GRB7,growth factor receptor bound protein 7,"Pleckstrin homology domain containing, SH2 domain containing",landmark,, +2896,GRN,granulin precursor,,landmark,, +10362,HMG20B,high mobility group 20B,Non-canonical high mobility group,landmark,, +23647,ARFIP2,ADP ribosylation factor interacting protein 2,Classical BAR domain containing,landmark,, +23659,PLA2G15,phospholipase A2 group XV,Phospholipases,landmark,, +10318,TNIP1,TNFAIP3 interacting protein 1,,landmark,, +23635,SSBP2,single stranded DNA binding protein 2,,landmark,, +10320,IKZF1,IKAROS family zinc finger 1,"Zinc fingers C2H2-type, Protein phosphatase 1 regulatory subunits",landmark,, +2908,NR3C1,nuclear receptor subfamily 3 group C member 1,Nuclear hormone receptors,landmark,, +10298,PAK4,p21 (RAC1) activated kinase 4,,landmark,, +10270,AKAP8,A-kinase anchoring protein 8,A-kinase anchoring proteins,landmark,, +23585,TMEM50A,transmembrane protein 50A,,landmark,, +23588,KLHDC2,kelch domain containing 2,,landmark,, +2920,CXCL2,C-X-C motif chemokine ligand 2,"Chemokine ligands, Endogenous ligands",landmark,, +10276,NET1,neuroepithelial cell transforming 1,"Pleckstrin homology domain containing, Rho guanine nucleotide exchange factors",landmark,, +10285,SMNDC1,survival motor neuron domain containing 1,Tudor domain containing,landmark,, +1605,DAG1,dystroglycan 1,,landmark,, +1616,DAXX,death domain associated protein,,landmark,, +2954,GSTZ1,glutathione S-transferase zeta 1,Glutathione S-transferases,landmark,, +2958,GTF2A2,general transcription factor IIA subunit 2,General transcription factors,landmark,, +2956,MSH6,mutS homolog 6,"MutS homologs, PWWP domain containing",landmark,, +60528,ELAC2,elaC ribonuclease Z 2,,landmark,, +1635,DCTD,dCMP deaminase,,landmark,, +1633,DCK,deoxycytidine kinase,,landmark,, +2961,GTF2E2,general transcription factor IIE subunit 2,General transcription factors,landmark,, +1643,DDB2,damage specific DNA binding protein 2,"WD repeat domain containing, Xeroderma pigmentosum complementation groups, Nucleotide excision repair",landmark,, +1647,GADD45A,growth arrest and DNA damage inducible alpha,,landmark,, +1666,DECR1,"2,4-dienoyl-CoA reductase 1",Short chain dehydrogenase/reductase superfamily,landmark,, +1662,DDX10,DEAD-box helicase 10,DEAD-box helicases,landmark,, +1677,DFFB,DNA fragmentation factor subunit beta,,landmark,, +1676,DFFA,DNA fragmentation factor subunit alpha,,landmark,, +10491,CRTAP,cartilage associated protein,,landmark,, +10493,VAT1,vesicle amine transport 1,,landmark,, +10494,STK25,serine/threonine kinase 25,STRIPAK complex,landmark,, +10489,LRRC41,leucine rich repeat containing 41,,landmark,, +10451,VAV3,vav guanine nucleotide exchange factor 3,"Pleckstrin homology domain containing, Rho guanine nucleotide exchange factors, SH2 domain containing",landmark,, +10450,PPIE,peptidylprolyl isomerase E,"RNA binding motif containing, Cyclophilin peptidylprolyl isomerases",landmark,, +10398,MYL9,myosin light chain 9,"Myosin light chains, EF-hand domain containing",landmark,, +1738,DLD,dihydrolipoamide dehydrogenase,,landmark,, +84617,TUBB6,tubulin beta 6 class V,Tubulins,landmark,, +1759,DNM1,dynamin 1,Pleckstrin homology domain containing,landmark,, +57019,CIAPIN1,cytokine induced apoptosis inhibitor 1,Seven-beta-strand methyltransferase motif containing,landmark,, +1788,DNMT3A,DNA methyltransferase 3 alpha,PWWP domain containing,landmark,, +1786,DNMT1,DNA methyltransferase 1,"Zinc fingers CXXC-type, Seven-beta-strand methyltransferase motif containing",landmark,, +200081,TXLNA,taxilin alpha,,landmark,, +10513,APPBP2,amyloid beta precursor protein binding protein 2,,landmark,, +10525,HYOU1,hypoxia up-regulated 1,Heat shock 70kDa proteins,landmark,, +58533,SNX6,sorting nexin 6,"Sorting nexins, PX-BAR domain containing",landmark,, +57215,THAP11,THAP domain containing 11,THAP domain containing,landmark,, +10589,DRAP1,DR1 associated protein 1,,landmark,, +10559,SLC35A1,solute carrier family 35 member A1,Solute carriers,landmark,, +57192,MCOLN1,mucolipin 1,Transient receptor potential cation channels,landmark,, +1802,DPH2,DPH2 homolog,,landmark,, +1829,DSG2,desmoglein 2,Desmosomal cadherins,landmark,, +1848,DUSP6,dual specificity phosphatase 6,MAP kinase phosphatases,landmark,, +1846,DUSP4,dual specificity phosphatase 4,MAP kinase phosphatases,landmark,, +1845,DUSP3,dual specificity phosphatase 3,Atypical dual specificity phosphatases,landmark,, +84722,PSRC1,proline and serine rich coiled-coil 1,,landmark,, +1861,TOR1A,torsin family 1 member A,,landmark,, +1870,E2F2,E2F transcription factor 2,E2F transcription factors,landmark,, +1891,ECH1,enoyl-CoA hydratase 1,,landmark,, +57149,LYRM1,LYR motif containing 1,LYR motif containing,landmark,, +57147,SCYL3,SCY1 like pseudokinase 3,SCY1 like pseudokinases,landmark,, +58478,ENOPH1,enolase-phosphatase 1,HAD Asp-based non-protein phosphatases,landmark,, +57178,ZMIZ1,zinc finger MIZ-type containing 1,Zinc fingers MIZ-type,landmark,, +10652,YKT6,YKT6 v-SNARE homolog (S. cerevisiae),SNAREs,landmark,, +10668,CGRRF1,cell growth regulator with ring finger domain 1,Ring finger proteins,landmark,, +10641,NPRL2,"NPR2-like, GATOR1 complex subunit",GATOR1 subcomplex,landmark,, +10644,IGF2BP2,insulin like growth factor 2 mRNA binding protein 2,RNA binding motif containing,landmark,, +10617,STAMBP,STAM binding protein,,landmark,, +10610,ST6GALNAC2,"ST6 N-acetylgalactosaminide alpha-2,6-sialyltransferase 2",Sialyltransferases,landmark,, +10606,PAICS,phosphoribosylaminoimidazole carboxylase and phosphoribosylaminoimidazolesuccinocarboxamide synthase,,landmark,, +10695,CNPY3,canopy FGF signaling regulator 3,Trinucleotide repeat containing,landmark,, +10670,RRAGA,Ras related GTP binding A,,landmark,, +10681,GNB5,G protein subunit beta 5,WD repeat domain containing,landmark,, +94239,H2AZ2,H2A histone family member V,Histones,landmark,, +1906,EDN1,endothelin 1,Endogenous ligands,landmark,, +1950,EGF,epidermal growth factor,,landmark,, +1958,EGR1,early growth response 1,Zinc fingers C2H2-type,landmark,, +1956,EGFR,epidermal growth factor receptor,Erb-b2 receptor tyrosine kinases,landmark,, +1978,EIF4EBP1,eukaryotic translation initiation factor 4E binding protein 1,,landmark,, +1983,EIF5,eukaryotic translation initiation factor 5,,landmark,, +1994,ELAVL1,ELAV like RNA binding protein 1,RNA binding motif containing,landmark,, +9019,MPZL1,myelin protein zero like 1,V-set domain containing,landmark,, +9053,MAP7,microtubule associated protein 7,,landmark,, +84890,ADO,2-aminoethanethiol dioxygenase,,landmark,, +10776,ARPP19,cAMP regulated phosphoprotein 19,,landmark,, +10775,POP4,"POP4 homolog, ribonuclease P/MRP subunit",,landmark,, +10782,ZNF274,zinc finger protein 274,"Zinc fingers C2H2-type, SCAN domain containing",landmark,, +57406,ABHD6,abhydrolase domain containing 6,Abhydrolase domain containing,landmark,, +9093,DNAJA3,DnaJ heat shock protein family (Hsp40) member A3,DNAJ (HSP40) heat shock proteins,landmark,, +9097,USP14,ubiquitin specific peptidase 14,Ubiquitin specific peptidases,landmark,, +10765,KDM5B,lysine demethylase 5B,"PHD finger proteins, AT-rich interaction domain containing, Lysine demethylases",landmark,, +10730,YME1L1,YME1 like 1 ATPase,AAA ATPases,landmark,, +10732,TCFL5,transcription factor like 5,Basic helix-loop-helix proteins,landmark,, +22794,CASC3,cancer susceptibility 3,Exon junction complex,landmark,, +22796,COG2,component of oligomeric golgi complex 2,Components of oligomeric golgi complex,landmark,, +10797,MTHFD2,"methylenetetrahydrofolate dehydrogenase (NADP+ dependent) 2, methenyltetrahydrofolate cyclohydrolase",,landmark,, +256364,EML3,echinoderm microtubule associated protein like 3,WD repeat domain containing,landmark,, +9112,MTA1,metastasis associated 1,"GATA zinc finger domain containing, Myb/SANT domain containing, NuRD complex",landmark,, +9124,PDLIM1,PDZ and LIM domain 1,"LIM domain containing, PDZ domain containing",landmark,, +9128,PRPF4,pre-mRNA processing factor 4,WD repeat domain containing,landmark,, +9126,SMC3,structural maintenance of chromosomes 3,"Proteoglycans, Structural maintenance of chromosomes proteins, Cohesin complex",landmark,, +9134,CCNE2,cyclin E2,Cyclins,landmark,, +9143,SYNGR3,synaptogyrin 3,,landmark,, +9170,LPAR2,lysophosphatidic acid receptor 2,Lysophosphatidic acid receptors,landmark,, +9181,ARHGEF2,Rho/Rac guanine nucleotide exchange factor 2,"Pleckstrin homology domain containing, Rho guanine nucleotide exchange factors",landmark,, +10898,CPSF4,cleavage and polyadenylation specific factor 4,,landmark,, +10892,MALT1,MALT1 paracaspase,"Immunoglobulin like domain containing, CBM complex",landmark,, +22883,CLSTN1,calsyntenin 1,Cadherin related,landmark,, +22887,FOXJ3,forkhead box J3,Forkhead boxes,landmark,, +124583,CANT1,calcium activated nucleotidase 1,,landmark,, +22841,RAB11FIP2,RAB11 family interacting protein 2,C2 domain containing,landmark,, +22809,ATF5,activating transcription factor 5,Basic leucine zipper proteins,landmark,, +22823,MTF2,metal response element binding transcription factor 2,"PHD finger proteins, Tudor domain containing",landmark,, +22827,PUF60,poly(U) binding splicing factor 60,RNA binding motif containing,landmark,, +10845,CLPX,caseinolytic mitochondrial matrix peptidase chaperone subunit,AAA ATPases,landmark,, +9212,AURKB,aurora kinase B,"Protein phosphatase 1 regulatory subunits, Chromosomal passenger complex",landmark,, +9217,VAPB,VAMP associated protein B and C,,landmark,, +9221,NOLC1,nucleolar and coiled-body phosphoprotein 1,,landmark,, +10810,WASF3,WAS protein family member 3,Wiskott-Aldrich Syndrome protein family,landmark,, +10818,FRS2,fibroblast growth factor receptor substrate 2,,landmark,, +83743,GRWD1,glutamate rich WD repeat containing 1,WD repeat domain containing,landmark,, +9246,UBE2L6,ubiquitin conjugating enzyme E2 L6,Ubiquitin conjugating enzymes E2,landmark,, +9267,CYTH1,cytohesin 1,Pleckstrin homology domain containing,landmark,, +9261,MAPKAPK2,mitogen-activated protein kinase-activated protein kinase 2,Mitogen-activated protein kinase-activated protein kinases,landmark,, +9276,COPB2,coatomer protein complex subunit beta 2,WD repeat domain containing,landmark,, +9270,ITGB1BP1,integrin subunit beta 1 binding protein 1,,landmark,, +9275,BCL7B,BCL tumor suppressor 7B,,landmark,, +55008,HERC6,HECT and RLD domain containing E3 ubiquitin protein ligase family member 6,,landmark,, +10972,TMED10,transmembrane p24 trafficking protein 10,Transmembrane p24 trafficking proteins,landmark,, +10973,ASCC3,activating signal cointegrator 1 complex subunit 3,"DNA helicases, RNA helicases",landmark,, +55012,PPP2R3C,protein phosphatase 2 regulatory subunit B''gamma,Protein phosphatase 2 regulatory subunits,landmark,, +55011,PIH1D1,PIH1 domain containing 1,R2TP complex,landmark,, +22934,RPIA,ribose 5-phosphate isomerase A,,landmark,, +10954,PDIA5,protein disulfide isomerase family A member 5,Protein disulfide isomerases,landmark,, +10953,TOMM34,translocase of outer mitochondrial membrane 34,Tetratricopeptide repeat domain containing,landmark,, +55033,FKBP14,FK506 binding protein 14,"EF-hand domain containing, FKBP prolyl isomerases",landmark,, +10969,EBNA1BP2,EBNA1 binding protein 2,,landmark,, +55038,CDCA4,cell division cycle associated 4,,landmark,, +10962,MLLT11,"myeloid/lymphoid or mixed-lineage leukemia; translocated to, 11",,landmark,, +147179,WIPF2,WAS/WASL interacting protein family member 2,,landmark,, +22908,SACM1L,SAC1 suppressor of actin mutations 1-like (yeast),Phosphoinositide phosphatases,landmark,, +22926,ATF6,activating transcription factor 6,Basic leucine zipper proteins,landmark,, +10915,TCERG1,transcription elongation regulator 1,,landmark,, +9375,TM9SF2,transmembrane 9 superfamily member 2,Transmembrane 9 superfamily members,landmark,, +22905,EPN2,epsin 2,,landmark,, +10921,RNPS1,RNA binding protein with serine rich domain 1,"RNA binding motif containing, ASAP complex",landmark,, +8050,PDHX,pyruvate dehydrogenase complex component X,,landmark,, +8061,FOSL1,"FOS like 1, AP-1 transcription factor subunit","Basic leucine zipper proteins, Fos transcription factor family",landmark,, +10904,BLCAP,bladder cancer associated protein,,landmark,, +8091,HMGA2,high mobility group AT-hook 2,Canonical high mobility group,landmark,, +57761,TRIB3,tribbles pseudokinase 3,,landmark,, +55111,PLEKHJ1,pleckstrin homology domain containing J1,Pleckstrin homology domain containing,landmark,, +55127,HEATR1,HEAT repeat containing 1,Minor histocompatibility antigens,landmark,, +55129,ANO10,anoctamin 10,Anoctamins,landmark,, +55148,UBR7,ubiquitin protein ligase E3 component n-recognin 7 (putative),Ubiquitin protein ligase E3 component n-recognins,landmark,, +79090,TRAPPC6A,trafficking protein particle complex 6A,Trafficking protein particle complex,landmark,, +79094,CHAC1,ChaC glutathione specific gamma-glutamylcyclotransferase 1,,landmark,, +79080,CCDC86,coiled-coil domain containing 86,,landmark,, +9448,MAP4K4,mitogen-activated protein kinase kinase kinase kinase 4,Mitogen-activated protein kinase kinase kinase kinases,landmark,, +9455,HOMER2,homer scaffolding protein 2,Homer scaffolding proteins,landmark,, +9467,SH3BP5,SH3 domain binding protein 5,,landmark,, +9488,PIGB,phosphatidylinositol glycan anchor biosynthesis class B,"Dolichyl D-mannosyl phosphate dependent mannosyltransferases, Phosphatidylinositol glycan anchor biosynthesis",landmark,, +9491,PSMF1,proteasome inhibitor subunit 1,Proteasome,landmark,, +79071,ELOVL6,ELOVL fatty acid elongase 6,,landmark,, +79073,TMEM109,transmembrane protein 109,,landmark,, +81533,ITFG1,integrin alpha FG-GAP repeat containing 1,,landmark,, +80204,FBXO11,F-box protein 11,"F-boxes other, Ubiquitin protein ligase E3 component n-recognins",landmark,, +55256,ADI1,acireductone dioxygenase 1,,landmark,, +80212,CCDC92,coiled-coil domain containing 92,,landmark,, +81544,GDPD5,glycerophosphodiester phosphodiesterase domain containing 5,,landmark,, +9517,SPTLC2,serine palmitoyltransferase long chain base subunit 2,,landmark,, +9519,TBPL1,TATA-box binding protein like 1,,landmark,, +9531,BAG3,BCL2 associated athanogene 3,BCL2 associated athanogene family,landmark,, +8204,NRIP1,nuclear receptor interacting protein 1,,landmark,, +8202,NCOA3,nuclear receptor coactivator 3,"Basic helix-loop-helix proteins, Lysine acetyltransferases, Trinucleotide repeat containing",landmark,, +9533,POLR1C,RNA polymerase I subunit C,RNA polymerase subunits,landmark,, +9552,SPAG7,sperm associated antigen 7,,landmark,, +55179,FAIM,Fas apoptotic inhibitory molecule,,landmark,, +79143,MBOAT7,membrane bound O-acyltransferase domain containing 7,Membrane bound O-acyltransferases,landmark,, +79170,PRR15L,proline rich 15 like,,landmark,, +79174,CRELD2,cysteine rich with EGF like domains 2,,landmark,, +93487,MAPK1IP1L,mitogen-activated protein kinase 1 interacting protein 1 like,,landmark,, +79187,FSD1,fibronectin type III and SPRY domain containing 1,Fibronectin type III domain containing,landmark,, +56654,NPDC1,"neural proliferation, differentiation and control 1",,landmark,, +66008,TRAK2,trafficking kinesin protein 2,,landmark,, +80347,COASY,Coenzyme A synthase,,landmark,, +29083,GTPBP8,GTP binding protein 8 (putative),,landmark,, +9641,IKBKE,inhibitor of nuclear factor kappa B kinase subunit epsilon,,landmark,, +8312,AXIN1,axin 1,Protein phosphatase 1 regulatory subunits,landmark,, +9637,FEZ2,fasciculation and elongation protein zeta 2,,landmark,, +8321,FZD1,frizzled class receptor 1,"G protein-coupled receptors, Class F frizzled",landmark,, +9653,HS2ST1,heparan sulfate 2-O-sulfotransferase 1,"Sulfotransferases, membrane bound",landmark,, +9650,MTFR1,mitochondrial fission regulator 1,,landmark,, +8324,FZD7,frizzled class receptor 7,"G protein-coupled receptors, Class F frizzled",landmark,, +8318,CDC45,cell division cycle 45,,landmark,, +7015,TERT,telomerase reverse transcriptase,,landmark,, +7016,TESK1,testis-specific kinase 1,,landmark,, +9670,IPO13,importin 13,Importins,landmark,, +9686,VGLL4,vestigial like family member 4,Vestigial like family,landmark,, +7027,TFDP1,transcription factor Dp-1,Transcription factor Dp family,landmark,, +9688,NUP93,nucleoporin 93,Nucleoporins,landmark,, +7020,TFAP2A,transcription factor AP-2 alpha,,landmark,, +8349,H2BC21,histone cluster 2 H2B family member e,Histones,landmark,, +9697,TRAM2,translocation associated membrane protein 2,TLC domain containing,landmark,, +9695,EDEM1,ER degradation enhancing alpha-mannosidase like protein 1,,landmark,, +9690,UBE3C,ubiquitin protein ligase E3C,,landmark,, +7043,TGFB3,transforming growth factor beta 3,Endogenous ligands,landmark,, +7048,TGFBR2,transforming growth factor beta receptor 2,Type 2 receptor serine/threonine kinases,landmark,, +8396,PIP4K2B,phosphatidylinositol-5-phosphate 4-kinase type 2 beta,,landmark,, +7077,TIMP2,TIMP metallopeptidase inhibitor 2,Tissue inhibitor of metallopeptidases,landmark,, +7074,TIAM1,T-cell lymphoma invasion and metastasis 1,"Pleckstrin homology domain containing, Rho guanine nucleotide exchange factors, PDZ domain containing",landmark,, +7088,TLE1,transducin like enhancer of split 1,WD repeat domain containing,landmark,, +7082,TJP1,tight junction protein 1,"Membrane associated guanylate kinases, PDZ domain containing",landmark,, +7099,TLR4,toll like receptor 4,"CD molecules, Toll like receptors",landmark,, +29103,DNAJC15,DnaJ heat shock protein family (Hsp40) member C15,DNAJ (HSP40) heat shock proteins,landmark,, +148022,TICAM1,toll like receptor adaptor molecule 1,TIR domain containing,landmark,, +9712,USP6NL,USP6 N-terminal like,,landmark,, +9710,GARRE1,KIAA0355,,landmark,, +9702,CEP57,centrosomal protein 57,,landmark,, +9703,BLTP2,KIAA0100,,landmark,, +9709,HERPUD1,homocysteine inducible ER protein with ubiquitin like domain 1,,landmark,, +102,ADAM10,ADAM metallopeptidase domain 10,"ADAM metallopeptidase domain containing, CD molecules",landmark,, +9738,CCP110,centriolar coiled-coil protein 110,,landmark,, +128,ADH5,"alcohol dehydrogenase 5 (class III), chi polypeptide",Alcohol dehydrogenases,landmark,, +9761,MLEC,malectin,Oligosaccharyltransferase complex subunits,landmark,, +8440,NCK2,NCK adaptor protein 2,SH2 domain containing,landmark,, +8446,DUSP11,dual specificity phosphatase 11,Atypical dual specificity phosphatases,landmark,, +8444,DYRK3,dual specificity tyrosine phosphorylation regulated kinase 3,,landmark,, +142,PARP1,poly(ADP-ribose) polymerase 1,"Zinc fingers, Zinc fingers PARP-type, Poly(ADP-ribose) polymerases",landmark,, +7106,TSPAN4,tetraspanin 4,Tetraspanins,landmark,, +154,ADRB2,adrenoceptor beta 2,Adrenoceptors,landmark,, +9797,TATDN2,TatD DNase domain containing 2,,landmark,, +178,AGL,"amylo-alpha-1, 6-glucosidase, 4-alpha-glucanotransferase",,landmark,, +7153,TOP2A,topoisomerase (DNA) II alpha,Topoisomerases,landmark,, +7158,TP53BP1,tumor protein p53 binding protein 1,Tudor domain containing,landmark,, +7157,TP53,tumor protein p53,,landmark,, +7159,TP53BP2,tumor protein p53 binding protein 2,"Ankyrin repeat domain containing, Protein phosphatase 1 regulatory subunits",landmark,, +8480,RAE1,ribonucleic acid export 1,"WD repeat domain containing, Nucleoporins",landmark,, +7165,TPD52L2,tumor protein D52 like 2,,landmark,, +7168,TPM1,tropomyosin 1 (alpha),Tropomyosins,landmark,, +80349,SKIC8,WD repeat domain 61,"WD repeat domain containing, Paf1/RNA polymerase II complex ",landmark,, +9801,MRPL19,mitochondrial ribosomal protein L19,Mitochondrial ribosomal proteins,landmark,, +9805,SCRN1,secernin 1,,landmark,, +9813,EFCAB14,EF-hand calcium binding domain 14,EF-hand domain containing,landmark,, +9817,KEAP1,kelch like ECH associated protein 1,"Kelch like, BTB domain containing",landmark,, +9833,MELK,maternal embryonic leucine zipper kinase,,landmark,, +207,AKT1,AKT serine/threonine kinase 1,Pleckstrin homology domain containing,landmark,, +9842,PLEKHM1,pleckstrin homology and RUN domain containing M1,Pleckstrin homology domain containing,landmark,, +211,ALAS1,5'-aminolevulinate synthase 1,,landmark,, +8503,PIK3R3,phosphoinositide-3-kinase regulatory subunit 3,SH2 domain containing,landmark,, +8508,NIPSNAP1,nipsnap homolog 1,,landmark,, +8520,HAT1,histone acetyltransferase 1,Lysine acetyltransferases,landmark,, +9851,KIAA0753,KIAA0753,,landmark,, +9854,C2CD2L,C2CD2 like,,landmark,, +55556,ENOSF1,enolase superfamily member 1,,landmark,, +226,ALDOA,"aldolase, fructose-bisphosphate A",,landmark,, +9847,C2CD5,C2 calcium dependent domain containing 5,C2 domain containing,landmark,, +56889,TM9SF3,transmembrane 9 superfamily member 3,Transmembrane 9 superfamily members,landmark,, +8553,BHLHE40,basic helix-loop-helix family member e40,Basic helix-loop-helix proteins,landmark,, +8550,MAPKAPK5,mitogen-activated protein kinase-activated protein kinase 5,Mitogen-activated protein kinase-activated protein kinases,landmark,, +8574,AKR7A2,aldo-keto reductase family 7 member A2,Aldo-keto reductases,landmark,, +8569,MKNK1,MAP kinase interacting serine/threonine kinase 1,Mitogen-activated protein kinase-activated protein kinases,landmark,, +7264,GFUS,tissue specific transplantation antigen P35B,Short chain dehydrogenase/reductase superfamily,landmark,, +291,SLC25A4,solute carrier family 25 member 4,Solute carriers,landmark,, +91137,SLC25A46,solute carrier family 25 member 46,Solute carriers,landmark,, +7296,TXNRD1,thioredoxin reductase 1,Selenoproteins,landmark,, +79643,CHMP6,charged multivesicular body protein 6,"Charged multivesicular body proteins, ESCRT-III",landmark,, +55699,IARS2,"isoleucyl-tRNA synthetase 2, mitochondrial","Aminoacyl tRNA synthetases, Class I",landmark,, +54386,TERF2IP,TERF2 interacting protein,Shelterin complex,landmark,, +65057,ACD,"ACD, shelterin complex subunit and telomerase recruitment factor",Shelterin complex,landmark,, +55608,ANKRD10,ankyrin repeat domain 10,Ankyrin repeat domain containing,landmark,, +56940,DUSP22,dual specificity phosphatase 22,Atypical dual specificity phosphatases,landmark,, +9903,KLHL21,kelch like family member 21,"Kelch like, BTB domain containing",landmark,, +55620,STAP2,signal transducing adaptor family member 2,,landmark,, +9917,FAM20B,"FAM20B, glycosaminoglycan xylosylkinase",,landmark,, +9918,NCAPD2,non-SMC condensin I complex subunit D2,,landmark,, +9915,ARNT2,aryl hydrocarbon receptor nuclear translocator 2,Basic helix-loop-helix proteins,landmark,, +9924,PAN2,PAN2 poly(A) specific ribonuclease subunit,"Ubiquitin specific peptidases, Exonucleases",landmark,, +9928,KIF14,kinesin family member 14,Kinesins,landmark,, +9926,LPGAT1,lysophosphatidylglycerol acyltransferase 1,,landmark,, +9943,OXSR1,oxidative stress responsive 1,,landmark,, +310,ANXA7,annexin A7,Annexins,landmark,, +8607,RUVBL1,RuvB like AAA ATPase 1,"AAA ATPases, INO80 complex, DNA helicases, SRCAP complex, R2TP complex",landmark,, +323,APBB2,amyloid beta precursor protein binding family B member 2,,landmark,, +79600,TCTN1,tectonic family member 1,Tectonic proteins,landmark,, +329,BIRC2,baculoviral IAP repeat containing 2,"Ring finger proteins, Baculoviral IAP repeat containing, Caspase recruitment domain containing",landmark,, +9961,MVP,major vault protein,,landmark,, +332,BIRC5,baculoviral IAP repeat containing 5,"Baculoviral IAP repeat containing, Chromosomal passenger complex",landmark,, +8624,PSMG1,proteasome assembly chaperone 1,,landmark,, +348,APOE,apolipoprotein E,Apolipoproteins,landmark,, +351,APP,amyloid beta precursor protein,Endogenous ligands,landmark,, +355,FAS,Fas cell surface death receptor,"CD molecules, Tumor necrosis factor receptor superfamily, Death inducing signaling complex ",landmark,, +9988,DMTF1,cyclin D binding myb like transcription factor 1,Myb/SANT domain containing,landmark,, +8678,BECN1,beclin 1,Autophagy related,landmark,, +6009,RHEB,Ras homolog enriched in brain,RAS type GTPase family,landmark,, +7358,UGDH,UDP-glucose 6-dehydrogenase,,landmark,, +387,RHOA,ras homolog family member A,Rho family GTPases,landmark,, +392,ARHGAP1,Rho GTPase activating protein 1,"Rho GTPase activating proteins, BCH domain containing ",landmark,, +7376,NR1H2,nuclear receptor subfamily 1 group H member 2,Nuclear hormone receptors,landmark,, +6050,RNH1,ribonuclease/angiogenin inhibitor 1,,landmark,, +7398,USP1,ubiquitin specific peptidase 1,Ubiquitin specific peptidases,landmark,, +56924,PAK6,p21 (RAC1) activated kinase 6,,landmark,, +65123,INTS3,integrator complex subunit 3,Integrator complex,landmark,, +80746,TSEN2,tRNA splicing endonuclease subunit 2,tRNA-splicing endonuclease subunits,landmark,, +54499,TMCO1,transmembrane and coiled-coil domains 1,,landmark,, +55748,CNDP2,CNDP dipeptidase 2 (metallopeptidase M20 family),,landmark,, +55746,NUP133,nucleoporin 133,"Minor histocompatibility antigens, Nucleoporins",landmark,, +79716,NPEPL1,aminopeptidase-like 1,Aminopeptidases,landmark,, +8720,MBTPS1,"membrane bound transcription factor peptidase, site 1",Proprotein convertase subtilisin/kexin family,landmark,, +427,ASAH1,N-acylsphingosine amidohydrolase 1,,landmark,, +8731,RNMT,RNA guanine-7 methyltransferase,Seven-beta-strand methyltransferase motif containing,landmark,, +8726,EED,embryonic ectoderm development,"WD repeat domain containing, Polycomb repressive complex 2",landmark,, +54438,GFOD1,glucose-fructose oxidoreductase domain containing 1,,landmark,, +8727,CTNNAL1,catenin alpha like 1,,landmark,, +54442,KCTD5,potassium channel tetramerization domain containing 5,,landmark,, +466,ATF1,activating transcription factor 1,Basic leucine zipper proteins,landmark,, +481,ATP1B1,ATPase Na+/K+ transporting subunit beta 1,ATPase Na+/K+ transporting subunits,landmark,, +6117,RPA1,replication protein A1,Nucleotide excision repair,landmark,, +6119,RPA3,replication protein A3,Nucleotide excision repair,landmark,, +6118,RPA2,replication protein A2,Nucleotide excision repair,landmark,, +7466,WFS1,wolframin ER transmembrane glycoprotein,,landmark,, +7485,GET1,tryptophan rich basic protein,,landmark,, +7494,XBP1,X-box binding protein 1,Basic leucine zipper proteins,landmark,, +6184,RPN1,ribophorin I,Oligosaccharyltransferase complex subunits,landmark,, +6193,RPS5,ribosomal protein S5,S ribosomal proteins,landmark,, +6195,RPS6KA1,ribosomal protein S6 kinase A1,Mitogen-activated protein kinase-activated protein kinases,landmark,, +6194,RPS6,ribosomal protein S6,S ribosomal proteins,landmark,, +388650,DIPK1A,family with sequence similarity 69 member A,,landmark,, +501,ALDH7A1,aldehyde dehydrogenase 7 family member A1,Aldehyde dehydrogenases,landmark,, +54512,EXOSC4,exosome component 4,Exosome complex,landmark,, +55847,CISD1,CDGSH iron sulfur domain 1,CDGSH iron sulfur domain containing,landmark,, +8800,PEX11A,peroxisomal biogenesis factor 11 alpha,Peroxins,landmark,, +8804,CREG1,cellular repressor of E1A stimulated genes 1,,landmark,, +8821,INPP4B,inositol polyphosphate-4-phosphatase type II B,"C2 domain containing, Phosphoinositide phosphatases",landmark,, +533,ATP6V0B,ATPase H+ transporting V0 subunit b,V-type ATPases,landmark,, +8826,IQGAP1,IQ motif containing GTPase activating protein 1,,landmark,, +7511,XPNPEP1,X-prolyl aminopeptidase 1,Aminopeptidases,landmark,, +54541,DDIT4,DNA damage inducible transcript 4,,landmark,, +8835,SOCS2,suppressor of cytokine signaling 2,"SH2 domain containing, Suppressors of cytokine signaling",landmark,, +8837,CFLAR,CASP8 and FADD like apoptosis regulator,"Endogenous ligands, Death effector domain containing, Death inducing signaling complex ",landmark,, +8851,CDK5R1,cyclin dependent kinase 5 regulatory subunit 1,,landmark,, +79850,TLCD3A,family with sequence similarity 57 member A,TLC domain containing,landmark,, +55893,ZNF395,zinc finger protein 395,Zinc fingers C2H2-type,landmark,, +8870,IER3,immediate early response 3,,landmark,, +572,BAD,BCL2 associated agonist of cell death,BCL2 homology region 3 (BH3) only,landmark,, +8869,ST3GAL5,"ST3 beta-galactoside alpha-2,3-sialyltransferase 5",Sialyltransferases,landmark,, +7538,ZFP36,ZFP36 ring finger protein,Ring finger proteins,landmark,, +8884,SLC5A6,solute carrier family 5 member 6,Solute carriers,landmark,, +581,BAX,"BCL2 associated X, apoptosis regulator",BCL2 family,landmark,, +8878,SQSTM1,sequestosome 1,Zinc fingers ZZ-type,landmark,, +8895,CPNE3,copine 3,Copines,landmark,, +595,CCND1,cyclin D1,Cyclins,landmark,, +596,BCL2,"BCL2, apoptosis regulator","Protein phosphatase 1 regulatory subunits, BCL2 family",landmark,, +6251,RSU1,Ras suppressor protein 1,,landmark,, +6253,RTN2,reticulon 2,,landmark,, +6275,S100A4,S100 calcium binding protein A4,"S100 calcium binding proteins, EF-hand domain containing",landmark,, +6284,S100A13,S100 calcium binding protein A13,S100 calcium binding proteins,landmark,, +55818,KDM3A,lysine demethylase 3A,Lysine demethylases,landmark,, +55825,PECR,peroxisomal trans-2-enoyl-CoA reductase,Short chain dehydrogenase/reductase superfamily,landmark,, +54505,DHX29,DExH-box helicase 29,DEAH-box helicases,landmark,, +55837,EAPP,E2F associated phosphoprotein,,landmark,, +27095,TRAPPC3,trafficking protein particle complex 3,Trafficking protein particle complex,landmark,, +64080,RBKS,ribokinase,,landmark,, +8900,CCNA1,cyclin A1,Cyclins,landmark,, +27032,ATP2C1,ATPase secretory pathway Ca2+ transporting 1,ATPases Ca2+ transporting,landmark,, +622,BDH1,3-hydroxybutyrate dehydrogenase 1,Short chain dehydrogenase/reductase superfamily,landmark,, +8914,TIMELESS,timeless circadian clock,,landmark,, +637,BID,BH3 interacting domain death agonist,"Endogenous ligands, BCL2 homology region 3 (BH3) only",landmark,, +642,BLMH,bleomycin hydrolase,,landmark,, +644,BLVRA,biliverdin reductase A,,landmark,, +652,BMP4,bone morphogenetic protein 4,"Bone morphogenetic proteins, Endogenous ligands",landmark,, +664,BNIP3,BCL2 interacting protein 3,BCL2 homology region 3 (BH3) only,landmark,, +665,BNIP3L,BCL2 interacting protein 3 like,,landmark,, +79947,DHDDS,dehydrodolichyl diphosphate synthase subunit,,landmark,, +8974,P4HA2,prolyl 4-hydroxylase subunit alpha 2,,landmark,, +670,BPHL,biphenyl hydrolase like,,landmark,, +672,BRCA1,"BRCA1, DNA repair associated","Ring finger proteins, Fanconi anemia complementation groups, Protein phosphatase 1 regulatory subunits, BRCA1 A complex, BRCA1 B complex, BRCA1 C complex",landmark,, +53343,NUDT9,nudix hydrolase 9,Nudix hydrolase family,landmark,, +8985,PLOD3,"procollagen-lysine,2-oxoglutarate 5-dioxygenase 3",,landmark,, +54681,P4HTM,"prolyl 4-hydroxylase, transmembrane",,landmark,, +79961,DENND2D,DENN domain containing 2D,DENN/MADD domain containing,landmark,, +8996,NOL3,nucleolar protein 3,Caspase recruitment domain containing,landmark,, +6342,SCP2,sterol carrier protein 2,,landmark,, +6347,CCL2,C-C motif chemokine ligand 2,"Chemokine ligands, Endogenous ligands",landmark,, +5018,OXA1L,"OXA1L, mitochondrial inner membrane protein",,landmark,, +5019,OXCT1,3-oxoacid CoA-transferase 1,,landmark,, +7690,ZNF131,zinc finger protein 131,"Zinc fingers C2H2-type, BTB domain containing",landmark,, +5048,PAFAH1B1,platelet activating factor acetylhydrolase 1b regulatory subunit 1,WD repeat domain containing,landmark,, +5054,SERPINE1,serpin family E member 1,Serpin peptidase inhibitors,landmark,, +5058,PAK1,p21 (RAC1) activated kinase 1,,landmark,, +5050,PAFAH1B3,platelet activating factor acetylhydrolase 1b catalytic subunit 3,,landmark,, +6390,SDHB,succinate dehydrogenase complex iron sulfur subunit B,Mitochondrial complex II: succinate dehydrogenase subunits,landmark,, +5096,PCCB,propionyl-CoA carboxylase beta subunit,,landmark,, +5092,PCBD1,pterin-4 alpha-carbinolamine dehydratase 1,,landmark,, +79902,NUP85,nucleoporin 85,Nucleoporins,landmark,, +54623,PAF1,"PAF1 homolog, Paf1/RNA polymerase II complex component",Paf1/RNA polymerase II complex,landmark,, +55958,KLHL9,kelch like family member 9,"Kelch like, BTB domain containing",landmark,, +727,C5,complement C5,"Complement system, Endogenous ligands, C3 and PZP like, alpha-2-macroglobulin domain containing",landmark,, +7750,ZMYM2,zinc finger MYM-type containing 2,Zinc fingers MYM-type,landmark,, +780,DDR1,discoidin domain receptor tyrosine kinase 1,"Receptor Tyrosine Kinases, CD molecules",landmark,, +5110,PCMT1,protein-L-isoaspartate (D-aspartate) O-methyltransferase,Seven-beta-strand methyltransferase motif containing,landmark,, +5111,PCNA,proliferating cell nuclear antigen,,landmark,, +6443,SGCB,sarcoglycan beta,,landmark,, +5106,PCK2,"phosphoenolpyruvate carboxykinase 2, mitochondrial",,landmark,, +5108,PCM1,pericentriolar material 1,,landmark,, +6464,SHC1,SHC adaptor protein 1,SH2 domain containing,landmark,, +5154,PDGFA,platelet derived growth factor subunit A,,landmark,, +6499,SKIC2,Ski2 like RNA helicase,RNA helicases,landmark,, +54733,SLC35F2,solute carrier family 35 member F2,Solute carriers,landmark,, +29763,PACSIN3,protein kinase C and casein kinase substrate in neurons 3,F-BAR domain containing,landmark,, +808,CALM3,calmodulin 3,"Endogenous ligands, EF-hand domain containing",landmark,, +813,CALU,calumenin,EF-hand domain containing,landmark,, +823,CAPN1,calpain 1,"EF-hand domain containing, Calpains",landmark,, +831,CAST,calpastatin,,landmark,, +835,CASP2,caspase 2,"Caspases, Protein phosphatase 1 regulatory subunits, Caspase recruitment domain containing",landmark,, +836,CASP3,caspase 3,Caspases,landmark,, +840,CASP7,caspase 7,Caspases,landmark,, +843,CASP10,caspase 10,"Caspases, Death effector domain containing, Death inducing signaling complex ",landmark,, +847,CAT,catalase,,landmark,, +6500,SKP1,S-phase kinase associated protein 1,SCF complex,landmark,, +868,CBLB,Cbl proto-oncogene B,Ring finger proteins,landmark,, +6509,SLC1A4,solute carrier family 1 member 4,Solute carriers,landmark,, +873,CBR1,carbonyl reductase 1,Short chain dehydrogenase/reductase superfamily,landmark,, +874,CBR3,carbonyl reductase 3,Short chain dehydrogenase/reductase superfamily,landmark,, +7852,CXCR4,C-X-C motif chemokine receptor 4,"CD molecules, C-X-C motif chemokine receptors",landmark,, +54881,TEX10,testis expressed 10,5FMC ribosome biogenesis complex,landmark,, +7849,PAX8,paired box 8,"PRD class homeoboxes and pseudogenes, Paired boxes",landmark,, +890,CCNA2,cyclin A2,Cyclins,landmark,, +891,CCNB1,cyclin B1,Cyclins,landmark,, +896,CCND3,cyclin D3,Cyclins,landmark,, +899,CCNF,cyclin F,"Cyclins, F-boxes other",landmark,, +5211,PFKL,"phosphofructokinase, liver type",,landmark,, +7874,USP7,ubiquitin specific peptidase 7,Ubiquitin specific peptidases,landmark,, +7866,IFRD2,interferon related developmental regulator 2,,landmark,, +7867,MAPKAPK3,mitogen-activated protein kinase-activated protein kinase 3,Mitogen-activated protein kinase-activated protein kinases,landmark,, +5236,PGM1,phosphoglucomutase 1,,landmark,, +5257,PHKB,phosphorylase kinase regulatory subunit beta,,landmark,, +5261,PHKG2,phosphorylase kinase catalytic subunit gamma 2,,landmark,, +6597,SMARCA4,"SWI/SNF related, matrix associated, actin dependent regulator of chromatin, subfamily a, member 4",,landmark,, +6599,SMARCC1,"SWI/SNF related, matrix associated, actin dependent regulator of chromatin subfamily c member 1",Myb/SANT domain containing,landmark,, +54807,ZNF586,zinc finger protein 586,Zinc fingers C2H2-type,landmark,, +5287,PIK3C2B,phosphatidylinositol-4-phosphate 3-kinase catalytic subunit type 2 beta,Phosphatidylinositol 3-kinase subunits,landmark,, +5289,PIK3C3,phosphatidylinositol 3-kinase catalytic subunit type 3,Phosphatidylinositol 3-kinase subunits,landmark,, +5290,PIK3CA,"phosphatidylinositol-4,5-bisphosphate 3-kinase catalytic subunit alpha",Phosphatidylinositol 3-kinase subunits,landmark,, +54850,FBXL12,F-box and leucine rich repeat protein 12,F-box and leucine rich repeat proteins,landmark,, +27242,TNFRSF21,TNF receptor superfamily member 21,"CD molecules, Tumor necrosis factor receptor superfamily",landmark,, +27244,SESN1,sestrin 1,,landmark,, +29890,RBM15B,RNA binding motif protein 15B,RNA binding motif containing,landmark,, +30849,PIK3R4,phosphoinositide-3-kinase regulatory subunit 4,WD repeat domain containing,landmark,, +902,CCNH,cyclin H,Cyclins,landmark,, +30836,DNTTIP2,deoxynucleotidyltransferase terminal interacting protein 2,,landmark,, +51097,SCCPDH,saccharopine dehydrogenase (putative),,landmark,, +949,SCARB1,scavenger receptor class B member 1,Scavenger receptors,landmark,, +7905,REEP5,receptor accessory protein 5,Receptor accessory proteins,landmark,, +958,CD40,CD40 molecule,"CD molecules, Tumor necrosis factor receptor superfamily",landmark,, +960,CD44,CD44 molecule (Indian blood group),"Blood group antigens, CD molecules, Proteoglycans",landmark,, +26054,SENP6,SUMO1/sentrin specific peptidase 6,SUMO specific peptidases,landmark,, +6603,SMARCD2,"SWI/SNF related, matrix associated, actin dependent regulator of chromatin, subfamily d, member 2",,landmark,, +26064,RAI14,retinoic acid induced 14,Ankyrin repeat domain containing,landmark,, +983,CDK1,cyclin dependent kinase 1,Cyclin dependent kinases,landmark,, +6616,SNAP25,synaptosome associated protein 25,SNAREs,landmark,, +5300,PIN1,"peptidylprolyl cis/trans isomerase, NIMA-interacting 1",Parvulins,landmark,, +991,CDC20,cell division cycle 20,WD repeat domain containing,landmark,, +993,CDC25A,cell division cycle 25A,Class III Cys-based CDC25 phosphatases,landmark,, +994,CDC25B,cell division cycle 25B,Class III Cys-based CDC25 phosphatases,landmark,, +998,CDC42,cell division cycle 42,Rho family GTPases,landmark,, +51005,AMDHD2,amidohydrolase domain containing 2,,landmark,, +6622,SNCA,synuclein alpha,Parkinson disease associated genes,landmark,, +51015,ISOC1,isochorismatase domain containing 1,,landmark,, +5321,PLA2G4A,phospholipase A2 group IVA,"Phospholipases, C2 domain containing phospholipases",landmark,, +7982,ST7,suppression of tumorigenicity 7,,landmark,, +51021,MRPS16,mitochondrial ribosomal protein S16,Mitochondrial ribosomal proteins,landmark,, +51024,FIS1,"fission, mitochondrial 1",Tetratricopeptide repeat domain containing,landmark,, +51026,GOLT1B,golgi transport 1B,,landmark,, +91949,COG7,component of oligomeric golgi complex 7,Components of oligomeric golgi complex,landmark,, +5331,PLCB3,phospholipase C beta 3,"Phospholipases, C2 domain containing phospholipases",landmark,, +7994,KAT6A,lysine acetyltransferase 6A,"Zinc fingers C2HC-type, PHD finger proteins, Lysine acetyltransferases",landmark,, +51031,GLOD4,glyoxalase domain containing 4,,landmark,, +6659,SOX4,SRY-box 4,SRY-boxes,landmark,, +6676,SPAG4,sperm associated antigen 4,,landmark,, +51053,GMNN,"geminin, DNA replication inhibitor",,landmark,, +51056,LAP3,leucine aminopeptidase 3,Aminopeptidases,landmark,, +4016,LOXL1,lysyl oxidase like 1,,landmark,, +5347,PLK1,polo like kinase 1,,landmark,, +6696,SPP1,secreted phosphoprotein 1,"Endogenous ligands, SIBLING family",landmark,, +5366,PMAIP1,phorbol-12-myristate-13-acetate-induced protein 1,BCL2 homology region 3 (BH3) only,landmark,, +6697,SPR,"sepiapterin reductase (7,8-dihydrobiopterin:NADP+ oxidoreductase)",Short chain dehydrogenase/reductase superfamily,landmark,, +5357,PLS1,plastin 1,EF-hand domain containing,landmark,, +5359,PLSCR1,phospholipid scramblase 1,Phospholipid scramblases,landmark,, +5373,PMM2,phosphomannomutase 2,HAD Asp-based non-protein phosphatases,landmark,, +4043,LRPAP1,LDL receptor related protein associated protein 1,,landmark,, +51070,NOSIP,nitric oxide synthase interacting protein,,landmark,, +51071,DERA,deoxyribose-phosphate aldolase,,landmark,, +54915,YTHDF1,YTH N6-methyladenosine RNA binding protein 1,,landmark,, +4067,LYN,"LYN proto-oncogene, Src family tyrosine kinase","SH2 domain containing, Src family tyrosine kinases",landmark,, +29928,TIMM22,translocase of inner mitochondrial membrane 22,TIM22 complex,landmark,, +4088,SMAD3,SMAD family member 3,SMAD family,landmark,, +29937,NENF,neudesin neurotrophic factor,Membrane associated progesterone receptor family,landmark,, +54957,TXNL4B,thioredoxin like 4B,,landmark,, +29911,HOOK2,hook microtubule tethering protein 2,,landmark,, +29916,SNX11,sorting nexin 11,Sorting nexins,landmark,, +26036,ZNF451,zinc finger protein 451,Zinc fingers C2H2-type,landmark,, +27346,TMEM97,transmembrane protein 97,,landmark,, +26020,LRP10,LDL receptor related protein 10,Low density lipoprotein receptors,landmark,, +26001,RNF167,ring finger protein 167,Ring finger proteins,landmark,, +6709,SPTAN1,"spectrin alpha, non-erythrocytic 1","EF-hand domain containing, Spectrins",landmark,, +6714,SRC,"SRC proto-oncogene, non-receptor tyrosine kinase","SH2 domain containing, Src family tyrosine kinases",landmark,, +5423,POLB,DNA polymerase beta,DNA polymerases,landmark,, +5427,POLE2,"DNA polymerase epsilon 2, accessory subunit",DNA polymerases,landmark,, +5440,POLR2K,RNA polymerase II subunit K,RNA polymerase subunits,landmark,, +6772,STAT1,signal transducer and activator of transcription 1,SH2 domain containing,landmark,, +6774,STAT3,signal transducer and activator of transcription 3,SH2 domain containing,landmark,, +5438,POLR2I,RNA polymerase II subunit I,RNA polymerase subunits,landmark,, +6777,STAT5B,signal transducer and activator of transcription 5B,SH2 domain containing,landmark,, +6790,AURKA,aurora kinase A,Protein phosphatase 1 regulatory subunits,landmark,, +6793,STK10,serine/threonine kinase 10,,landmark,, +51160,VPS28,"VPS28, ESCRT-I subunit",ESCRT-I,landmark,, +4125,MAN2B1,mannosidase alpha class 2B member 1,Mannosidases alpha class 2,landmark,, +4144,MAT2A,methionine adenosyltransferase 2A,,landmark,, +5467,PPARD,peroxisome proliferator activated receptor delta,Nuclear hormone receptors,landmark,, +5468,PPARG,peroxisome proliferator activated receptor gamma,Nuclear hormone receptors,landmark,, +4154,MBNL1,muscleblind like splicing regulator 1,Zinc fingers CCCH-type,landmark,, +5480,PPIC,peptidylprolyl isomerase C,Cyclophilin peptidylprolyl isomerases,landmark,, +5498,PPOX,protoporphyrinogen oxidase,,landmark,, +4172,MCM3,minichromosome maintenance complex component 3,MCM family,landmark,, +64422,ATG3,autophagy related 3,Autophagy related,landmark,, +64429,ZDHHC6,zinc finger DHHC-type containing 6,Zinc fingers DHHC-type,landmark,, +64428,CIAO3,nuclear prelamin A recognition factor like,Cytosolic iron-sulfur assembly components,landmark,, +51116,MRPS2,mitochondrial ribosomal protein S2,Mitochondrial ribosomal proteins,landmark,, +26136,TES,testin LIM domain protein,LIM domain containing,landmark,, +6804,STX1A,syntaxin 1A,Syntaxins,landmark,, +6810,STX4,syntaxin 4,Syntaxins,landmark,, +6812,STXBP1,syntaxin binding protein 1,,landmark,, +6813,STXBP2,syntaxin binding protein 2,,landmark,, +6832,SUPV3L1,Suv3 like RNA helicase,RNA helicases,landmark,, +6850,SYK,spleen associated tyrosine kinase,SH2 domain containing,landmark,, +26292,MYCBP,MYC binding protein,,landmark,, +4200,ME2,malic enzyme 2,,landmark,, +6856,SYPL1,synaptophysin like 1,,landmark,, +5525,PPP2R5A,protein phosphatase 2 regulatory subunit B'alpha,Protein phosphatase 2 regulatory subunits,landmark,, +5529,PPP2R5E,protein phosphatase 2 regulatory subunit B'epsilon,Protein phosphatase 2 regulatory subunits,landmark,, +4208,MEF2C,myocyte enhancer factor 2C,"Myocyte enhancer factor 2 proteins, MADS box family",landmark,, +4216,MAP3K4,mitogen-activated protein kinase kinase kinase 4,Mitogen-activated protein kinase kinase kinases,landmark,, +5547,PRCP,prolylcarboxypeptidase,"Minor histocompatibility antigens, Carboxypeptidases",landmark,, +6894,TARBP1,TAR (HIV-1) RNA binding protein 1,SPOUT methyltranferase domain containing,landmark,, +4232,MEST,mesoderm specific transcript,,landmark,, +5566,PRKACA,protein kinase cAMP-activated catalytic subunit alpha,,landmark,, +5580,PRKCD,protein kinase C delta,C2 domain containing protein kinases,landmark,, +51282,SCAND1,SCAN domain containing 1,SCAN domain containing,landmark,, +51293,CD320,CD320 molecule,CD molecules,landmark,, +5588,PRKCQ,protein kinase C theta,C2 domain containing protein kinases,landmark,, +51203,NUSAP1,nucleolar and spindle associated protein 1,,landmark,, +26227,PHGDH,phosphoglycerate dehydrogenase,,landmark,, +6908,TBP,TATA-box binding protein,General transcription factors,landmark,, +6909,TBX2,T-box 2,T-boxes,landmark,, +6919,TCEA2,transcription elongation factor A2,,landmark,, +6915,TBXA2R,thromboxane A2 receptor,Prostaglandin (prostanoid) receptors,landmark,, +5607,MAP2K5,mitogen-activated protein kinase kinase 5,Mitogen-activated protein kinase kinases,landmark,, +5601,MAPK9,mitogen-activated protein kinase 9,Mitogen-activated protein kinases,landmark,, +5603,MAPK13,mitogen-activated protein kinase 13,Mitogen-activated protein kinases,landmark,, +6944,VPS72,vacuolar protein sorting 72 homolog,SRCAP complex,landmark,, +5627,PROS1,protein S (alpha),Gla domain containing,landmark,, +5641,LGMN,legumain,,landmark,, +4312,MMP1,matrix metallopeptidase 1,"Endogenous ligands, Matrix metallopeptidases",landmark,, +4313,MMP2,matrix metallopeptidase 2,Matrix metallopeptidases,landmark,, +5654,HTRA1,HtrA serine peptidase 1,"Proteases, serine, PDZ domain containing",landmark,, +51375,SNX7,sorting nexin 7,"Sorting nexins, PX-BAR domain containing",landmark,, +51382,ATP6V1D,ATPase H+ transporting V1 subunit D,V-type ATPases,landmark,, +51385,ZNF589,zinc finger protein 589,Zinc fingers C2H2-type,landmark,, +5696,PSMB8,proteasome subunit beta 8,Proteasome,landmark,, +3033,HADH,hydroxyacyl-CoA dehydrogenase,,landmark,, +3066,HDAC2,histone deacetylase 2,"Histone deacetylases, class I, EMSY complex, NuRD complex, SIN3 histone deacetylase complex",landmark,, +3098,HK1,hexokinase 1,,landmark,, +89910,UBE3B,ubiquitin protein ligase E3B,,landmark,, +28969,BZW2,basic leucine zipper and W2 domains 2,,landmark,, +5710,PSMD4,"proteasome 26S subunit, non-ATPase 4",Proteasome,landmark,, +5720,PSME1,proteasome activator subunit 1,Proteasome,landmark,, +5743,PTGS2,prostaglandin-endoperoxide synthase 2,,landmark,, +5747,PTK2,protein tyrosine kinase 2,"Protein phosphatase 1 regulatory subunits, FERM domain containing",landmark,, +5770,PTPN1,"protein tyrosine phosphatase, non-receptor type 1","Protein tyrosine phosphatases, non-receptor type",landmark,, +5782,PTPN12,"protein tyrosine phosphatase, non-receptor type 12","Protein tyrosine phosphatases, non-receptor type",landmark,, +3122,HLA-DRA,"major histocompatibility complex, class II, DR alpha","Histocompatibility complex, C1-set domain containing",landmark,, +5777,PTPN6,"protein tyrosine phosphatase, non-receptor type 6","SH2 domain containing, Protein tyrosine phosphatases, non-receptor type",landmark,, +5792,PTPRF,"protein tyrosine phosphatase, receptor type F","Fibronectin type III domain containing, I-set domain containing, Protein tyrosine phosphatases, receptor type",landmark,, +5796,PTPRK,"protein tyrosine phosphatase, receptor type K","Fibronectin type III domain containing, Immunoglobulin like domain containing, Protein tyrosine phosphatases, receptor type",landmark,, +5788,PTPRC,"protein tyrosine phosphatase, receptor type C","CD molecules, Fibronectin type III domain containing, Protein tyrosine phosphatases, receptor type",landmark,, +4482,MSRA,methionine sulfoxide reductase A,,landmark,, +3156,HMGCR,3-hydroxy-3-methylglutaryl-CoA reductase,,landmark,, +3162,HMOX1,heme oxygenase 1,,landmark,, +3157,HMGCS1,3-hydroxy-3-methylglutaryl-CoA synthase 1,,landmark,, +51422,PRKAG2,protein kinase AMP-activated non-catalytic subunit gamma 2,,landmark,, +64746,ACBD3,acyl-CoA binding domain containing 3,A-kinase anchoring proteins,landmark,, +51465,UBE2J1,ubiquitin conjugating enzyme E2 J1,Ubiquitin conjugating enzymes E2,landmark,, +51466,EVL,Enah/Vasp-like,ENAH/VASPs,landmark,, +64781,CERK,ceramide kinase,,landmark,, +5829,PXN,paxillin,LIM domain containing,landmark,, +5831,PYCR1,pyrroline-5-carboxylate reductase 1,,landmark,, +5836,PYGL,glycogen phosphorylase L,Glycogen phosphorylases,landmark,, +5873,RAB27A,"RAB27A, member RAS oncogene family","RAB, member RAS oncogene GTPases",landmark,, +5867,RAB4A,"RAB4A, member RAS oncogene family","RAB, member RAS oncogene GTPases",landmark,, +5883,RAD9A,RAD9 checkpoint clamp component A,Checkpoint clamp complex,landmark,, +5891,MOK,MOK protein kinase,,landmark,, +5889,RAD51C,RAD51 paralog C,Fanconi anemia complementation groups,landmark,, +5898,RALA,RAS like proto-oncogene A,RAS type GTPase family,landmark,, +5899,RALB,RAS like proto-oncogene B,RAS type GTPase family,landmark,, +4582,MUC1,"mucin 1, cell surface associated","CD molecules, Mucins",landmark,, +3280,HES1,hes family bHLH transcription factor 1,Basic helix-loop-helix proteins,landmark,, +26511,CHIC2,cysteine rich hydrophobic domain 2,,landmark,, +51569,UFM1,ubiquitin fold modifier 1,,landmark,, +26520,TIMM9,translocase of inner mitochondrial membrane 9,TIM22 complex,landmark,, +51599,LSR,lipolysis stimulated lipoprotein receptor,Immunoglobulin like domain containing,landmark,, +5909,RAP1GAP,RAP1 GTPase activating protein,,landmark,, +5900,RALGDS,ral guanine nucleotide dissociation stimulator,,landmark,, +5927,KDM5A,lysine demethylase 5A,"PHD finger proteins, AT-rich interaction domain containing, Lysine demethylases, EMSY complex",landmark,, +5921,RASA1,RAS p21 protein activator 1,"Pleckstrin homology domain containing, SH2 domain containing, C2 and RasGAP domain containing",landmark,, +5925,RB1,RB transcriptional corepressor 1,Endogenous ligands,landmark,, +4609,MYC,v-myc avian myelocytomatosis viral oncogene homolog,Basic helix-loop-helix proteins,landmark,, +4605,MYBL2,MYB proto-oncogene like 2,Myb/SANT domain containing,landmark,, +4616,GADD45B,growth arrest and DNA damage inducible beta,,landmark,, +5971,RELB,"RELB proto-oncogene, NF-kB subunit",NF-kappa B complex subunits,landmark,, +3303,HSPA1A,heat shock protein family A (Hsp70) member 1A,Heat shock 70kDa proteins,landmark,, +3300,DNAJB2,DnaJ heat shock protein family (Hsp40) member B2,DNAJ (HSP40) heat shock proteins,landmark,, +3308,HSPA4,heat shock protein family A (Hsp70) member 4,Heat shock 70kDa proteins,landmark,, +4638,MYLK,myosin light chain kinase,"Fibronectin type III domain containing, I-set domain containing",landmark,, +4651,MYO10,myosin X,"Pleckstrin homology domain containing, Myosins, class X, FERM domain containing",landmark,, +5982,RFC2,replication factor C subunit 2,AAA ATPases,landmark,, +3312,HSPA8,heat shock protein family A (Hsp70) member 8,"Heat shock 70kDa proteins, NineTeen complex",landmark,, +5993,RFX5,regulatory factor X5,Regulatory factor X family,landmark,, +3329,HSPD1,heat shock protein family D (Hsp60) member 1,Chaperonins,landmark,, +5985,RFC5,replication factor C subunit 5,AAA ATPases,landmark,, +5986,RFNG,RFNG O-fucosylpeptide 3-beta-N-acetylglucosaminyltransferase,Beta 3-glycosyltransferases,landmark,, +3337,DNAJB1,DnaJ heat shock protein family (Hsp40) member B1,DNAJ (HSP40) heat shock proteins,landmark,, +5997,RGS2,regulator of G-protein signaling 2,"Endogenous ligands, Regulators of G-protein signaling",landmark,, +2017,CTTN,cortactin,,landmark,, +2042,EPHA3,EPH receptor A3,"Fibronectin type III domain containing, Sterile alpha motif domain containing, EPH receptors",landmark,, +2037,EPB41L2,erythrocyte membrane protein band 4.1 like 2,"Erythrocyte membrane protein band 4.1, FERM domain containing",landmark,, +3385,ICAM3,intercellular adhesion molecule 3,"CD molecules, Immunoglobulin like domain containing",landmark,, +3383,ICAM1,intercellular adhesion molecule 1,"CD molecules, Endogenous ligands, Immunoglobulin like domain containing",landmark,, +2048,EPHB2,EPH receptor B2,"Fibronectin type III domain containing, Sterile alpha motif domain containing, EPH receptors",landmark,, +3398,ID2,"inhibitor of DNA binding 2, HLH protein",Basic helix-loop-helix proteins,landmark,, +2065,ERBB3,erb-b2 receptor tyrosine kinase 3,Erb-b2 receptor tyrosine kinases,landmark,, +2064,ERBB2,erb-b2 receptor tyrosine kinase 2,"CD molecules, Minor histocompatibility antigens, Erb-b2 receptor tyrosine kinases",landmark,, +2063,NR2F6,nuclear receptor subfamily 2 group F member 6,Nuclear hormone receptors,landmark,, +2058,EPRS1,glutamyl-prolyl-tRNA synthetase,"Aminoacyl tRNA synthetases, Class I, Aminoacyl tRNA synthetases, Class II",landmark,, +64943,NT5DC2,5'-nucleotidase domain containing 2,,landmark,, +51635,DHRS7,dehydrogenase/reductase 7,Short chain dehydrogenase/reductase superfamily,landmark,, +3416,IDE,insulin degrading enzyme,,landmark,, +2109,ETFB,electron transfer flavoprotein beta subunit,,landmark,, +4780,NFE2L2,"nuclear factor, erythroid 2 like 2",Basic leucine zipper proteins,landmark,, +4783,NFIL3,"nuclear factor, interleukin 3 regulated",Basic leucine zipper proteins,landmark,, +2115,ETV1,ETS variant 1,ETS transcription factor family,landmark,, +2113,ETS1,"ETS proto-oncogene 1, transcription factor",ETS transcription factor family,landmark,, +4775,NFATC3,nuclear factor of activated T-cells 3,Nuclear factors of activated T-cells,landmark,, +4776,NFATC4,nuclear factor of activated T-cells 4,Nuclear factors of activated T-cells,landmark,, +4791,NFKB2,nuclear factor kappa B subunit 2,"Ankyrin repeat domain containing, NF-kappa B complex subunits",landmark,, +4792,NFKBIA,NFKB inhibitor alpha,Ankyrin repeat domain containing,landmark,, +4793,NFKBIB,NFKB inhibitor beta,Ankyrin repeat domain containing,landmark,, +4794,NFKBIE,NFKB inhibitor epsilon,Ankyrin repeat domain containing,landmark,, +2131,EXT1,exostosin glycosyltransferase 1,Exostosin glycosyltransferase family,landmark,, +3454,IFNAR1,interferon alpha and beta receptor subunit 1,Interferon receptors,landmark,, +3486,IGFBP3,insulin like growth factor binding protein 3,Insulin like growth factor binding proteins,landmark,, +3482,IGF2R,insulin like growth factor 2 receptor,"CD molecules, MRH domain containing ",landmark,, +3480,IGF1R,insulin like growth factor 1 receptor,"Receptor Tyrosine Kinases, CD molecules, Fibronectin type III domain containing",landmark,, +2146,EZH2,enhancer of zeste 2 polycomb repressive complex 2 subunit,"Lysine methyltransferases, Myb/SANT domain containing, Polycomb repressive complex 2, SET domain containing",landmark,, +2185,PTK2B,protein tyrosine kinase 2 beta,"Minor histocompatibility antigens, FERM domain containing",landmark,, +2184,FAH,fumarylacetoacetate hydrolase,,landmark,, +2195,FAT1,FAT atypical cadherin 1,Cadherin related,landmark,, +24149,ZNF318,zinc finger protein 318,Zinc fingers C2H2-type,landmark,, +51719,CAB39,calcium binding protein 39,,landmark,, +4817,NIT1,nitrilase 1,,landmark,, +51742,ARID4B,AT-rich interaction domain 4B,AT-rich interaction domain containing,landmark,, +3508,IGHMBP2,immunoglobulin mu binding protein 2,"Zinc fingers AN1-type, UPF1 like RNA helicases",landmark,, +4836,NMT1,N-myristoyltransferase 1,,landmark,, +4850,CNOT4,CCR4-NOT transcription complex subunit 4,"RNA binding motif containing, CCR4-NOT transcription complex",landmark,, +4846,NOS3,nitric oxide synthase 3,,landmark,, +4860,PNP,purine nucleoside phosphorylase,,landmark,, +4851,NOTCH1,notch 1,Ankyrin repeat domain containing,landmark,, +4864,NPC1,NPC intracellular cholesterol transporter 1,,landmark,, +3551,IKBKB,inhibitor of nuclear factor kappa B kinase subunit beta,,landmark,, +4891,SLC11A2,solute carrier family 11 member 2,Solute carriers,landmark,, +4893,NRAS,neuroblastoma RAS viral oncogene homolog,RAS type GTPase family,landmark,, +2222,FDFT1,farnesyl-diphosphate farnesyltransferase 1,,landmark,, +3553,IL1B,interleukin 1 beta,"Endogenous ligands, Interleukins",landmark,, +3566,IL4R,interleukin 4 receptor,"CD molecules, Interleukin receptors",landmark,, +2264,FGFR4,fibroblast growth factor receptor 4,"Receptor Tyrosine Kinases, CD molecules, I-set domain containing",landmark,, +2263,FGFR2,fibroblast growth factor receptor 2,"Receptor Tyrosine Kinases, CD molecules, I-set domain containing",landmark,, +2274,FHL2,four and a half LIM domains 2,LIM domain containing,landmark,, +2288,FKBP4,FK506 binding protein 4,"Tetratricopeptide repeat domain containing, FKBP prolyl isomerases",landmark,, +63874,ABHD4,abhydrolase domain containing 4,Abhydrolase domain containing,landmark,, +85236,H2BC12,histone cluster 1 H2B family member k,Histones,landmark,, +4925,NUCB2,nucleobindin 2,EF-hand domain containing,landmark,, +4927,NUP88,nucleoporin 88,Nucleoporins,landmark,, +4931,NVL,nuclear VCP-like,AAA ATPases,landmark,, +3611,ILK,integrin linked kinase,Ankyrin repeat domain containing,landmark,, +3628,INPP1,inositol polyphosphate-1-phosphatase,Phosphoinositide phosphatases,landmark,, diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/landmark_genes_proteomics.csv b/drevalpy/components/featurizers/cell_line/gene_lists/landmark_genes_proteomics.csv new file mode 100644 index 000000000..4cdd8aea4 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/landmark_genes_proteomics.csv @@ -0,0 +1,279 @@ +Symbol +SMC3 +RNPS1 +STXBP1 +NRAS +PAFAH1B1 +FKBP4 +PAICS +MLEC +DDX42 +PDLIM1 +VAT1 +PPIC +SFN +SPR +MTHFD2 +DAG1 +BID +PNKP +COPS7A +USP7 +HK1 +TOP2A +GNAI2 +PCNA +PARP1 +LSM6 +TRAPPC3 +CDK1 +SKP1 +AKAP8 +CNPY3 +GFPT1 +MRPL19 +ABCF1 +TM9SF2 +KIF2C +RPA2 +RRP8 +MAT2A +REEP5 +RFC5 +IDE +VAPB +CSNK1A1 +NUP93 +SMC4 +CRKL +OXA1L +TMEM109 +PCMT1 +PDHX +CTNND1 +TOMM34 +NOSIP +DECR1 +NCAPD2 +PLS1 +SYNE2 +OXSR1 +NUP85 +UFM1 +NUP88 +CLIC4 +SPEN +P4HA2 +BAD +EIF5 +ADI1 +CAPN1 +PCBD1 +DNAJB1 +GNA11 +CALU +CRK +DFFA +DNTTIP2 +ARHGEF2 +TMED10 +CAST +PLOD3 +PCK2 +ARHGAP1 +CRYZ +RPS6 +HYOU1 +CAT +GAA +SMARCA4 +MVP +SMNDC1 +RFC2 +APOE +CLTB +PWP1 +SACM1L +LBR +RUVBL1 +PGM1 +GLRX +NENF +BLMH +HDAC2 +PIN1 +OXCT1 +PSIP1 +GRWD1 +MPZL1 +RAB21 +PTPN1 +IARS2 +UBR7 +RAE1 +LRPAP1 +HSPA8 +HSPD1 +RPA1 +TCERG1 +DDX10 +SNX6 +DAXX +USP14 +TPM1 +POLR2I +HAT1 +BAX +EXOSC4 +RRS1 +ELAVL1 +ALDH7A1 +ACLY +EBNA1BP2 +HMGCS1 +RRP1B +MBNL1 +PTPRF +MRPS2 +DLD +PTK2 +SMARCD2 +CBR1 +CCDC86 +MRPS16 +CRTAP +ANXA7 +CREG1 +LAP3 +DNAJA3 +ATP1B1 +SMARCC1 +MAN2B1 +TERF2IP +KTN1 +CPSF4 +SCRN1 +CREB1 +STAMBP +DSG2 +PRPF4 +RNH1 +BAG3 +IGF2R +DUSP3 +NUCB2 +SUZ12 +NIT1 +AURKB +SQSTM1 +ACAT2 +STMN1 +TXLNA +XPNPEP1 +PPIE +DNMT1 +IQGAP1 +CSNK2A2 +PSMF1 +CTTN +BLVRA +NNT +NIPSNAP1 +ENOPH1 +NUP133 +MSH6 +POLR1C +NUSAP1 +STAT1 +CD44 +ELAC2 +ILK +CISD1 +GNAS +PCCB +S100A13 +AKAP8L +ADH5 +DHRS7 +CLTC +NMT1 +DRAP1 +TIMM9 +RPA3 +CBR3 +ETFB +MYCBP +RHOA +YKT6 +RALA +PAFAH1B3 +ALDOA +ADAM10 +RPN1 +HSPA4 +PIH1D1 +CLPX +COASY +PSME1 +CNDP2 +PYCR1 +ACBD3 +FIS1 +UGDH +CDC42 +FDFT1 +HADH +HEBP1 +PHGDH +RSU1 +TUBB6 +TJP1 +CEBPZ +STX4 +PDIA5 +SCCPDH +GAPDH +MCM3 +G3BP1 +YME1L1 +CSK +EPB41L2 +ME2 +TM9SF3 +PLCB3 +RNMT +TRAP1 +RBM6 +ECH1 +PSMD4 +PAF1 +CASP3 +AKR7A2 +TXNRD1 +SLC25A13 +RRP12 +SCP2 +GLOD4 +HEATR1 +COPB2 +BZW2 +RALB +PUF60 +RPS5 +ATG3 +IGF2BP2 +ITGB5 +CIRBP +ACAA1 +SYPL1 +PDS5A +CPNE3 +CHMP6 +SUPV3L1 +PNP +TPD52L2 +NVL +SDHB +CIAPIN1 +TMCO1 +LIG1 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/landmark_genes_reduced.csv b/drevalpy/components/featurizers/cell_line/gene_lists/landmark_genes_reduced.csv new file mode 100644 index 000000000..8cd0a5690 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/landmark_genes_reduced.csv @@ -0,0 +1,271 @@ +Symbol +SMC3 +RNPS1 +STXBP1 +NRAS +PAFAH1B1 +FKBP4 +PAICS +MLEC +DDX42 +PDLIM1 +VAT1 +PPIC +SFN +SPR +MTHFD2 +DAG1 +BID +PNKP +COPS7A +USP7 +HK1 +TOP2A +GNAI2 +PCNA +LSM6 +TRAPPC3 +CDK1 +SKP1 +AKAP8 +CNPY3 +GFPT1 +MRPL19 +ABCF1 +TM9SF2 +KIF2C +RPA2 +RRP8 +MAT2A +REEP5 +RFC5 +IDE +VAPB +CSNK1A1 +NUP93 +SMC4 +CRKL +OXA1L +TMEM109 +PCMT1 +PDHX +CTNND1 +TOMM34 +NOSIP +DECR1 +NCAPD2 +PLS1 +SYNE2 +OXSR1 +NUP85 +UFM1 +NUP88 +CLIC4 +SPEN +P4HA2 +BAD +EIF5 +ADI1 +CAPN1 +PCBD1 +DNAJB1 +GNA11 +CALU +CRK +DFFA +DNTTIP2 +ARHGEF2 +TMED10 +CAST +PLOD3 +PCK2 +ARHGAP1 +CRYZ +RPS6 +HYOU1 +CAT +GAA +SMARCA4 +MVP +SMNDC1 +RFC2 +APOE +CLTB +PWP1 +SACM1L +LBR +RUVBL1 +PGM1 +GLRX +HDAC2 +BLMH +PIN1 +OXCT1 +PSIP1 +GRWD1 +MPZL1 +RAB21 +PTPN1 +IARS2 +UBR7 +RAE1 +LRPAP1 +HSPA8 +HSPD1 +RPA1 +TCERG1 +DDX10 +DAXX +USP14 +TPM1 +POLR2I +HAT1 +BAX +EXOSC4 +RRS1 +ELAVL1 +ALDH7A1 +ACLY +EBNA1BP2 +HMGCS1 +RRP1B +MBNL1 +PTPRF +MRPS2 +DLD +PTK2 +SMARCD2 +CBR1 +CCDC86 +CRTAP +ANXA7 +CREG1 +LAP3 +DNAJA3 +ATP1B1 +SMARCC1 +MAN2B1 +TERF2IP +KTN1 +CPSF4 +SCRN1 +CREB1 +STAMBP +DSG2 +PRPF4 +RNH1 +BAG3 +IGF2R +DUSP3 +NUCB2 +SUZ12 +NIT1 +AURKB +SQSTM1 +ACAT2 +STMN1 +TXLNA +XPNPEP1 +PPIE +DNMT1 +IQGAP1 +CSNK2A2 +PSMF1 +CTTN +BLVRA +NNT +NIPSNAP1 +ENOPH1 +MSH6 +POLR1C +NUSAP1 +STAT1 +CD44 +ELAC2 +ILK +CISD1 +GNAS +PCCB +S100A13 +AKAP8L +ADH5 +DHRS7 +CLTC +NMT1 +DRAP1 +TIMM9 +RPA3 +CBR3 +ETFB +RALA +RHOA +YKT6 +PAFAH1B3 +ALDOA +ADAM10 +RPN1 +HSPA4 +PIH1D1 +CLPX +COASY +PSME1 +CNDP2 +PYCR1 +ACBD3 +FIS1 +UGDH +CDC42 +FDFT1 +HADH +HEBP1 +PHGDH +TUBB6 +TJP1 +CEBPZ +STX4 +PDIA5 +SCCPDH +GAPDH +MCM3 +G3BP1 +YME1L1 +CSK +EPB41L2 +ME2 +TM9SF3 +PLCB3 +TRAP1 +RBM6 +ECH1 +PSMD4 +PAF1 +CASP3 +AKR7A2 +TXNRD1 +SLC25A13 +RRP12 +SCP2 +GLOD4 +HEATR1 +COPB2 +BZW2 +RALB +PUF60 +RPS5 +ATG3 +IGF2BP2 +ITGB5 +CIRBP +ACAA1 +SYPL1 +PDS5A +CPNE3 +CHMP6 +SUPV3L1 +PNP +TPD52L2 +NVL +SDHB +CIAPIN1 +TMCO1 +LIG1 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/methylation_intersection.csv b/drevalpy/components/featurizers/cell_line/gene_lists/methylation_intersection.csv new file mode 100644 index 000000000..b7e268635 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/methylation_intersection.csv @@ -0,0 +1,81 @@ +,Symbol +0,chr6:52149149-52149725 +1,chr4:1722179-1723850 +2,chr17:2206517-2207248 +3,chr1:40105010-40105707 +4,chr10:102027099-102027490 +5,chr16:90014250-90014613 +6,chr4:10117575-10118952 +7,chr20:25565437-25566547 +8,chr11:66383571-66385047 +9,chr5:6632740-6634162 +10,chr6:144328916-144329847 +11,chr11:82612259-82612844 +12,chr16:755037-756696 +13,chr4:41752280-41752502 +14,chr6:146350522-146350780 +15,chr18:71958141-71959770 +16,chr16:46864453-46865561 +17,chr3:44283239-44283838 +18,chr6:148663425-148664041 +19,chr2:95537196-95537810 +20,chr12:107711603-107714107 +21,chr12:56546192-56546411 +22,chr20:5099993-5100866 +23,chr16:31453943-31454561 +24,chr2:172949242-172950126 +25,chr12:51984474-51985284 +26,chr15:90727879-90728570 +27,chr7:107301205-107302416 +28,chr1:45241013-45241900 +29,chr10:105677555-105678040 +30,chr14:68066584-68067134 +31,chr2:105953744-105954249 +32,chr19:18794191-18794819 +33,chr19:30156038-30156795 +34,chr17:8533004-8534933 +35,chr12:40498962-40500017 +36,chr1:39456655-39457271 +37,chr3:113464437-113465159 +38,chr11:125365137-125366319 +39,chr4:146857334-146859082 +40,chr17:72983621-72984591 +41,chr2:99797348-99797623 +42,chr20:2852899-2854635 +43,chr13:26624725-26626265 +44,chr8:104426894-104427823 +45,chr3:113666359-113667390 +46,chr21:36260450-36263687 +47,chr3:11034446-11035384 +48,chr9:138852639-138853439 +49,chr10:102790820-102791049 +50,chr1:44444635-44445622 +51,chr1:161494024-161495356 +52,chr3:47619112-47621131 +53,chr20:10015610-10015914 +54,chr1:35325182-35325550 +55,chr12:54447744-54448091 +56,chr11:86665901-86666567 +57,chr11:111385337-111385712 +58,chr10:124638743-124639793 +59,chr5:150400000-150400490 +60,chr5:56204962-56206344 +61,chr1:29557220-29557627 +62,chr16:30134220-30134488 +63,chr15:64454890-64455577 +64,chr20:32580799-32582502 +65,chr11:113745816-113746693 +66,chr16:30076310-30077872 +67,chr22:50353596-50357215 +68,chr19:46518283-46520080 +69,chr3:42641844-42642605 +70,chr3:148415442-148415932 +71,chr19:30206040-30206497 +72,chr7:150725173-150726018 +73,chr12:58004982-58005351 +74,chr11:119039429-119039944 +75,chr18:44496910-44497832 +76,chr7:23338797-23339264 +77,chr4:145566242-145567413 +78,chr11:34378229-34379993 +79,chr15:67357339-67359061 diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/mutations_intersection.csv b/drevalpy/components/featurizers/cell_line/gene_lists/mutations_intersection.csv new file mode 100644 index 000000000..dd19eccec --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/mutations_intersection.csv @@ -0,0 +1,2744 @@ +,Symbol +0,RRM1 +1,PLEKHJ1 +2,AASDH +3,FKBP4 +4,KDM5A +5,HSPA2 +6,SKIC8 +7,ASB8 +8,BID +9,TPRA1 +10,CNPY3 +11,PSMG1 +12,CLEC1A +13,ESYT2 +14,MFSD11 +15,POLR2G +16,XKR6 +17,ALDOB +18,HRG +19,GPX6 +20,NDUFA2 +21,TUBA1C +22,MAPK9 +23,KCNK1 +24,KRTAP1-5 +25,EGR1 +26,KLHL9 +27,USP38 +28,PRKAG2 +29,SHD +30,PIP4K2B +31,TSGA10 +32,RTF1 +33,PDX1 +34,RAB19 +35,ZNF276 +36,PLEKHA6 +37,NFATC2 +38,CCDC136 +39,SRC +40,ANKRD28 +41,ADRB2 +42,EIF5 +43,SETX +44,IQSEC3 +45,DMBT1 +46,CDC42BPB +47,SRA1 +48,RAB31 +49,MLLT11 +50,GTF2B +51,FAM219A +52,MVP +53,PSG4 +54,ZNF600 +55,EYS +56,ARNT2 +57,FGFBP1 +58,STX1A +59,PREB +60,LRIG3 +61,SNAPC4 +62,RAB21 +63,CDIP1 +64,BAMBI +65,LGALS8 +66,TES +67,SNAPIN +68,BAX +69,WFDC10B +70,GLRX5 +71,SCGB1D2 +72,NT5DC2 +73,MRPS2 +74,AURKAIP1 +75,ANXA7 +76,CCNE1 +77,OR4K13 +78,FAS +79,LRRC41 +80,VAV3 +81,STAMBP +82,PAK2 +83,TMCC3 +84,MRTFA +85,MTF2 +86,CCND1 +87,SART1 +88,ARPP19 +89,EXTL2 +90,DYRK3 +91,PIK3CD +92,ZEB1 +93,SENP6 +94,CSNK2A2 +95,SETD1A +96,HUNK +97,USP32 +98,NR1H2 +99,KNDC1 +100,SLC16A14 +101,PIK3CB +102,SCARB1 +103,B4GALT1 +104,MARK4 +105,RPA3 +106,MYCBP +107,LRP12 +108,KIF9 +109,KRT3 +110,TATDN2 +111,ZNF862 +112,IER3 +113,PYCR1 +114,NDUFAB1 +115,PIK3R5 +116,PTN +117,SMO +118,ANAPC10 +119,HEBP1 +120,CCL2 +121,ZNF586 +122,KLF6 +123,RNF11 +124,OVOL1 +125,DPP8 +126,TSPAN3 +127,MNX1 +128,SPTLC2 +129,NPEPL1 +130,CHUK +131,NTRK3 +132,CACNA1E +133,TBC1D9B +134,MCOLN1 +135,PSMD4 +136,STAR +137,PYGL +138,KNTC1 +139,ATP7B +140,SLC25A18 +141,FGFR2 +142,PIGB +143,CIRBP +144,CDR2L +145,NR2C2AP +146,SLC25A4 +147,FCGR2A +148,STPG1 +149,UBE2L6 +150,SPRYD7 +151,TDRD7 +152,RNF167 +153,QRICH1 +154,COPZ1 +155,TIMM22 +156,NRAS +157,PMP2 +158,CLN5 +159,MAN2C1 +160,TGFBR1 +161,PPP2R3C +162,PPIC +163,SLFN13 +164,SPR +165,KIF21A +166,IGFL3 +167,BCL7B +168,RAF1 +169,PDE10A +170,TNKS2 +171,SKP1 +172,PRKACA +173,IL12B +174,CES4A +175,MAPK1 +176,PPT1 +177,ARHGEF40 +178,ZDHHC6 +179,BPIFB1 +180,CAPZB +181,TMEM170B +182,MALT1 +183,SPATA31D1 +184,TNFRSF13B +185,RAB4A +186,TPGS2 +187,TBXA2R +188,RAD51D +189,MAP3K4 +190,ZNF202 +191,ATP6V1B2 +192,IL20RA +193,ATG12 +194,VCL +195,CD63 +196,MRPS26 +197,MBTPS1 +198,PEX13 +199,HES1 +200,NSMCE1 +201,CCL21 +202,INTS3 +203,MBNL2 +204,AEBP1 +205,CYP2W1 +206,ARIH2 +207,DENND2D +208,CRLS1 +209,RRP9 +210,VPS35 +211,BAD +212,EGFR +213,ADI1 +214,DESI1 +215,SUPT20H +216,AXL +217,CLK4 +218,GUCA2B +219,ZNF451 +220,IARS1 +221,UBE3B +222,PTPRK +223,KDM3A +224,PCBP2 +225,MAPKAPK2 +226,KRTAP22-1 +227,CDKN2A +228,PIK3CA +229,DCTN2 +230,S100A4 +231,TGFB1I1 +232,ANK1 +233,RUVBL1 +234,GNG2 +235,NR1H4 +236,ZNF395 +237,PTPN1 +238,TXNL4B +239,RNF152 +240,YTHDF1 +241,CHEK2 +242,AGPAT2 +243,WEE1 +244,COLEC12 +245,DYNC2LI1 +246,SCD +247,ALDH7A1 +248,PCDHB12 +249,MBNL1 +250,CCNF +251,ATRN +252,C2CD5 +253,OR1G1 +254,INSIG1 +255,HEATR6 +256,TRUB1 +257,ZNF318 +258,CCNA2 +259,BRIP1 +260,BRDT +261,SLC38A7 +262,MEF2C +263,IGF2R +264,EZH2 +265,POLE2 +266,SQSTM1 +267,TMC3 +268,TTC32 +269,FGF18 +270,OR2A12 +271,NDUFB1 +272,ZDHHC19 +273,BLVRA +274,BRAF +275,BPIFA1 +276,CCDC146 +277,NIPSNAP1 +278,YPEL2 +279,POLR1C +280,CLEC16A +281,NCAM1 +282,STARD4 +283,BRF2 +284,CISD1 +285,CCDC112 +286,AMIGO1 +287,ADH5 +288,MFAP2 +289,SPG7 +290,TCEA2 +291,CDKN2AIP +292,YKT6 +293,RPS6KB1 +294,NFATC4 +295,KCNA4 +296,TGIF1 +297,B9D1 +298,GSTP1 +299,CDH1 +300,ORC1 +301,AIM2 +302,UPP2 +303,OR8B8 +304,TP53 +305,BIRC5 +306,STEAP2 +307,KEAP1 +308,MAP4K1 +309,HEATR4 +310,SCYL3 +311,ERBB3 +312,EML3 +313,CORIN +314,ABHD6 +315,GPATCH8 +316,MAP2 +317,PRKCB +318,PLA2R1 +319,SEMA4A +320,TONSL +321,CSRP1 +322,KANK1 +323,CENPE +324,PROM2 +325,WBP2NL +326,HDAC5 +327,AHDC1 +328,STX3 +329,IFI27L2 +330,PDS5A +331,CPNE3 +332,TFDP1 +333,CDK13 +334,FNBP1 +335,DNAI2 +336,ENO2 +337,VWA5B1 +338,PAFAH1B1 +339,NUDT5 +340,STEAP3 +341,USP15 +342,PAICS +343,CD82 +344,IFT122 +345,LAMP3 +346,TYW1 +347,B3GALT1 +348,UBE3C +349,CYP3A5 +350,DIRAS1 +351,AP2A1 +352,ZNF556 +353,SMAD4 +354,PPIA +355,TOP2A +356,TMEM203 +357,SWSAP1 +358,IQUB +359,TMEM132B +360,HDAC7 +361,NDUFA10 +362,ALAS1 +363,OR5D14 +364,WIF1 +365,FAM210B +366,MYO18A +367,ECD +368,JMJD4 +369,NCOR2 +370,RPH3A +371,ANKRD34B +372,CSNK1A1 +373,TMBIM6 +374,RAPSN +375,PKIB +376,WDR75 +377,MSH2 +378,PLA2G15 +379,DDIT4L +380,TRADD +381,GUCA1C +382,RASA1 +383,ABCC8 +384,AURKC +385,GDF5 +386,DOCK4 +387,PSRC1 +388,MED13 +389,MYL6B +390,DNM1 +391,GTDC1 +392,HSD17B14 +393,BCAR3 +394,CETN3 +395,PDP1 +396,FAM76B +397,GATA6 +398,MRPL46 +399,TRPC4AP +400,HMG20B +401,LCK +402,AKAP12 +403,AMDHD2 +404,PCK2 +405,MAP2K5 +406,TAS2R42 +407,PNMA2 +408,NR4A3 +409,HGF +410,MAPK3 +411,PSTPIP2 +412,SDR9C7 +413,CDC123 +414,ZBTB44 +415,ATP6V1D +416,MDH1B +417,LGALSL +418,MRGPRD +419,ERBB4 +420,NR2F6 +421,ATF1 +422,SLC12A6 +423,NCSTN +424,BNC2 +425,TRIM37 +426,EXOSC4 +427,RING1 +428,GCG +429,ISOC1 +430,PTK2 +431,MAFK +432,INSL5 +433,HMGCR +434,PRMT7 +435,SNX11 +436,PITPNA +437,C1QA +438,MCM9 +439,SHROOM3 +440,FRMD1 +441,ITPKB +442,PRDM5 +443,CASP7 +444,OSGIN2 +445,ACAT2 +446,TIPARP +447,DUSP6 +448,IL6ST +449,ADCY10 +450,PEAR1 +451,RNF181 +452,IL17F +453,ENO3 +454,NNT +455,EPAS1 +456,ZC3HAV1 +457,MSH6 +458,CTCFL +459,KCNJ3 +460,COX7A2L +461,WDR47 +462,HDAC4 +463,FRS2 +464,HMX1 +465,CDK7 +466,SKIC2 +467,FIGN +468,WDR5 +469,FBXO8 +470,ATAD5 +471,DNAJB2 +472,HSPA4 +473,SIVA1 +474,CSRNP1 +475,RET +476,PROX2 +477,MCMBP +478,OR2C3 +479,IKBKB +480,TMEM64 +481,ZCCHC17 +482,PKP4 +483,RBKS +484,KRT32 +485,FZD1 +486,YBX2 +487,DEFB116 +488,HAPLN4 +489,SLC25A13 +490,HESX1 +491,BZW2 +492,ARHGEF19 +493,PTTG1IP +494,FMO2 +495,RNASE2 +496,IL9 +497,SGK2 +498,TMEM53 +499,PKIG +500,ACAA1 +501,MTMR9 +502,HYAL2 +503,GGPS1 +504,PNP +505,ANKRD49 +506,RPN2 +507,HIPK4 +508,PDE8A +509,LEP +510,PPCDC +511,CIB3 +512,PPIL1 +513,STXBP1 +514,ABL1 +515,NFATC3 +516,ZRANB1 +517,SUGP2 +518,SACS +519,PRDM10 +520,SLAMF1 +521,EXOC3 +522,MTHFD2 +523,RNASE3 +524,TRIM45 +525,IRX3 +526,MYL5 +527,AKAP8 +528,CDK19 +529,PAK4 +530,MCHR1 +531,OR51I2 +532,OTUD4 +533,CANT1 +534,KLHL36 +535,RFC5 +536,ARRB1 +537,UNC13D +538,CRKL +539,SLC25A20 +540,H2BC12 +541,S100A1 +542,ZNF7 +543,USP4 +544,ARFGAP1 +545,DECR2 +546,USP6 +547,PLS1 +548,BMP2 +549,CHIC2 +550,SURF6 +551,VNN3P +552,NUP85 +553,IFNA8 +554,KLK12 +555,SVOP +556,AARS2 +557,WFS1 +558,TACO1 +559,TXNDC9 +560,DFFA +561,ERBB2 +562,CATSPERD +563,BFAR +564,ZNF195 +565,SMARCB1 +566,DDX6 +567,GALC +568,SRSF12 +569,DSG1 +570,RAD51AP2 +571,DDR1 +572,MDM4 +573,APOE +574,RBP7 +575,NTN4 +576,PSG9 +577,TOR1AIP1 +578,LRP10 +579,SLC25A46 +580,STAT5B +581,STOML3 +582,IARS2 +583,TMEM33 +584,PYCARD +585,MED23 +586,SLC35F3 +587,ZNF71 +588,LRPAP1 +589,PDZD7 +590,B3GNT9 +591,F13A1 +592,DAXX +593,HERC6 +594,FAP +595,PHYHIP +596,ABCA8 +597,PAPSS2 +598,SPIC +599,EHMT2 +600,ATP6V1E2 +601,PDE9A +602,TTC8 +603,CREG1 +604,AURKA +605,SRGAP1 +606,CEP72 +607,TMEM18 +608,DBR1 +609,HSPA5 +610,ATF7 +611,FABP1 +612,EIF1B +613,ICA1L +614,DERA +615,IGFBP3 +616,REPS1 +617,HLA-DRA +618,PPP2R5A +619,EGLN1 +620,GNPDA1 +621,GPRIN1 +622,NXPH1 +623,RBL2 +624,KANSL1 +625,ODAD2 +626,QPCTL +627,DHRS7 +628,MON2 +629,SLC16A4 +630,MUC12 +631,GREM1 +632,RAC3 +633,NPVF +634,SLC2A6 +635,KDM2A +636,LRP4 +637,TRIQK +638,MAST3 +639,GP6 +640,CD8A +641,CLPX +642,HSPA1A +643,ACBD3 +644,UGDH +645,FEZ2 +646,TAP1 +647,APRT +648,HADH +649,KIF17 +650,TUBB6 +651,LYN +652,SLC5A6 +653,DHX29 +654,TSPAN4 +655,DCUN1D2 +656,TM9SF3 +657,ARID3C +658,SLC7A1 +659,BLNK +660,UGT2B15 +661,CALCA +662,SLC37A4 +663,OR2F2 +664,AK5 +665,PROK1 +666,BBS2 +667,ATF2 +668,IDH1 +669,DNAH14 +670,RPS5 +671,TMEM259 +672,MS4A12 +673,PACS1 +674,EIF2B5 +675,EFHC1 +676,ZNF704 +677,PKN1 +678,KRTAP11-1 +679,BRD1 +680,PROS1 +681,NET1 +682,DYNLT1 +683,SLC4A11 +684,STARD3 +685,VPS72 +686,FLNB +687,TMCC2 +688,ERCC5 +689,CKAP4 +690,PHKG2 +691,KRTAP20-4 +692,TLE1 +693,TCFL5 +694,KLHL3 +695,ACTB +696,COG5 +697,PDGFRA +698,UBASH3B +699,SNX21 +700,PKM +701,SPAG16 +702,ZIK1 +703,PXMP4 +704,PARVB +705,TRIM7 +706,NDN +707,IDE +708,NNAT +709,NUP93 +710,LIMD2 +711,KCTD5 +712,SET +713,PTGS2 +714,GRB10 +715,KLHDC2 +716,CTNND1 +717,DECR1 +718,SLBP +719,GNG8 +720,DDX60 +721,RASSF4 +722,TOR4A +723,FBXO21 +724,EXT1 +725,UFM1 +726,CCNA1 +727,NBL1 +728,ZBTB14 +729,PCBD1 +730,DNAJB1 +731,PEX6 +732,DNTTIP2 +733,OPTC +734,DEK +735,WDR24 +736,POLR3A +737,MAN2A1 +738,MLPH +739,CIAO3 +740,ELOVL6 +741,ITGB1BP1 +742,RNF122 +743,CATSPERB +744,OXCT1 +745,PRTN3 +746,TTC33 +747,GRWD1 +748,OSR1 +749,ST3GAL5 +750,RUNDC3B +751,MAP2K1 +752,DNAJC15 +753,MTFR1 +754,PRCP +755,TRPM5 +756,HMOX1 +757,LRRK2 +758,PDZD9 +759,RPL30 +760,DEPDC4 +761,PRM2 +762,PPM1M +763,CSF1R +764,ACTR6 +765,BRD4 +766,MAPKAPK3 +767,LARP4 +768,KIF25 +769,HAO2 +770,IGDCC3 +771,CLEC10A +772,NPDC1 +773,PGM2 +774,BUB3 +775,SLC27A5 +776,RGS5 +777,CD164L2 +778,ZDHHC5 +779,NPC1L1 +780,CYP26B1 +781,MSRB3 +782,PPIE +783,ADO +784,GPSM1 +785,SH2B1 +786,VARS2 +787,CTTN +788,P4HTM +789,BIRC3 +790,ZNF76 +791,CHKA +792,KCNQ4 +793,NDUFA13 +794,ATP6V1G1 +795,BNIP3L +796,RAB6A +797,PLSCR1 +798,RAD51C +799,HGD +800,PTER +801,SOX4 +802,ULK2 +803,CGRRF1 +804,CDK4 +805,PHF1 +806,PCOLCE2 +807,SLC25A45 +808,LYSMD2 +809,CCL20 +810,AGL +811,GNB5 +812,MDM2 +813,GLIPR2 +814,C2CD2L +815,AGAP3 +816,RSU1 +817,NR2F2 +818,HNRNPU +819,G3BP1 +820,EPB41L2 +821,VWC2 +822,SPAG4 +823,DNAJB5 +824,PARN +825,RFX5 +826,NAMPT +827,C1RL +828,ZNF253 +829,COG4 +830,GPC1 +831,MEGF8 +832,JAKMIP2 +833,ECH1 +834,CCDC127 +835,GPR160 +836,HRNR +837,NFKBID +838,SNX22 +839,RIT1 +840,ZFAND1 +841,CPNE7 +842,FBXO11 +843,MYCBP2 +844,FGF11 +845,SYPL1 +846,TBP +847,KLHL24 +848,ALDH3B2 +849,THUMPD3 +850,TEC +851,GPR146 +852,ITPRID2 +853,CTNNA3 +854,CIAPIN1 +855,SH3BP5 +856,ETS1 +857,MAP3K7 +858,CBLB +859,IFNB1 +860,DOK2 +861,LPAR2 +862,ZNF772 +863,ABCA5 +864,CEP97 +865,USP7 +866,FHIT +867,PARP1 +868,TAAR8 +869,MOSPD3 +870,SLC25A38 +871,SCUBE1 +872,HDAC1 +873,ZNF510 +874,FAM20B +875,SLC39A10 +876,STRA6 +877,PIK3C3 +878,PARP6 +879,CSE1L +880,ECHS1 +881,CHST11 +882,LYPD3 +883,CCDC92 +884,YBEY +885,CLCN2 +886,CAMK2B +887,TCIRG1 +888,BMP4 +889,CCDC90B +890,F13B +891,TFAP2A +892,PTK2B +893,DHX16 +894,NCAPD2 +895,SNRPA +896,USP30 +897,ARHGEF15 +898,MAPK11 +899,KLHL11 +900,TNFRSF9 +901,LPIN2 +902,ESR1 +903,OR6N2 +904,XAB2 +905,CALU +906,CLDN4 +907,NPRL2 +908,SIX4 +909,TLK2 +910,RUVBL2 +911,ALG8 +912,STAT3 +913,SRPK1 +914,EZH1 +915,ALK +916,GPR183 +917,KRT85 +918,PTPRM +919,LAT2 +920,TMED4 +921,PTMS +922,ARHGAP28 +923,PRKAA2 +924,PDE1C +925,SNX2 +926,PTPN11 +927,PSMA8 +928,GIGYF1 +929,SH3BGRL2 +930,TRAK2 +931,POLG2 +932,ZNF543 +933,DYRK1B +934,P2RY6 +935,APMAP +936,GIPC2 +937,GPR152 +938,BST2 +939,ESR2 +940,SLC35F2 +941,BTRC +942,MSRA +943,ULK1 +944,TOPAZ1 +945,LPL +946,TMEM86A +947,ANGPT1 +948,RPUSD3 +949,SPHK1 +950,ZNF527 +951,ILDR1 +952,PMAIP1 +953,SLC49A4 +954,NMUR2 +955,ZFHX2 +956,TUBA1A +957,SCRN1 +958,FGL1 +959,RAD9A +960,RXFP3 +961,CDH15 +962,GALNT13 +963,MXD4 +964,ELOVL4 +965,IDH2 +966,CAPN9 +967,INO80D +968,FANK1 +969,SLC35A3 +970,ZNF468 +971,C1QTNF8 +972,TNRC6C +973,FAIM +974,CPXM2 +975,LOXL1 +976,TNFSF10 +977,CCT5 +978,SERPINE1 +979,AFMID +980,TMEM97 +981,UQCC5 +982,EPHA7 +983,NEU4 +984,C2CD2 +985,CELF1 +986,CRB1 +987,GCC1 +988,HIF1AN +989,DCLK1 +990,ARHGAP44 +991,ZNF107 +992,FFAR2 +993,TRA2B +994,KIF5C +995,MUC5B +996,LRRC58 +997,CALR +998,ZNF333 +999,MYLK +1000,YWHAQ +1001,STX4 +1002,MTA1 +1003,KPNA7 +1004,GRB7 +1005,ACSM4 +1006,ARF4 +1007,CASP3 +1008,LMO4 +1009,MYL9 +1010,TXNRD1 +1011,BCL2 +1012,LRP2 +1013,SCP2 +1014,LGR5 +1015,COPB2 +1016,DNAJB6 +1017,DNM1L +1018,CKAP2 +1019,FLT4 +1020,FAM186A +1021,PLEKHM3 +1022,TRIM2 +1023,POLR1G +1024,SIRPB2 +1025,TAFA2 +1026,SOWAHA +1027,BAAT +1028,TFB2M +1029,RPP25L +1030,PHF14 +1031,RTN2 +1032,NUP107 +1033,TPPP +1034,TP53RK +1035,EMC10 +1036,SUPT16H +1037,UBTF +1038,ROS1 +1039,TMEM50A +1040,UBQLN1 +1041,TRAPPC3 +1042,TRIM55 +1043,SUGT1 +1044,ABCF1 +1045,GLI2 +1046,RSPO2 +1047,PLK1 +1048,PIK3CG +1049,WDTC1 +1050,DLEU7 +1051,RNF149 +1052,MAPK10 +1053,TOR3A +1054,UGGT2 +1055,PHRF1 +1056,HMGA2 +1057,CTNNAL1 +1058,BRD2 +1059,KRT2 +1060,OR10J5 +1061,WDR74 +1062,TBK1 +1063,ECHDC2 +1064,ZFP1 +1065,FAM120AOS +1066,FAM114A1 +1067,TTLL2 +1068,NFE2L2 +1069,ZNF626 +1070,NPLOC4 +1071,MOAP1 +1072,SYNJ2 +1073,GLYR1 +1074,DNAH3 +1075,IRAK4 +1076,TBCEL +1077,ELF2 +1078,NLE1 +1079,SMAGP +1080,ROCK2 +1081,GDPD5 +1082,HEG1 +1083,PARVA +1084,VPS37A +1085,RPS6 +1086,SLC2A9 +1087,KDM4B +1088,EED +1089,PSMB8 +1090,NENF +1091,HOXA7 +1092,SNRNP25 +1093,KLK7 +1094,COL15A1 +1095,NCKAP5 +1096,KAT6A +1097,LSG1 +1098,HSP90AA1 +1099,BDH1 +1100,ZNF619 +1101,TNFRSF21 +1102,SMOX +1103,CSTB +1104,CCR4 +1105,BCL9L +1106,DLD +1107,MAST2 +1108,IQCA1 +1109,MAN2B1 +1110,ANGPTL6 +1111,ASAH1 +1112,DLST +1113,NOL3 +1114,CYB5R4 +1115,MOK +1116,EBF1 +1117,SUZ12 +1118,EFCAB12 +1119,TEX264 +1120,RNF112 +1121,TEAD3 +1122,LRRC32 +1123,SPP1 +1124,CNOT4 +1125,XPNPEP1 +1126,RHBDL2 +1127,TTC17 +1128,DNMT1 +1129,PRDX6 +1130,IL37 +1131,SYK +1132,ARL11 +1133,PRR16 +1134,CCSER1 +1135,KIF14 +1136,GABRR1 +1137,CST1 +1138,SCYL2 +1139,CSF3 +1140,NDUFAF5 +1141,RABL3 +1142,KCNRG +1143,TBX4 +1144,SSBP2 +1145,ELK4 +1146,COL4A1 +1147,MKNK1 +1148,ADRB3 +1149,UNCX +1150,TRAF2 +1151,SNAP25 +1152,KRT72 +1153,CCNE2 +1154,PDIA5 +1155,SCCPDH +1156,GAPDH +1157,UNC45B +1158,ME2 +1159,ISL2 +1160,CYP1B1 +1161,NPM1 +1162,CYTH1 +1163,LSP1 +1164,TRAM2 +1165,FHL2 +1166,PSKH2 +1167,FEM1B +1168,EHMT1 +1169,WARS1 +1170,PFKL +1171,NAB2 +1172,IKZF1 +1173,KRTAP13-3 +1174,SLC35B1 +1175,TSC22D1 +1176,OSTN +1177,IL1B +1178,DPH2 +1179,PCGF3 +1180,PECR +1181,ZNF781 +1182,TERT +1183,SMC3 +1184,ST6GALNAC2 +1185,PMM2 +1186,LCE1E +1187,KLK1 +1188,FOSL1 +1189,RHD +1190,DNAJC2 +1191,DYDC2 +1192,PUS1 +1193,PDCD6 +1194,IGF1 +1195,MYL6 +1196,MRPL19 +1197,TWSG1 +1198,HOXC13 +1199,DCK +1200,DUSP4 +1201,CTRC +1202,ENPEP +1203,KLHL23 +1204,KCNE4 +1205,TICAM1 +1206,RB1 +1207,BCR +1208,TMEM109 +1209,PCMT1 +1210,PNLIP +1211,ZNF30 +1212,TOMM34 +1213,DCST2 +1214,FGFR1OP2 +1215,DTX2 +1216,NRIP1 +1217,S100A8 +1218,ZBTB7B +1219,ECSIT +1220,OR4C15 +1221,TESK1 +1222,NALCN +1223,MAT2B +1224,P4HA2 +1225,ARHGEF12 +1226,ZNF77 +1227,SESN1 +1228,MRTO4 +1229,CSNK1E +1230,TMED10 +1231,DRAXIN +1232,LETMD1 +1233,TBPL1 +1234,HOXA13 +1235,RXRB +1236,GNAI1 +1237,MYADM +1238,LPGAT1 +1239,ICMT +1240,RFC2 +1241,NAALAD2 +1242,BARX1 +1243,ATF5 +1244,ASTN1 +1245,GCNT1 +1246,ATP11B +1247,ZSCAN22 +1248,APBB2 +1249,RAE1 +1250,DDX49 +1251,INPP1 +1252,RPA1 +1253,SRP54 +1254,GAL3ST1 +1255,NTMT1 +1256,RIMS3 +1257,HMGCS1 +1258,MANEA +1259,ATP6V1C1 +1260,MYO18B +1261,DDC +1262,ZNF697 +1263,MC4R +1264,ATM +1265,HCN1 +1266,NSMCE2 +1267,COMMD1 +1268,MYO10 +1269,HOMER1 +1270,CASP10 +1271,NPW +1272,PDPK1 +1273,ASCC2 +1274,EPHA3 +1275,FBXO16 +1276,FGFR3 +1277,GRIK3 +1278,SLTM +1279,GRN +1280,ASNS +1281,TP53BP2 +1282,RNF170 +1283,IGF1R +1284,SLC35A1 +1285,F2 +1286,CHEK1 +1287,RHEB +1288,GSK3A +1289,BRCA1 +1290,STAT1 +1291,PPM1L +1292,ST7L +1293,CD44 +1294,ORAI2 +1295,HAVCR1 +1296,SASS6 +1297,CRELD2 +1298,CLTC +1299,IL24 +1300,ZBTB22 +1301,TUBB +1302,FGF22 +1303,DMXL1 +1304,STARD7 +1305,PPA2 +1306,RBM18 +1307,IMPA1 +1308,SLC30A10 +1309,MAPK13 +1310,ALDOA +1311,PIKFYVE +1312,PIH1D1 +1313,PRKCG +1314,ICAM1 +1315,CCDC85B +1316,USP47 +1317,MAST4 +1318,IRX5 +1319,DEFB134 +1320,MRPL22 +1321,CSK +1322,MLXIP +1323,APCS +1324,CALCB +1325,HSP90AB1 +1326,ATR +1327,DYRK1A +1328,SUGP1 +1329,RALB +1330,CTTNBP2 +1331,SLC5A9 +1332,GCFC2 +1333,SPRED2 +1334,EFNA5 +1335,OLIG1 +1336,GLS +1337,PSPH +1338,TIAM1 +1339,PER1 +1340,CEBPE +1341,CDK9 +1342,MZT2A +1343,TPH2 +1344,PDE5A +1345,ACAP1 +1346,MROH1 +1347,TGFB3 +1348,GPAM +1349,TEK +1350,RBM15B +1351,ENDOV +1352,SRP68 +1353,TFPI2 +1354,EIF2A +1355,PNKP +1356,PLEKHG1 +1357,GSK3B +1358,CTSB +1359,PAX8 +1360,SPATA12 +1361,ATN1 +1362,H2BC21 +1363,LRRC71 +1364,APPBP2 +1365,JAK2 +1366,NXPH2 +1367,NIPA1 +1368,CSDC2 +1369,KCNG2 +1370,TMEM229B +1371,RHBDD3 +1372,FLACC1 +1373,SLC32A1 +1374,CPOX +1375,MAPK8 +1376,BHLHE23 +1377,VAPB +1378,RHOB +1379,USP1 +1380,RARS2 +1381,CHN1 +1382,EDN1 +1383,PDHX +1384,FKBP1B +1385,GTF2E2 +1386,NBEA +1387,PXN +1388,TMEM151A +1389,TIE1 +1390,ANKRD30A +1391,TP53BP1 +1392,KATNIP +1393,LSR +1394,TFF1 +1395,GTF2A2 +1396,SLC6A18 +1397,MTHFD1L +1398,TUBB1 +1399,YY1AP1 +1400,SPDEF +1401,TBX18 +1402,BRSK2 +1403,SOCS1 +1404,CAST +1405,SLC25A26 +1406,PLOD3 +1407,FAM174A +1408,MCL1 +1409,ADAM15 +1410,SCAND1 +1411,CPLX1 +1412,FBXO7 +1413,TMEM50B +1414,ZKSCAN2 +1415,TGFBR2 +1416,KCNT1 +1417,MYT1L +1418,TPPP2 +1419,SENP1 +1420,ITGAE +1421,MRGBP +1422,CASP1 +1423,PPM1D +1424,SLC10A7 +1425,PCM1 +1426,CFLAR +1427,NOL7 +1428,PPARD +1429,ITPK1 +1430,CLUL1 +1431,RGS16 +1432,PAK6 +1433,HDLBP +1434,SEZ6L +1435,AKT2 +1436,BHLHE40 +1437,ATP10B +1438,RDH8 +1439,CSAD +1440,RILPL1 +1441,GATA3 +1442,KRTAP27-1 +1443,EBNA1BP2 +1444,ZCWPW1 +1445,CNOT10 +1446,OR8B12 +1447,ZNF280D +1448,ATG9A +1449,IRGQ +1450,KIF20A +1451,PEG10 +1452,RNF208 +1453,PPP2R5E +1454,ATP1B1 +1455,FGFR4 +1456,ZHX2 +1457,OLFML3 +1458,ACVR1B +1459,PCYOX1L +1460,TRDMT1 +1461,GNG5 +1462,IFRD2 +1463,MED10 +1464,UBL3 +1465,SIRT1 +1466,KCNK16 +1467,DIPK1A +1468,POLB +1469,SLC27A4 +1470,THRB +1471,NUDCD3 +1472,SGTB +1473,PLK2 +1474,PTH2 +1475,ZNF189 +1476,RHBDF1 +1477,DNAJC22 +1478,XBP1 +1479,DDX25 +1480,MAP7 +1481,CBR3 +1482,ZNF329 +1483,SMAD3 +1484,COX5B +1485,PEX11A +1486,LPXN +1487,CNDP2 +1488,NPFFR2 +1489,FOXO3 +1490,SAR1A +1491,ARHGEF4 +1492,A2M +1493,MFSD10 +1494,FBXL3 +1495,GADD45A +1496,RAB40B +1497,RAB27A +1498,PTEN +1499,CAMSAP2 +1500,KCNT2 +1501,DMXL2 +1502,RBP2 +1503,SHC2 +1504,PDE3B +1505,RAB7A +1506,SNX20 +1507,AARD +1508,PAK1 +1509,CAMP +1510,RPL39L +1511,TIMP2 +1512,AKT1S1 +1513,GTPBP1 +1514,WBP11 +1515,RNF25 +1516,ENOSF1 +1517,TMEM38B +1518,UBE2L3 +1519,MS4A6E +1520,ZNF131 +1521,PIK3C2B +1522,NEUROD2 +1523,MGAT1 +1524,CAPN10 +1525,IGFBP5 +1526,MROH9 +1527,CRIP3 +1528,CDC45 +1529,TARBP1 +1530,SLC24A3 +1531,TPD52L2 +1532,NACC1 +1533,FBXO41 +1534,MRPL18 +1535,TMEM8B +1536,CASR +1537,RNPS1 +1538,NDUFB2 +1539,NCK2 +1540,THOP1 +1541,RALY +1542,XRCC3 +1543,NCOA2 +1544,VAT1 +1545,AP5M1 +1546,CLYBL +1547,PRR15L +1548,EME1 +1549,RTN4IP1 +1550,EVL +1551,APLP2 +1552,GNAI2 +1553,FBXO33 +1554,CDKN1B +1555,STAP2 +1556,GFPT1 +1557,CELF4 +1558,ACTR3B +1559,UQCRH +1560,TM9SF2 +1561,TMEM255B +1562,NELL2 +1563,ANKRD44 +1564,ITFG1 +1565,EEF2K +1566,RPA2 +1567,CORO1A +1568,KIF2C +1569,CEP152 +1570,NOLC1 +1571,GMNN +1572,UFC1 +1573,SEMA6C +1574,PIK3R4 +1575,SLC39A8 +1576,MMP2 +1577,S100A7L2 +1578,FSCN3 +1579,GARRE1 +1580,CEBPD +1581,DUSP11 +1582,SH3D21 +1583,NPBWR2 +1584,HOOK2 +1585,CLSTN1 +1586,PHOSPHO1 +1587,LRRTM1 +1588,CRK +1589,NTHL1 +1590,GEMIN5 +1591,KHDC3L +1592,SESN2 +1593,BANF2 +1594,ETFDH +1595,FAHD1 +1596,PLAGL1 +1597,CCND3 +1598,CRYZ +1599,ARHGAP1 +1600,IKBKE +1601,SNRNP35 +1602,ARNT +1603,RNF32 +1604,JAK1 +1605,LBR +1606,GHR +1607,NAT2 +1608,BLMH +1609,SLC35F1 +1610,CELSR3 +1611,LYPD6 +1612,TRIAP1 +1613,KALRN +1614,MPZL1 +1615,FPGS +1616,EIF2S1 +1617,THADA +1618,LGMN +1619,LORICRIN +1620,CDK5 +1621,EGF +1622,SMURF2 +1623,HSPA8 +1624,TCERG1 +1625,DHODH +1626,FKBP14 +1627,SNX6 +1628,MET +1629,POLR3G +1630,SNX7 +1631,SCAMP2 +1632,SDHA +1633,CCDC62 +1634,SHC1 +1635,GFOD1 +1636,COPZ2 +1637,GSTZ1 +1638,NLRP5 +1639,ZWILCH +1640,CRBN +1641,HMOX2 +1642,URB2 +1643,NUDT17 +1644,EPHB4 +1645,CAPG +1646,DSG2 +1647,R3HDML +1648,TMEM106A +1649,PAPLN +1650,LIPA +1651,SLC46A2 +1652,HDAC11 +1653,MIXL1 +1654,MICALL1 +1655,TXLNA +1656,ARHGEF33 +1657,DMTF1 +1658,CDC20 +1659,IQGAP1 +1660,CDC25B +1661,PSMF1 +1662,RSBN1 +1663,RSRC1 +1664,PARP2 +1665,NEUROG1 +1666,MRPL36 +1667,GNAS +1668,PCCB +1669,FLG +1670,S100A13 +1671,MMEL1 +1672,MAF +1673,CARD6 +1674,HOMER2 +1675,NUDT1 +1676,ETFB +1677,RALA +1678,PIK3AP1 +1679,TNFRSF8 +1680,TUBD1 +1681,DCTN5 +1682,OR2J3 +1683,ADAM10 +1684,RELN +1685,PARP9 +1686,IVD +1687,RPIA +1688,SRSF6 +1689,CRYBB1 +1690,ARFIP2 +1691,GOLT1B +1692,TEX30 +1693,DENND1B +1694,KPNB1 +1695,E2F2 +1696,ATXN7L3 +1697,ELMOD2 +1698,GFUS +1699,TOR2A +1700,G6PC3 +1701,TFF3 +1702,OSBPL5 +1703,FRMD6 +1704,UBXN7 +1705,PAF1 +1706,SLC25A1 +1707,NYAP2 +1708,RXRA +1709,ADRA1D +1710,ARHGAP9 +1711,TRIB1 +1712,DLX3 +1713,RGMB +1714,RBP4 +1715,SGCB +1716,NES +1717,GUK1 +1718,DTL +1719,QARS1 +1720,HEATR1 +1721,IQGAP2 +1722,MFSD3 +1723,HERPUD1 +1724,ZNHIT6 +1725,FAM163A +1726,CDCA4 +1727,ZMYM2 +1728,ADAMTSL1 +1729,LIG1 +1730,CLPS +1731,HCAR1 +1732,APEX1 +1733,DDIT4 +1734,PDLIM1 +1735,DDX42 +1736,CORO1B +1737,TKT +1738,ACSL3 +1739,ETV1 +1740,PCNA +1741,PSMB5 +1742,APOBEC1 +1743,SERTAD1 +1744,NKIRAS1 +1745,NLRC4 +1746,SAMD4B +1747,AKT3 +1748,PDCD11 +1749,IL2RA +1750,DEFB118 +1751,BLTP2 +1752,CDS2 +1753,MAT2A +1754,FAM110A +1755,BBS4 +1756,PAN2 +1757,IFIT1 +1758,TMEM235 +1759,HAND2 +1760,ST6GAL1 +1761,SYT8 +1762,SYMPK +1763,NOSIP +1764,INPP4B +1765,GNA15 +1766,FSTL1 +1767,RAB6B +1768,ABCB4 +1769,GABRB1 +1770,MAPK7 +1771,MIA2 +1772,ICAM3 +1773,NUDT9 +1774,ALOX12B +1775,ABHD4 +1776,ROR1 +1777,CYP1A2 +1778,GABARAPL2 +1779,BRD10 +1780,TNKS +1781,DNAJB8 +1782,PKD1 +1783,TSEN2 +1784,PAK1IP1 +1785,ASB2 +1786,EPHB2 +1787,TARS3 +1788,MME +1789,PACSIN3 +1790,NRSN1 +1791,ZBTB7C +1792,STK11 +1793,EDNRA +1794,PIM1 +1795,CTU1 +1796,PLEKHM1 +1797,HS2ST1 +1798,SLC22A8 +1799,BMP2K +1800,FSCB +1801,CYTL1 +1802,HIVEP1 +1803,WFDC10A +1804,GRIP1 +1805,LMTK3 +1806,CXCL6 +1807,MTARC2 +1808,RIPK1 +1809,RRS1 +1810,LONP2 +1811,KRTAP5-6 +1812,PDE7A +1813,CDC7 +1814,SMARCD2 +1815,SLC8A1 +1816,LIMS1 +1817,ELOVL7 +1818,VPS28 +1819,MFAP5 +1820,PNPLA6 +1821,MYBPHL +1822,GNA13 +1823,CLEC4D +1824,ZDHHC2 +1825,ENTPD8 +1826,DUSP3 +1827,CERK +1828,FUT1 +1829,LANCL1 +1830,ANKRD10 +1831,MGMT +1832,GH2 +1833,IGFL1 +1834,NAB1 +1835,NDUFAF2 +1836,NUP133 +1837,IGHMBP2 +1838,SERTAD2 +1839,METTL3 +1840,CYP27C1 +1841,ATP6V0B +1842,ANKRD40 +1843,S100A16 +1844,LY6H +1845,HS6ST3 +1846,IFIH1 +1847,CEL +1848,KCTD13 +1849,LRRC1 +1850,ARHGAP25 +1851,DYNLT4 +1852,ALLC +1853,ZNF530 +1854,CCDC116 +1855,MADD +1856,TBX2 +1857,ERI1 +1858,DDX1 +1859,GPBP1L1 +1860,DNER +1861,FSD1 +1862,CLMN +1863,ENTPD2 +1864,MACF1 +1865,MTX3 +1866,EHD3 +1867,BPHL +1868,SYCN +1869,AKR7A2 +1870,PRRX2 +1871,ZNF330 +1872,ZNF473 +1873,PPOX +1874,MUC13 +1875,GLOD4 +1876,HDC +1877,GRPEL1 +1878,CDKN2AIPNL +1879,CHST14 +1880,CYB5B +1881,F12 +1882,RAB11FIP2 +1883,UQCR11 +1884,IPO7 +1885,ZDHHC13 +1886,SDHB +1887,COG2 +1888,MESP2 +1889,ID2 +1890,SPTAN1 +1891,FAM3D +1892,GALR2 +1893,MYBPC2 +1894,GEMIN7 +1895,TESC +1896,ZNF426 +1897,SMARCA2 +1898,PRKRIP1 +1899,KCNB1 +1900,PYGB +1901,AGRN +1902,PTPN12 +1903,NR3C1 +1904,TRIM13 +1905,AK7 +1906,MED19 +1907,NTRK1 +1908,LRRC66 +1909,HHLA2 +1910,DNASE2B +1911,DIXDC1 +1912,RIC3 +1913,AAAS +1914,NEU1 +1915,AMFR +1916,HIPK2 +1917,RBM45 +1918,PAQR9 +1919,FANCM +1920,NUP153 +1921,SNX17 +1922,MPEG1 +1923,LIN28B +1924,SGK3 +1925,HPSE2 +1926,MT1H +1927,WDR7 +1928,ZNF345 +1929,MMS22L +1930,DDB2 +1931,CER1 +1932,VPS41 +1933,NCALD +1934,ORMDL1 +1935,ZNF462 +1936,NRXN2 +1937,ZKSCAN3 +1938,TYMS +1939,CDK5R1 +1940,OXSR1 +1941,ZNF620 +1942,PRR4 +1943,SLC16A1 +1944,TLCD3A +1945,CIMAP1D +1946,IGSF21 +1947,NFKBIE +1948,GALNT8 +1949,SLC6A13 +1950,GABRG3 +1951,NANOGNB +1952,S1PR1 +1953,ARHGEF2 +1954,KRT84 +1955,RSPH3 +1956,SLC25A36 +1957,TFAP4 +1958,CCDC102A +1959,C9 +1960,MYOG +1961,APCDD1 +1962,HIF3A +1963,ATF6 +1964,RTP4 +1965,FBN2 +1966,STON2 +1967,CSF2RB +1968,HAS1 +1969,ZC2HC1C +1970,GJC2 +1971,SMNDC1 +1972,MYBL1 +1973,CLTB +1974,PPP1CB +1975,ABCC4 +1976,PYY +1977,HDAC2 +1978,TTC19 +1979,PSIP1 +1980,PALB2 +1981,PPP1R3G +1982,NCOA3 +1983,PBX1 +1984,CAMK1G +1985,USP14 +1986,CHRNB1 +1987,TPM1 +1988,ROCK1 +1989,MBOAT7 +1990,POR +1991,GATA2 +1992,ELAVL1 +1993,DCBLD1 +1994,CA12 +1995,ZNF274 +1996,DCUN1D4 +1997,PELP1 +1998,RABEP2 +1999,LMO1 +2000,FCHO1 +2001,ROM1 +2002,OR10Z1 +2003,GPR45 +2004,ZBTB46 +2005,ATP2C1 +2006,EFCAB14 +2007,DNAJA3 +2008,SYT11 +2009,ACTA1 +2010,ZMIZ1 +2011,TERF2IP +2012,TRIB3 +2013,CPSF4 +2014,STK25 +2015,CRTAM +2016,BAG3 +2017,CYP2A13 +2018,TLR4 +2019,RHOH +2020,ATP6V0A1 +2021,STK10 +2022,MRPL40 +2023,PRKCQ +2024,DUSP22 +2025,NISCH +2026,TENM2 +2027,CDC25A +2028,THAP11 +2029,CDYL +2030,SAMD9 +2031,BCL2L12 +2032,ACVR1C +2033,CDK6 +2034,GCLC +2035,SLC11A2 +2036,NTRK2 +2037,SYNGR2 +2038,SDC4 +2039,DRAP1 +2040,TMEM132A +2041,GRIA4 +2042,CDK5R2 +2043,FREM1 +2044,GABRB2 +2045,SLC1A4 +2046,NDUFA3 +2047,PHLDA1 +2048,ATG5 +2049,PENK +2050,CDC42 +2051,FTMT +2052,KRAS +2053,PAX2 +2054,PHGDH +2055,ZNF589 +2056,MPP4 +2057,SNRPD1 +2058,HAL +2059,TIMELESS +2060,NRG3 +2061,DHX9 +2062,TEX19 +2063,RBM6 +2064,ZBTB20 +2065,RAP1A +2066,FOS +2067,WDR77 +2068,LIMK1 +2069,SLC29A2 +2070,RRP12 +2071,MS4A5 +2072,ATP6V1C2 +2073,TRAPPC6A +2074,WDR1 +2075,PIK3R3 +2076,RARA +2077,BHMT +2078,GALE +2079,SLC22A7 +2080,SWAP70 +2081,CXCR4 +2082,TSKU +2083,TMCO1 +2084,KCNG4 +2085,PLK3 +2086,AGTPBP1 +2087,DSPP +2088,KIAA0753 +2089,ARVCF +2090,PDGFRB +2091,COG7 +2092,GET1 +2093,COX5A +2094,CMPK1 +2095,CNOT2 +2096,CSDE1 +2097,STC1 +2098,RELB +2099,OR51E1 +2100,CEACAM19 +2101,MTOR +2102,CHMP5 +2103,KLK2 +2104,ARL4C +2105,PRRC2B +2106,ZNF347 +2107,PSMB7 +2108,SLC16A3 +2109,FBXL12 +2110,SMC4 +2111,SWT1 +2112,FBXO34 +2113,ANO10 +2114,ZNF35 +2115,HS3ST5 +2116,SYNM +2117,MAP4K4 +2118,TVP23A +2119,ABTB1 +2120,AXIN1 +2121,GATAD1 +2122,GADD45B +2123,SCAP +2124,SNX13 +2125,RAC1 +2126,CCDC102B +2127,CSHL1 +2128,ERCC1 +2129,FAM151B +2130,KCND3 +2131,TMEM198 +2132,GNA11 +2133,CD40 +2134,HKDC1 +2135,TMEM106B +2136,BARD1 +2137,FUCA2 +2138,SULF1 +2139,ZKSCAN5 +2140,CYP2S1 +2141,HYOU1 +2142,PFN3 +2143,SMARCA4 +2144,SMU1 +2145,MAP2K2 +2146,RNF207 +2147,IDUA +2148,PWP1 +2149,KANSL1L +2150,MYC +2151,GLRX +2152,KIT +2153,CCL28 +2154,PLCXD3 +2155,LEFTY1 +2156,POLDIP3 +2157,RAB42 +2158,PCDHB15 +2159,F11R +2160,MAP4K2 +2161,STXBP2 +2162,NRL +2163,NR4A2 +2164,EFTUD2 +2165,RRP1B +2166,ZHX3 +2167,CCDC86 +2168,MRPS16 +2169,ATG16L1 +2170,RFPL2 +2171,JMJD6 +2172,SRSF1 +2173,LAP3 +2174,SMARCC1 +2175,SDC1 +2176,KTN1 +2177,CCNH +2178,CREB1 +2179,CAB39 +2180,EIF4EBP1 +2181,RAI14 +2182,SYNGR3 +2183,GLS2 +2184,MED29 +2185,BIRC2 +2186,ATXN7L3B +2187,H2AZ2 +2188,KRTAP6-2 +2189,EFCC1 +2190,ASPH +2191,ARHGEF18 +2192,NMNAT3 +2193,ATAD2 +2194,HLA-DRB5 +2195,PRLR +2196,COL1A1 +2197,CPEB2 +2198,TSPAN9 +2199,SERPINA6 +2200,PRKDC +2201,RHOA +2202,FZD7 +2203,GRB14 +2204,COASY +2205,HABP2 +2206,PSME1 +2207,NOP10 +2208,FDFT1 +2209,SEMA4F +2210,MYH4 +2211,KCNK18 +2212,IL4R +2213,SIRT3 +2214,TRAP1 +2215,DIPK1C +2216,VGLL4 +2217,ST3GAL6 +2218,PRKCA +2219,NFKBIA +2220,LTBP3 +2221,MRPL23 +2222,RALGDS +2223,XPO7 +2224,SIGLEC6 +2225,OR51V1 +2226,MOXD1 +2227,ZNF789 +2228,FBXO47 +2229,MIEN1 +2230,SHH +2231,PTPRC +2232,SAMD15 +2233,ERGIC1 +2234,ZNF736 +2235,SOX15 +2236,MYOZ3 +2237,DYSF +2238,SLC2A1 +2239,METAP2 +2240,LAYN +2241,SEC24C +2242,MLEC +2243,FANCF +2244,BNIP3 +2245,CCP110 +2246,COPS7A +2247,RAB11FIP3 +2248,CDK1 +2249,TSN +2250,TP53INP2 +2251,STAB2 +2252,ODF4 +2253,ARID5B +2254,SRSF7 +2255,MAU2 +2256,SPAG7 +2257,RRP8 +2258,OR13A1 +2259,ZBTB9 +2260,EPN2 +2261,SLC30A5 +2262,USP45 +2263,TAF4B +2264,TOPBP1 +2265,STK4 +2266,YWHAZ +2267,CYB561 +2268,SORBS3 +2269,TBX19 +2270,HEPACAM +2271,WIPF2 +2272,CPPED1 +2273,JAK3 +2274,CD320 +2275,PTPN6 +2276,SLC25A32 +2277,TRIM66 +2278,ZFP36 +2279,MEX3B +2280,CHST4 +2281,FRZB +2282,WDR70 +2283,KCNN3 +2284,ITK +2285,TM7SF3 +2286,RNF168 +2287,CRY2 +2288,CCDC170 +2289,PRMT5 +2290,POU4F1 +2291,ACTR1A +2292,NPC1 +2293,PGM1 +2294,INCENP +2295,PIN1 +2296,MAPKAPK5 +2297,ZNF471 +2298,CDH9 +2299,RDH12 +2300,MANBAL +2301,GABPB1 +2302,POLR2I +2303,ZNF3 +2304,PIAS1 +2305,GPR63 +2306,AMER2 +2307,NFKBIB +2308,NEFH +2309,GATA5 +2310,ABCB5 +2311,HTRA1 +2312,ADA +2313,NELL1 +2314,RUNDC3A +2315,QSOX1 +2316,ZNF415 +2317,LGR4 +2318,CHAC1 +2319,SEC31B +2320,CDK2 +2321,LTK +2322,MAPK14 +2323,EIF2AK3 +2324,TIAM2 +2325,NUCB2 +2326,AURKB +2327,OPRK1 +2328,MARCHF3 +2329,IL26 +2330,AFAP1L1 +2331,OR2T1 +2332,SORCS2 +2333,RASD2 +2334,CCN4 +2335,GBP2 +2336,TENT4A +2337,UBQLN4 +2338,MEGF11 +2339,IFNAR1 +2340,MDFIC +2341,FAM118A +2342,ENOPH1 +2343,POLE +2344,TTPAL +2345,NUSAP1 +2346,ELAC2 +2347,NEUROD4 +2348,PRKCD +2349,NR1H3 +2350,KCNH7 +2351,MESP1 +2352,RIN2 +2353,PGAM2 +2354,SERPINB1 +2355,RGS9 +2356,SLC15A3 +2357,UBE3A +2358,DOCK8 +2359,CCDC34 +2360,TNFSF11 +2361,GCKR +2362,FCRL4 +2363,TMEM179B +2364,CEBPZ +2365,ZNF92 +2366,SLC24A2 +2367,POLG +2368,ACVR2B +2369,ANXA4 +2370,MYBPC1 +2371,SLC23A2 +2372,CP +2373,DLK2 +2374,EDEM1 +2375,ANGEL2 +2376,CD2BP2 +2377,DHTKD1 +2378,FYN +2379,MUC1 +2380,EID2 +2381,HNRNPA3 +2382,MRPL9 +2383,INCA1 +2384,DNMT3A +2385,ATG3 +2386,IGF2BP2 +2387,SLC10A6 +2388,TG +2389,TMPRSS9 +2390,KAT6B +2391,SUPV3L1 +2392,HOXA11 +2393,PRKAA1 +2394,OR51S1 +2395,GDNF +2396,FAIM2 +2397,SPRTN +2398,RPS18 +2399,DDX18 +2400,PARPBP +2401,CDH3 +2402,TOR1A +2403,PLA2G1B +2404,SFN +2405,NGFR +2406,LLGL2 +2407,DAG1 +2408,ZNF135 +2409,IFT172 +2410,B4GALT4 +2411,LSM6 +2412,BECN1 +2413,NUAK2 +2414,DHX32 +2415,ITIH3 +2416,RND2 +2417,TGFB1 +2418,NOTCH1 +2419,EBAG9 +2420,REEP5 +2421,KDM5B +2422,GPR39 +2423,FAM114A2 +2424,KLF10 +2425,TEX10 +2426,MPC2 +2427,ZNF404 +2428,DCTD +2429,SYNE2 +2430,ZNF502 +2431,FLRT1 +2432,ERMN +2433,NUP88 +2434,PDE4B +2435,SSH1 +2436,FAM204A +2437,MAP1A +2438,C5 +2439,PPARG +2440,HELB +2441,CALM3 +2442,POP4 +2443,CARD11 +2444,DENND4B +2445,UBE2J1 +2446,VSTM1 +2447,FASLG +2448,ASRGL1 +2449,LCE3C +2450,MORC2 +2451,ZNF284 +2452,ADAM2 +2453,FAM216B +2454,ARHGAP32 +2455,EMC7 +2456,RPS6KA1 +2457,SLC38A11 +2458,MYBL2 +2459,CASC3 +2460,MAGI1 +2461,RAP1GAP +2462,ARSI +2463,EFCAB5 +2464,CD300A +2465,SPA17 +2466,NPHP3 +2467,HAT1 +2468,TLL2 +2469,MPZL3 +2470,PEPD +2471,KCTD6 +2472,ATP2A2 +2473,KIF11 +2474,CBR1 +2475,NTF3 +2476,ATMIN +2477,ATOH8 +2478,STIM1 +2479,FYCO1 +2480,ABCC5 +2481,PRPF4 +2482,FOXJ3 +2483,MEPE +2484,GIMAP6 +2485,NIT1 +2486,RBM43 +2487,ASCC3 +2488,FJX1 +2489,STMN1 +2490,TNIP1 +2491,PHF12 +2492,CHN2 +2493,FUCA1 +2494,MEST +2495,TBX20 +2496,TNNI3 +2497,NUAK1 +2498,SLC36A1 +2499,ACP5 +2500,GDA +2501,ILK +2502,BLCAP +2503,FOXN2 +2504,GLIS2 +2505,NMT1 +2506,GTPBP8 +2507,STK11IP +2508,NEBL +2509,TIMM9 +2510,DAPK3 +2511,GSR +2512,PAFAH1B3 +2513,KPNA4 +2514,POLR2K +2515,CRCP +2516,RPN1 +2517,FAT1 +2518,LRRC40 +2519,FIS1 +2520,USP6NL +2521,EBF2 +2522,DGAT1 +2523,ZFC3H1 +2524,MCM3 +2525,TTC39C +2526,FGFR1 +2527,YME1L1 +2528,LETM1 +2529,CCDC73 +2530,STRA8 +2531,PPP2R2D +2532,WNT5A +2533,PSCA +2534,HDAC3 +2535,SMTNL2 +2536,KLF12 +2537,DEFA5 +2538,RNMT +2539,ZNF414 +2540,TOP1 +2541,ADAT1 +2542,BCLAF1 +2543,TTBK1 +2544,ARHGDIB +2545,LDHAL6B +2546,TRPV4 +2547,SNCA +2548,ITGB5 +2549,HDAC9 +2550,PRSS23 +2551,CHMP2B +2552,NR2C2 +2553,FAH +2554,HINT2 +2555,GSDMB +2556,SF3A1 +2557,TPRX1 +2558,BUB1 +2559,RAB33B +2560,ZP1 +2561,SOX8 +2562,SOCS2 +2563,FGFBP3 +2564,ITGB2 +2565,RGS2 +2566,JUN +2567,HK1 +2568,SUV39H2 +2569,ZBTB43 +2570,NFIL3 +2571,PHKB +2572,APP +2573,DVL2 +2574,SPATA32 +2575,FAM111B +2576,ACRV1 +2577,L3MBTL3 +2578,ZNF490 +2579,SLC25A37 +2580,NFKB2 +2581,GJB4 +2582,ACSM2A +2583,PHOX2A +2584,MAP3K8 +2585,DUSP14 +2586,OXA1L +2587,ELL2 +2588,UCHL5 +2589,SAMD5 +2590,AARS1 +2591,KCNG1 +2592,SLC22A5 +2593,DENND2A +2594,PLCH2 +2595,SEMA3D +2596,RIMS4 +2597,AXDND1 +2598,HIVEP2 +2599,CLIC4 +2600,SPEN +2601,IAPP +2602,EAPP +2603,CAPN1 +2604,RPRD1B +2605,CCNB1 +2606,PTGDR2 +2607,BRD3 +2608,LRRC55 +2609,PIM3 +2610,AFG2B +2611,CAT +2612,PLXNA2 +2613,GAA +2614,BRD9 +2615,IGSF9B +2616,TFCP2L1 +2617,CEACAM4 +2618,GJA4 +2619,SACM1L +2620,USP16 +2621,CREBBP +2622,ADCY3 +2623,UBR2 +2624,UBE2C +2625,CASP2 +2626,SMCR8 +2627,GTF2F2 +2628,CHMP1A +2629,ACD +2630,DFFB +2631,UBR7 +2632,NEXN +2633,PLG +2634,HSPD1 +2635,DDX10 +2636,FAM135B +2637,DDX31 +2638,BEND4 +2639,FNTA +2640,ACLY +2641,TCTN1 +2642,MIER1 +2643,AKR1E2 +2644,PTPRF +2645,CEP350 +2646,OSBPL3 +2647,PDK1 +2648,GDE1 +2649,CRTAP +2650,DOT1L +2651,ATP10A +2652,SLC27A3 +2653,FARP2 +2654,CSNK1A1L +2655,PIGM +2656,RNH1 +2657,EPRS1 +2658,FLT3 +2659,EEIG2 +2660,NUMBL +2661,SDCCAG8 +2662,ACBD5 +2663,NOS3 +2664,USP22 +2665,FXN +2666,IDI1 +2667,PEX2 +2668,DHDDS +2669,MFAP3L +2670,VWDE +2671,SLC29A3 +2672,RRAGA +2673,GPR137 +2674,PDGFA +2675,CLECL1P +2676,TLCD3B +2677,KDR +2678,CCDC122 +2679,GFOD2 +2680,AKAP8L +2681,PLA2G4A +2682,PRMT6 +2683,MELK +2684,RFNG +2685,GGH +2686,VEZF1 +2687,GPRC5C +2688,SLC25A30 +2689,REC8 +2690,MKNK2 +2691,RANBP3 +2692,CEP57 +2693,BCL2L1 +2694,HCLS1 +2695,CHST1 +2696,SLC16A7 +2697,WASF3 +2698,IPO13 +2699,VENTX +2700,MMP1 +2701,ACADVL +2702,ST7 +2703,TTK +2704,IFT57 +2705,LAMA3 +2706,FLT1 +2707,TJP1 +2708,PLCB3 +2709,SSTR1 +2710,BACE2 +2711,PPP1R13B +2712,SSRP1 +2713,KLHL21 +2714,BRD8 +2715,SH2B3 +2716,CLEC4E +2717,AKT1 +2718,CAD +2719,ARID4B +2720,CHD9 +2721,PTPRS +2722,ETV3 +2723,LYRM1 +2724,TEKT1 +2725,TCP10L3 +2726,PUF60 +2727,GFPT2 +2728,F3 +2729,SPO11 +2730,TSPAN19 +2731,MAPK1IP1L +2732,RD3 +2733,CXCL2 +2734,SREBF1 +2735,POC5 +2736,CHMP6 +2737,ACTRT2 +2738,SERINC5 +2739,NVL +2740,WDR27 +2741,IMP3 +2742,BLTP3B diff --git a/drevalpy/components/featurizers/cell_line/gene_lists/proteomics_intersection.csv b/drevalpy/components/featurizers/cell_line/gene_lists/proteomics_intersection.csv new file mode 100644 index 000000000..d4dc12ff0 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/gene_lists/proteomics_intersection.csv @@ -0,0 +1,537 @@ +,Symbol +0,RRM1 +1,FLNB +2,FKBP4 +3,HSPA2 +4,SRP68 +5,CKAP4 +6,EIF2A +7,BID +8,PNKP +9,COX5A +10,CTSB +11,CMPK1 +12,CSDE1 +13,CNOT2 +14,CNPY3 +15,ESYT2 +16,PKM +17,NDUFA2 +18,CPOX +19,TUBA1C +20,IDE +21,VAPB +22,PSMB7 +23,SMC4 +24,NUP93 +25,SET +26,RTF1 +27,PDHX +28,CTNND1 +29,DECR1 +30,UFM1 +31,RAC1 +32,EIF5 +33,MTHFD1L +34,PCBD1 +35,DNAJB1 +36,GNA11 +37,DNTTIP2 +38,CAST +39,PLOD3 +40,DEK +41,HYOU1 +42,GTF2B +43,SMARCA4 +44,SMU1 +45,MVP +46,PWP1 +47,MRGBP +48,PREB +49,GLRX +50,OXCT1 +51,NOL7 +52,GRWD1 +53,RAB21 +54,HDLBP +55,POLDIP3 +56,F11R +57,SNAPIN +58,BAX +59,GLRX5 +60,EBNA1BP2 +61,EFTUD2 +62,RRP1B +63,MRPS2 +64,IRGQ +65,CCDC86 +66,MRPS16 +67,RPL30 +68,ANXA7 +69,LAP3 +70,SRSF1 +71,ATP1B1 +72,SMARCC1 +73,BRD4 +74,LARP4 +75,KTN1 +76,CREB1 +77,STAMBP +78,PAK2 +79,PGM2 +80,BUB3 +81,SART1 +82,GNG5 +83,PPIE +84,CSNK2A2 +85,CTTN +86,SLC27A4 +87,ASPH +88,NDUFA13 +89,ATAD2 +90,PRKDC +91,ATP6V1G1 +92,RPA3 +93,CBR3 +94,MYCBP +95,RHOA +96,COX5B +97,COASY +98,PSME1 +99,CNDP2 +100,PYCR1 +101,NDUFAB1 +102,NOP10 +103,SAR1A +104,FDFT1 +105,HEBP1 +106,RSU1 +107,HNRNPU +108,G3BP1 +109,EPB41L2 +110,TRAP1 +111,NAMPT +112,RAB7A +113,ECH1 +114,PSMD4 +115,GTPBP1 +116,WBP11 +117,MRPL23 +118,UBE2L3 +119,CIRBP +120,SYPL1 +121,ERGIC1 +122,TPD52L2 +123,NACC1 +124,CIAPIN1 +125,MRPL18 +126,COPZ1 +127,RNPS1 +128,NRAS +129,SLC2A1 +130,NDUFB2 +131,METAP2 +132,THOP1 +133,SEC24C +134,MLEC +135,VAT1 +136,PPIC +137,SPR +138,COPS7A +139,USP7 +140,GNAI2 +141,PARP1 +142,CDK1 +143,SKP1 +144,PPT1 +145,MAPK1 +146,TSN +147,HDAC1 +148,GFPT1 +149,UQCRH +150,SLC39A10 +151,CAPZB +152,TM9SF2 +153,SRSF7 +154,KIF2C +155,RPA2 +156,RRP8 +157,CSE1L +158,ECHS1 +159,UFC1 +160,YWHAZ +161,ATP6V1B2 +162,MRPS26 +163,VCL +164,DHX16 +165,NCAPD2 +166,SNRPA +167,RRP9 +168,WDR70 +169,VPS35 +170,BAD +171,ADI1 +172,XAB2 +173,CALU +174,CRK +175,GEMIN5 +176,RUVBL2 +177,FAHD1 +178,ARHGAP1 +179,PRMT5 +180,CRYZ +181,SWAP70 +182,PCBP2 +183,SRPK1 +184,ACTR1A +185,DCTN2 +186,LBR +187,RUVBL1 +188,PGM1 +189,INCENP +190,BLMH +191,PIN1 +192,MPZL1 +193,TMED4 +194,EIF2S1 +195,PTMS +196,PTPN1 +197,SNX2 +198,PTPN11 +199,CDK5 +200,HSPA8 +201,TCERG1 +202,DHODH +203,SNX6 +204,POLR2I +205,ALDH7A1 +206,APMAP +207,SDHA +208,SCAMP2 +209,MBNL1 +210,TRUB1 +211,HMOX2 +212,SCRN1 +213,DSG2 +214,IGF2R +215,NUCB2 +216,AURKB +217,SQSTM1 +218,TXLNA +219,IDH2 +220,NDUFB1 +221,IQGAP1 +222,UBQLN4 +223,PSMF1 +224,BLVRA +225,NIPSNAP1 +226,ENOPH1 +227,POLR1C +228,NUSAP1 +229,ELAC2 +230,CCT5 +231,CISD1 +232,GNAS +233,PCCB +234,S100A13 +235,ADH5 +236,NUDT1 +237,CELF1 +238,ETFB +239,YKT6 +240,CDKN2AIP +241,RALA +242,ADAM10 +243,GSTP1 +244,IVD +245,TRA2B +246,SRSF6 +247,CALR +248,KPNB1 +249,CEBPZ +250,STX4 +251,YWHAQ +252,ANXA4 +253,PAF1 +254,SLC25A1 +255,ARF4 +256,CASP3 +257,CD2BP2 +258,TXNRD1 +259,GUK1 +260,SCP2 +261,HEATR1 +262,COPB2 +263,HNRNPA3 +264,MRPL9 +265,ATG3 +266,IGF2BP2 +267,PDS5A +268,CPNE3 +269,SUPV3L1 +270,PRKAA1 +271,LIG1 +272,APEX1 +273,PAFAH1B1 +274,NUDT5 +275,RPS18 +276,PAICS +277,DDX18 +278,DDX42 +279,RPP25L +280,PDLIM1 +281,CORO1B +282,SFN +283,NUP107 +284,DAG1 +285,AP2A1 +286,EMC10 +287,SUPT16H +288,ACSL3 +289,UBTF +290,TOP2A +291,PPIA +292,PCNA +293,UBQLN1 +294,PSMB5 +295,LSM6 +296,TRAPPC3 +297,NDUFA10 +298,PDCD11 +299,SUGT1 +300,ABCF1 +301,MYO18A +302,EBAG9 +303,NCOR2 +304,MAT2A +305,REEP5 +306,CSNK1A1 +307,MSH2 +308,WDR74 +309,SYMPK +310,NOSIP +311,SYNE2 +312,NPLOC4 +313,NUP88 +314,PDP1 +315,MRPL46 +316,NLE1 +317,AKAP12 +318,PCK2 +319,RPS6 +320,MAPK3 +321,EMC7 +322,NENF +323,NCSTN +324,LSG1 +325,HAT1 +326,EXOSC4 +327,HSP90AA1 +328,RRS1 +329,RING1 +330,CSTB +331,PEPD +332,DLD +333,PTK2 +334,SMARCD2 +335,ATP2A2 +336,KIF11 +337,CBR1 +338,STIM1 +339,MAN2B1 +340,GNA13 +341,DLST +342,PRPF4 +343,DUSP3 +344,SUZ12 +345,TEX264 +346,NIT1 +347,ACAT2 +348,STMN1 +349,XPNPEP1 +350,DNMT1 +351,PRDX6 +352,NDUFAF2 +353,NNT +354,ZC3HAV1 +355,NUP133 +356,MSH6 +357,RABL3 +358,ILK +359,NMT1 +360,TIMM9 +361,GSR +362,WDR5 +363,PAFAH1B3 +364,KPNA4 +365,RPN1 +366,HSPA4 +367,LRRC40 +368,FIS1 +369,DDX1 +370,PDIA5 +371,SCCPDH +372,GAPDH +373,MCM3 +374,YME1L1 +375,LETM1 +376,ME2 +377,NPM1 +378,RNMT +379,TOP1 +380,AKR7A2 +381,EHMT1 +382,SLC25A13 +383,GLOD4 +384,BZW2 +385,GRPEL1 +386,ITGB5 +387,CHMP2B +388,CYB5B +389,HINT2 +390,ACAA1 +391,IPO7 +392,PNP +393,SF3A1 +394,SDHB +395,RPN2 +396,SMC3 +397,PPIL1 +398,STXBP1 +399,RAB33B +400,SUGP2 +401,MTHFD2 +402,PYGB +403,HK1 +404,DNAJC2 +405,AKAP8 +406,PUS1 +407,PDCD6 +408,AAAS +409,AMFR +410,MRPL19 +411,RFC5 +412,CRKL +413,OXA1L +414,UCHL5 +415,ARFGAP1 +416,TMEM109 +417,PCMT1 +418,TOMM34 +419,PLS1 +420,SURF6 +421,OXSR1 +422,NUP85 +423,CLIC4 +424,SLC16A1 +425,SPEN +426,MAT2B +427,P4HA2 +428,AARS2 +429,MRTO4 +430,TACO1 +431,CAPN1 +432,RPRD1B +433,DFFA +434,ARHGEF2 +435,TMED10 +436,SMARCB1 +437,DDX6 +438,CAT +439,GAA +440,SMNDC1 +441,RFC2 +442,APOE +443,CLTB +444,SACM1L +445,PPP1CB +446,TOR1AIP1 +447,HDAC2 +448,PSIP1 +449,GTF2F2 +450,CHMP1A +451,IARS2 +452,UBR7 +453,RAE1 +454,LRPAP1 +455,HSPD1 +456,RPA1 +457,DDX10 +458,SRP54 +459,DAXX +460,USP14 +461,NTMT1 +462,TPM1 +463,POR +464,ELAVL1 +465,ACLY +466,HMGCS1 +467,ATP6V1C1 +468,PTPRF +469,PELP1 +470,EHMT2 +471,CRTAP +472,CREG1 +473,DNAJA3 +474,TERF2IP +475,CPSF4 +476,HSPA5 +477,RNH1 +478,BAG3 +479,ASCC2 +480,ATP6V0A1 +481,MRPL40 +482,SLTM +483,FXN +484,IDI1 +485,REPS1 +486,ASNS +487,STAT1 +488,CD44 +489,AKAP8L +490,DHRS7 +491,CLTC +492,GGH +493,TUBB +494,DRAP1 +495,IMPA1 +496,PPA2 +497,ALDOA +498,KDM2A +499,RANBP3 +500,PIH1D1 +501,CLPX +502,ACBD3 +503,UGDH +504,APRT +505,CDC42 +506,HADH +507,PHGDH +508,TUBB6 +509,TJP1 +510,MRPL22 +511,CSK +512,TM9SF3 +513,PLCB3 +514,SNRPD1 +515,SLC7A1 +516,SSRP1 +517,DHX9 +518,RBM6 +519,HSP90AB1 +520,RAP1A +521,CAD +522,WDR77 +523,RRP12 +524,RALB +525,PUF60 +526,IDH1 +527,RPS5 +528,WDR1 +529,GLS +530,PKN1 +531,PSPH +532,CHMP6 +533,NVL +534,IMP3 +535,TMCO1 diff --git a/drevalpy/components/featurizers/cell_line/landmark.py b/drevalpy/components/featurizers/cell_line/landmark.py new file mode 100644 index 000000000..7a9a7c462 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/landmark.py @@ -0,0 +1,249 @@ +"""Landmark gene featurizers for literature models.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.components.featurizers.cell_line.gene_lists import gene_names_from_list_csv, resolve_gene_list_path +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.feature_source import FeatureSource + +if TYPE_CHECKING: + from sklearn.preprocessing import MinMaxScaler, StandardScaler + + +def _load_gene_indices( + source: FeatureSource, + view: str, + gene_list_stem: str, +) -> list[int]: + names = source.get_feature_names(view) + if names is None: + msg = f"FeatureSource has no feature names for view {view!r}" + raise ValueError(msg) + gene_list_path = resolve_gene_list_path(gene_list_stem) + selected_genes = gene_names_from_list_csv(gene_list_path) + gene_to_idx = {str(gene): index for index, gene in enumerate(names)} + indices = [gene_to_idx[gene] for gene in selected_genes if gene in gene_to_idx] + if not indices: + msg = f"No genes from {gene_list_stem!r} matched view {view!r}" + raise ValueError(msg) + return indices + + +@register( + "landmarkGenes", + description="L1000 landmark genes with arcsinh and optional scaling.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class LandmarkGenesFeaturizer(DenseViewCellLineFeaturizer): + """Landmark genes featurizer component.""" + + input_views: ClassVar[tuple[str, ...]] = ("gene_expression",) + requires_fit: ClassVar[bool] = True + + def __init__( + self, + *, + view: str = "gene_expression", + gene_list_stem: str = "landmark_genes", + standardize: bool = True, + minmax_scale: bool = False, + arcsinh: bool = True, + ) -> None: + """Initialize instance state. + + :param view: view. + :param gene_list_stem: gene list stem. + :param standardize: standardize. + :param minmax_scale: minmax scale. + :param arcsinh: arcsinh. + """ + super().__init__(view=view) + self._gene_list_stem = gene_list_stem + self._standardize = standardize + self._minmax_scale = minmax_scale + self._arcsinh = arcsinh + self._gene_indices: list[int] = [] + self._scaler: StandardScaler | None = None + self._minmax: MinMaxScaler | None = None + + def _fit_state(self, source: FeatureSource, entity_ids: np.ndarray) -> int: + """Select the landmark genes and fit the optional scalers. + + :param source: Feature source providing view matrices. + :param entity_ids: Cell-line identifiers to fit on. + :returns: Number of selected genes. + """ + self._gene_indices = _load_gene_indices(source, self._view, self._gene_list_stem) + matrix = self._select_and_transform(self._raw_matrix(source, entity_ids)) + self._fit_scalers(matrix) + return len(self._gene_indices) + + def _fit_scalers(self, matrix: np.ndarray) -> None: + """Fit the standard and optional min-max scalers on *matrix*. + + :param matrix: Gene-subset matrix for the fit entities. + """ + if not self._standardize: + self._scaler = None + self._minmax = None + return + from sklearn.preprocessing import MinMaxScaler, StandardScaler + + self._scaler = StandardScaler() + self._scaler.fit(matrix) + if self._minmax_scale: + self._minmax = MinMaxScaler() + self._minmax.fit(self._scaler.transform(matrix)) + else: + self._minmax = None + + def _select_and_transform(self, matrix: np.ndarray) -> np.ndarray: + """Subset *matrix* to the selected genes and apply the optional arcsinh. + + :param matrix: Raw view matrix. + :returns: Gene-subset matrix, before scaling. + """ + selected = np.nan_to_num(matrix.astype(np.float64)[:, self._gene_indices], nan=0.0, posinf=0.0, neginf=0.0) + return np.arcsinh(selected) if self._arcsinh else selected + + def _compute_matrix(self, source: FeatureSource, matrix: np.ndarray) -> np.ndarray: + """Subset, transform and scale *matrix*. + + :param source: Feature source the matrix came from. + :param matrix: Raw view matrix for the requested entity IDs. + :returns: Landmark-gene feature matrix. + """ + _ = source + result = self._select_and_transform(matrix) + if self._scaler is not None: + result = self._scaler.transform(result) + if self._minmax is not None: + result = self._minmax.transform(result) + return result + + def _block_name(self) -> str: + """Publish under the canonical gene-expression block name. + + :returns: Block name. + """ + return "gene_expression" + + def _block_feature_names(self, source: FeatureSource) -> tuple[str, ...] | None: + """Return only the names of the selected landmark genes. + + :param source: Feature source providing view matrices. + :returns: Selected gene names, or ``None`` when the source has none. + """ + names = source.get_feature_names(self._view) + if names is None: + return None + return tuple(str(names[index]) for index in self._gene_indices) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "standardize": {"type": "categorical", "choices": [True, False], "default": True}, + "minmax_scale": {"type": "categorical", "choices": [True, False], "default": False}, + } + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + if not self._is_fitted: + return {} + return { + "view": self._view, + "gene_list_stem": self._gene_list_stem, + "standardize": self._standardize, + "minmax_scale": self._minmax_scale, + "arcsinh": self._arcsinh, + "gene_indices": list(self._gene_indices), + "scaler": self._scaler, + "minmax": self._minmax, + "output_dim": self._output_dim, + "fitted": True, + } + + def _restore_landmark_identity(self, state: dict[str, object]) -> None: + stem = state.get("gene_list_stem") + if isinstance(stem, str): + self._gene_list_stem = stem + if "standardize" in state: + self._standardize = bool(state["standardize"]) + if "minmax_scale" in state: + self._minmax_scale = bool(state["minmax_scale"]) + if "arcsinh" in state: + self._arcsinh = bool(state["arcsinh"]) + + def _restore_landmark_fit_state(self, state: dict[str, object]) -> None: + from sklearn.preprocessing import MinMaxScaler, StandardScaler + + gene_indices = state.get("gene_indices") + if isinstance(gene_indices, list): + self._gene_indices = [int(index) for index in gene_indices] + scaler = state.get("scaler") + if isinstance(scaler, StandardScaler) or scaler is None: + self._scaler = scaler + minmax = state.get("minmax") + if isinstance(minmax, MinMaxScaler) or minmax is None: + self._minmax = minmax + self._restore_dense_state(state) + if not isinstance(state.get("output_dim"), int) and self._gene_indices: + self._output_dim = len(self._gene_indices) + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + self._restore_landmark_identity(state) + self._restore_landmark_fit_state(state) + + +@register( + "landmarkGenesReduced", + description="Reduced landmark gene set used by DrugGNN and PharmaFormer.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class LandmarkGenesReducedFeaturizer(LandmarkGenesFeaturizer): + """Landmark genes reduced featurizer component.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX),) + + def __init__( + self, + *, + view: str = "gene_expression", + standardize: bool = False, + minmax_scale: bool = False, + arcsinh: bool = False, + **kwargs: Any, + ) -> None: + """Initialize the reduced landmark featurizer variant. + + :param view: Omics view name (defaults to ``gene_expression``). + :param standardize: Whether to z-score features after loading. + :param minmax_scale: Whether to min-max scale features after loading. + :param arcsinh: Whether to apply ``arcsinh`` transform after loading. + :param kwargs: Ignored keyword arguments. + """ + super().__init__( + view=view, + gene_list_stem="landmark_genes_reduced", + standardize=standardize, + minmax_scale=minmax_scale, + arcsinh=arcsinh, + ) diff --git a/drevalpy/components/featurizers/cell_line/molir_omics.py b/drevalpy/components/featurizers/cell_line/molir_omics.py new file mode 100644 index 000000000..a56037ad7 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/molir_omics.py @@ -0,0 +1,164 @@ +"""MOLIR multi-omics preprocessing featurizer.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec, FeatureBlock, numeric_feature_block +from drevalpy.types.data.feature_source import FeatureSource + +_VIEWS = ("gene_expression", "mutations", "copy_number_variation_gistic") + + +@register( + "molirOmics", + description="MOLIR multi-omics input preparation.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class MOLIROmicsFeaturizer(DenseViewCellLineFeaturizer): + """Arcsinh-scale and select variable gene-expression features for MOLIR.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("mutations", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("copy_number_variation_gistic", FeatureFormat.NUMERIC_MATRIX), + ) + input_views: ClassVar[tuple[str, ...]] = _VIEWS + fit_on_unique_ids: ClassVar[bool] = True + + def __init__(self, *, n_gene_expression_features: int = 1000) -> None: + """Store the variance-selection feature count and initialize scalers. + + :param n_gene_expression_features: Number of gene-expression features to keep. + """ + from sklearn.preprocessing import StandardScaler + + super().__init__() + self._n_features = int(n_gene_expression_features) + self._scaler = StandardScaler() + self._mask: np.ndarray = np.array([], dtype=bool) + self._selected_feature_names: tuple[str, ...] = () + self._feature_names: dict[str, tuple[str, ...] | None] = {} + + def _on_precomputed_fit(self, source: FeatureSource) -> None: + """Accept every stored column and record the per-view feature names. + + :param source: Feature source carrying the stored variant. + """ + self._mask = np.ones(self._output_dim, dtype=bool) + self._feature_names = {view: source.get_feature_names(view) for view in _VIEWS} + + def _fit_state(self, source: FeatureSource, entity_ids: np.ndarray) -> int: + """Fit the scaler and pick the highest-variance gene-expression features. + + :param source: Feature source providing view matrices. + :param entity_ids: Deduplicated cell-line identifiers to fit on. + :returns: Number of selected gene-expression features. + """ + matrix = np.arcsinh(self._raw_matrix(source, entity_ids)) + self._scaler.fit(matrix) + variances = np.var(self._scaler.transform(matrix), axis=0) + self._mask = np.zeros(len(variances), dtype=bool) + self._mask[np.argsort(variances)[::-1][: min(self._n_features, len(variances))]] = True + + ge_names = source.get_feature_names("gene_expression") + self._selected_feature_names = () if ge_names is None else tuple(np.array(ge_names)[self._mask]) + self._feature_names = { + "gene_expression": self._selected_feature_names, + **{view: source.get_feature_names(view) for view in _VIEWS[1:]}, + } + return int(self._mask.sum()) + + def _compute_matrix(self, source: FeatureSource, matrix: np.ndarray) -> np.ndarray: + """Arcsinh-scale *matrix* and keep only the selected features. + + :param source: Feature source the matrix came from. + :param matrix: Raw gene-expression matrix. + :returns: Selected gene-expression features. + """ + _ = source + return self._scaler.transform(np.arcsinh(matrix))[:, self._mask] + + def _block_feature_names(self, source: FeatureSource) -> tuple[str, ...] | None: + """Return the selected gene names recorded at fit time. + + :param source: Feature source (unused; names are recorded during fit). + :returns: Selected gene-expression feature names, or ``None``. + """ + _ = source + return self._feature_names.get("gene_expression") + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Return per-omics numeric blocks for MOLIR. + + The gene-expression block goes through the shared dense path; the two other + omics views are passed through untouched. + + :param source: Feature source providing view matrices. + :param entity_ids: Cell-line identifiers to transform. + :returns: Mapping of omics view name to numeric blocks. + """ + return { + **super()._transform_blocks(source, entity_ids), + **{ + view: numeric_feature_block( + source.get_view_matrix(view, entity_ids).astype(np.float32), + feature_names=self._feature_names.get(view), + ) + for view in _VIEWS[1:] + }, + } + + @property + def output_dim(self) -> int: + """Return the number of selected gene-expression features. + + :returns: Selected gene-expression feature count. + """ + return int(self._mask.sum()) if self._mask.size > 0 else 0 + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable variance-selection feature count. + + :returns: Ray Tune-style hyperparameter space mapping. + """ + return {"n_gene_expression_features": {"type": "int", "low": 1, "high": 1000, "default": 1000}} + + def get_state(self) -> dict[str, object]: + """Serialize scaler, mask, and feature-name metadata. + + :returns: Fitted state mapping. + """ + return { + "scaler": self._scaler, + "mask": self._mask, + "selected_feature_names": self._selected_feature_names, + "feature_names": self._feature_names, + "n_gene_expression_features": self._n_features, + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore scaler, mask, and feature names from ``get_state``. + + :param state: Mapping previously returned by ``get_state``. + """ + from sklearn.preprocessing import StandardScaler + + scaler = state.get("scaler") + if isinstance(scaler, StandardScaler): + self._scaler = scaler + mask = state.get("mask") + if isinstance(mask, np.ndarray): + self._mask = mask + selected = state.get("selected_feature_names") + if isinstance(selected, tuple): + self._selected_feature_names = selected + names = state.get("feature_names") + if isinstance(names, dict): + self._feature_names = {str(key): value for key, value in names.items()} diff --git a/drevalpy/components/featurizers/cell_line/normalized_proteomics.py b/drevalpy/components/featurizers/cell_line/normalized_proteomics.py new file mode 100644 index 000000000..3a1996d43 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/normalized_proteomics.py @@ -0,0 +1,159 @@ +"""Normalized proteomics featurizer for cell lines. + +``ProteomicsMedianCenterAndImputeTransformer`` lives in +``_proteomics_transformer`` because it subclasses ``sklearn.base.BaseEstimator``, +which cannot be deferred past the ``class`` statement. It is re-exported here +through ``__getattr__`` so the historical import path - and any checkpoint +pickled against it - keeps resolving without importing ``sklearn`` at +registration time. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.feature_source import FeatureSource + +if TYPE_CHECKING: + from drevalpy.components.featurizers.cell_line._proteomics_transformer import ( + ProteomicsMedianCenterAndImputeTransformer, + ) + +__all__ = [ + "NormalizedProteomicsCellLineFeaturizer", + "ProteomicsMedianCenterAndImputeTransformer", + "log10_and_set_na", +] + + +def __getattr__(name: str) -> Any: + """Resolve the re-exported transformer on first access. + + :param name: Attribute being looked up on this module. + :returns: The requested attribute. + :raises AttributeError: If *name* is not re-exported here. + """ + if name == "ProteomicsMedianCenterAndImputeTransformer": + from drevalpy.components.featurizers.cell_line._proteomics_transformer import ( + ProteomicsMedianCenterAndImputeTransformer as _Transformer, + ) + + return _Transformer + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) + + +def log10_and_set_na(x: np.ndarray) -> np.ndarray: + """Log10 transform and set NaN for infinite values. + + :param x: input array + :returns: log10 transformed array with NaN for infinite values + """ + x = np.log10(x) + x[np.isinf(x)] = np.nan + return x + + +@register( + "normalizedProteomics", + description="Proteomics view with log10 transform, median centering, and imputation.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class NormalizedProteomicsCellLineFeaturizer(DenseViewCellLineFeaturizer): + """Match sklearn baseline proteomics preprocessing.""" + + input_views: ClassVar[tuple[str, ...]] = ("proteomics",) + fit_on_unique_ids: ClassVar[bool] = True + + def __init__( + self, + *, + view: str = "proteomics", + proteomics_feature_threshold: float = 0.7, + proteomics_n_features: int = 1000, + proteomics_normalization_width: float = 0.3, + proteomics_normalization_downshift: float = 1.8, + ) -> None: + """Initialize instance state. + + :param view: view. + :param proteomics_feature_threshold: proteomics feature threshold. + :param proteomics_n_features: proteomics n features. + :param proteomics_normalization_width: proteomics normalization width. + :param proteomics_normalization_downshift: proteomics normalization downshift. + """ + from drevalpy.components.featurizers.cell_line._proteomics_transformer import ( + ProteomicsMedianCenterAndImputeTransformer, + ) + + super().__init__(view=view) + self._transformer = ProteomicsMedianCenterAndImputeTransformer( + feature_threshold=proteomics_feature_threshold, + n_features=proteomics_n_features, + normalization_width=proteomics_normalization_width, + normalization_downshift=proteomics_normalization_downshift, + ) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable hyperparameter specs. + + :returns: HP space mapping. + """ + return { + "proteomics_feature_threshold": {"type": "float", "low": 0.3, "high": 0.9, "default": 0.7}, + "proteomics_n_features": {"type": "int", "low": 500, "high": 2000, "default": 1000}, + "proteomics_normalization_downshift": {"type": "float", "low": 1.0, "high": 3.0, "default": 1.8}, + "proteomics_normalization_width": {"type": "float", "low": 0.1, "high": 0.6, "default": 0.3}, + } + + def _fit_state(self, source: FeatureSource, entity_ids: np.ndarray) -> int: + """Fit the median-centering transformer on log10 training rows. + + :param source: Feature source providing view matrices. + :param entity_ids: Deduplicated cell-line identifiers to fit on. + :returns: Number of retained proteins. + """ + self._transformer.fit(log10_and_set_na(self._raw_matrix(source, entity_ids))) + return len(self._transformer.protein_indices) + + def _compute_matrix(self, source: FeatureSource, matrix: np.ndarray) -> np.ndarray: + """Log10-transform *matrix* and apply the fitted transformer row by row. + + :param source: Feature source the matrix came from. + :param matrix: Raw view matrix for the requested entity IDs. + :returns: Normalized feature matrix. + """ + _ = source + rows = [self._transformer.transform(row[None, :])[0] for row in log10_and_set_na(matrix)] + return np.vstack(rows) + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + return { + "proteomics_transformer": self._transformer, + "view": self._view, + "output_dim": self._output_dim, + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + from drevalpy.components.featurizers.cell_line._proteomics_transformer import ( + ProteomicsMedianCenterAndImputeTransformer, + ) + + transformer = state.get("proteomics_transformer") + if isinstance(transformer, ProteomicsMedianCenterAndImputeTransformer): + self._transformer = transformer + self._restore_dense_state(state) diff --git a/drevalpy/components/featurizers/cell_line/pathways.py b/drevalpy/components/featurizers/cell_line/pathways.py new file mode 100644 index 000000000..de7598b8d --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/pathways.py @@ -0,0 +1,174 @@ +"""Pathway featurizer for Precily.""" + +from __future__ import annotations + +import os +import tempfile +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.log import get_logger +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.batch.feature_block import FeatureBlock, numeric_feature_block +from drevalpy.types.data.feature_source import FeatureSource + +logger = get_logger(__name__) + + +@register( + "pathways", + description="GSVA pathway features computed per-split (set-dependent).", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class PathwaysCellLineFeaturizer(CellLineFeaturizer): + """Pathways cell line featurizer component. + + Set-dependent: GSVA enrichment scores depend on the distribution of all + samples in the expression matrix, so they must be computed per-split. + """ + + input_views: ClassVar[tuple[str, ...]] = ("pathways",) + source_views: ClassVar[tuple[str, ...]] = ("gene_expression",) + precompute: ClassVar[bool] = False + + def __init__(self, *, view: str = "pathways") -> None: + """Initialize instance state. + + :param view: view. + """ + self._view = view + self._output_dim = 0 + self._fit_scores: np.ndarray | None = None + self._fit_ids: np.ndarray | None = None + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> PathwaysCellLineFeaturizer: + """Compute GSVA on training cell lines. + + :param source: Feature source providing cell-line views. + :param entity_ids: Training cell-line IDs. + :param pair_expanded_ids: Unused training IDs with duplicates. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: Fitted featurizer instance. + """ + _ = pair_expanded_ids, pair_expanded_es_ids + ids = entity_ids if entity_ids is not None else source.identifiers + result = self._run_gsva(source, ids) + self._fit_scores = result + self._fit_ids = ids + self._output_dim = int(result.shape[1]) + return self + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Return GSVA scores for requested entities. + + :param source: Feature source providing cell-line views. + :param entity_ids: Cell-line IDs to transform. + :returns: Float32 feature matrix. + """ + if self._fit_scores is None: + msg = "PathwaysCellLineFeaturizer must be fit before transform" + raise RuntimeError(msg) + + id_map = {str(id_): i for i, id_ in enumerate(self._fit_ids)} + if all(str(id_) in id_map for id_ in entity_ids): + indices = [id_map[str(id_)] for id_ in entity_ids] + return self._fit_scores[indices].astype(np.float32) + + all_ids = np.unique(np.concatenate([self._fit_ids, entity_ids])) + result = self._run_gsva(source, all_ids) + id_map = {str(id_): i for i, id_ in enumerate(all_ids)} + indices = [id_map[str(id_)] for id_ in entity_ids] + return result[indices].astype(np.float32) + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Transform blocks. + + :param source: Feature source providing cell-line views. + :param entity_ids: entity ids. + :returns: Result. + """ + return { + self._view: numeric_feature_block( + self._transform(source, entity_ids), + feature_names=None, + ) + } + + @property + def output_dim(self) -> int: + """Return output feature dimension after fitting. + + :returns: Result. + """ + return self._output_dim + + def _run_gsva(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Run GSVA on the given cell lines. + + :param source: Feature source providing cell-line views. + :param entity_ids: Cell-line IDs. + :returns: Float32 array of shape (len(entity_ids), n_pathways). + """ + import gseapy as gp + + mdata = getattr(source, "mdata", None) + if mdata is None or "pathways_gmt" not in mdata.uns: + msg = "Pathway features require pathways_gmt in mdata.uns" + raise ValueError(msg) + + import pandas as pd + + expr_matrix = source.get_view_matrix("gene_expression", entity_ids) + gene_names = source.get_feature_names("gene_expression") + if gene_names is None: + msg = "gene_expression view must provide feature names" + raise ValueError(msg) + + expr_df = pd.DataFrame(expr_matrix, index=entity_ids, columns=list(gene_names)) + expr_df = expr_df.loc[~expr_df.index.duplicated(keep="first")] + expr_genes_by_samples = expr_df.T + + gmt_text: str = mdata.uns["pathways_gmt"] + with tempfile.NamedTemporaryFile(mode="w", suffix=".gmt", delete=False) as f: + f.write(gmt_text) + gmt_path = f.name + + gv = gp.gsva( + data=expr_genes_by_samples, + gene_sets=gmt_path, + kcdf="Gaussian", + min_size=5, + max_size=2000, + mx_diff=True, + threads=4, + seed=42, + outdir=None, + verbose=False, + ) + + os.unlink(gmt_path) + + long = gv.res2d.copy() + cols = {c.lower(): c for c in long.columns} + term_col = cols.get("term", "Term") + name_col = cols.get("name", "Name") + es_col = cols.get("es", cols.get("nes", "ES")) + wide = long.pivot(index=term_col, columns=name_col, values=es_col) + scores = wide.T.astype(np.float32) + + result = np.zeros((len(entity_ids), scores.shape[1]), dtype=np.float32) + for i, cl in enumerate(entity_ids): + if cl in scores.index: + result[i] = scores.loc[cl].values + + return result diff --git a/drevalpy/components/featurizers/cell_line/pca.py b/drevalpy/components/featurizers/cell_line/pca.py new file mode 100644 index 000000000..b5cf41ddc --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/pca.py @@ -0,0 +1,128 @@ +"""PCA cell-line featurizer.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers._matrix import feature_names_for_view +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.feature_source import FeatureSource + + +@register( + "pca", + description="PCA compression of one dense cell-line view fit on training cell lines.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class PCACellLineFeaturizer(DenseViewCellLineFeaturizer): + """Reduce one cell-line view with PCA.""" + + requires_view: ClassVar[bool] = True + + def __init__(self, *, view: str, n_components: int = 128) -> None: + """Initialize instance state. + + :param view: view. + :param n_components: n components. + :raises ValueError: Raised on invalid input. + """ + from sklearn.decomposition import PCA + + if not view or not view.strip(): + msg = "pca featurizer requires an explicit view" + raise ValueError(msg) + super().__init__(view=view) + self._n_components = int(n_components) + self._pca = PCA(n_components=self._n_components) + self._feature_names: tuple[str, ...] | None = None + + def _fetch_hyperparameters(self) -> dict[str, Any]: + """Match only a variant stored with this component count. + + :returns: HP mapping identifying the stored variant. + """ + return {"n_components": self._n_components} + + def _on_precomputed_fit(self, source: FeatureSource) -> None: + """Record the source's feature names alongside a stored variant. + + :param source: Feature source carrying the stored variant. + """ + self._feature_names = feature_names_for_view(source, self._view) + + def _fit_state(self, source: FeatureSource, entity_ids: np.ndarray) -> int: + """Fit PCA on the training rows and return the retained component count. + + :param source: Feature source providing views for the entity type. + :param entity_ids: Cell-line identifiers to fit on. + :returns: Number of retained components. + """ + matrix = self._raw_matrix(source, entity_ids) + n_components = min(self._n_components, matrix.shape[0], matrix.shape[1]) + self._pca.n_components = n_components + self._pca.fit(matrix) + self._feature_names = feature_names_for_view(source, self._view) + return n_components + + def _compute_matrix(self, source: FeatureSource, matrix: np.ndarray) -> np.ndarray: + """Project *matrix* through the fitted PCA, realigning columns by name first. + + :param source: Feature source the matrix came from. + :param matrix: Raw view matrix for the requested entity IDs. + :returns: PCA-reduced feature matrix. + """ + names = feature_names_for_view(source, self._view) + if self._feature_names is not None and names is not None: + source_indices = {name: index for index, name in enumerate(names)} + aligned = np.zeros((matrix.shape[0], len(self._feature_names)), dtype=matrix.dtype) + for index, name in enumerate(self._feature_names): + source_index = source_indices.get(name) + if source_index is not None: + aligned[:, index] = matrix[:, source_index] + matrix = aligned + return self._pca.transform(matrix) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "n_components": {"type": "int", "low": 8, "high": 512, "default": 128}, + } + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + return { + "pca": self._pca, + "view": self._view, + "n_components": self._n_components, + "output_dim": self._output_dim, + "feature_names": self._feature_names, + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + from sklearn.decomposition import PCA + + pca = state.get("pca") + if isinstance(pca, PCA): + self._pca = pca + n_components = state.get("n_components") + if isinstance(n_components, int): + self._n_components = n_components + feature_names = state.get("feature_names") + if isinstance(feature_names, tuple): + self._feature_names = tuple(str(name) for name in feature_names) + self._restore_dense_state(state) diff --git a/drevalpy/components/featurizers/cell_line/pharmaformer_gene_expression.py b/drevalpy/components/featurizers/cell_line/pharmaformer_gene_expression.py new file mode 100644 index 000000000..2505f0964 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/pharmaformer_gene_expression.py @@ -0,0 +1,129 @@ +"""PharmaFormer gene-expression preprocessing featurizer.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.feature_source import FeatureSource + + +@register( + "pharmaFormerGeneExpression", + description="Reduced landmark genes scaled for PharmaFormer.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class PharmaFormerGeneExpressionFeaturizer(DenseViewCellLineFeaturizer): + """Apply the PharmaFormer StandardScaler then MinMaxScaler sequence.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX),) + input_views: ClassVar[tuple[str, ...]] = ("gene_expression",) + requires_fit: ClassVar[bool] = True + + def __init__(self) -> None: + """Initialize StandardScaler and MinMaxScaler pipelines.""" + from sklearn.preprocessing import MinMaxScaler, StandardScaler + + super().__init__() + self._scaler = StandardScaler() + self._minmax = MinMaxScaler() + self._feature_names: tuple[str, ...] | None = None + + def _fit_entity_ids( + self, + source: FeatureSource, + entity_ids: np.ndarray | None, + pair_expanded_ids: np.ndarray | None, + pair_expanded_es_ids: np.ndarray | None, + ) -> np.ndarray: + """Fit on the pair-expanded training IDs, matching the reference pipeline. + + :param source: Feature source providing view matrices. + :param entity_ids: Unused; PharmaFormer fits on the pair-expanded IDs. + :param pair_expanded_ids: Training entity IDs with duplicates per response pair. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: The pair-expanded training IDs. + :raises ValueError: If *pair_expanded_ids* is missing. + """ + _ = source, entity_ids, pair_expanded_es_ids + if pair_expanded_ids is None: + raise ValueError("pharmaFormerGeneExpression requires pair_expanded_ids") + return pair_expanded_ids + + def _on_precomputed_fit(self, source: FeatureSource) -> None: + """Record the source's feature names alongside a stored variant. + + :param source: Feature source carrying the stored variant. + """ + self._feature_names = source.get_feature_names(self._view) + + def _fit_state(self, source: FeatureSource, entity_ids: np.ndarray) -> int: + """Fit both scalers on the pair-expanded training rows. + + :param source: Feature source providing view matrices. + :param entity_ids: Pair-expanded training entity IDs. + :returns: Output feature dimension. + """ + matrix = self._raw_matrix(source, entity_ids) + self._minmax.fit(self._scaler.fit_transform(matrix)) + self._feature_names = source.get_feature_names(self._view) + return int(matrix.shape[1]) + + def _compute_matrix(self, source: FeatureSource, matrix: np.ndarray) -> np.ndarray: + """Apply the fitted scalers to *matrix*. + + :param source: Feature source the matrix came from. + :param matrix: Raw view matrix for the requested entity IDs. + :returns: Scaled feature matrix. + """ + _ = source + return self._minmax.transform(self._scaler.transform(matrix)) + + def _block_feature_names(self, source: FeatureSource) -> tuple[str, ...] | None: + """Return the feature names captured at fit time. + + :param source: Feature source (unused; names are recorded during fit). + :returns: Recorded feature names, or ``None``. + """ + _ = source + return self._feature_names + + def get_state(self) -> dict[str, object]: + """Serialize scaler state and feature names. + + :returns: Fitted state mapping, or empty dict before fitting. + """ + if not self._is_fitted: + return {} + return { + "scaler": self._scaler, + "minmax": self._minmax, + "feature_names": self._feature_names, + "output_dim": self._output_dim, + "fitted": True, + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore scaler state from ``get_state``. + + :param state: Mapping previously returned by ``get_state``. + """ + from sklearn.preprocessing import MinMaxScaler, StandardScaler + + scaler, minmax = state.get("scaler"), state.get("minmax") + if isinstance(scaler, StandardScaler): + self._scaler = scaler + if isinstance(minmax, MinMaxScaler): + self._minmax = minmax + names = state.get("feature_names") + if isinstance(names, tuple): + self._feature_names = tuple(str(name) for name in names) + output_dim = state.get("output_dim") + if isinstance(output_dim, int): + self._output_dim = output_dim + self._is_fitted = bool(state.get("fitted")) diff --git a/drevalpy/components/featurizers/cell_line/raw.py b/drevalpy/components/featurizers/cell_line/raw.py new file mode 100644 index 000000000..8d4f456f5 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/raw.py @@ -0,0 +1,31 @@ +"""Generic raw dense pass-through featurizer for one omics view.""" + +from __future__ import annotations + +from typing import ClassVar + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import register + + +@register( + "raw", + description="Pass through one dense omics view without preprocessing.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class RawCellLineFeaturizer(DenseViewCellLineFeaturizer): + """Featurize one omics view as a dense matrix without transformation.""" + + requires_view: ClassVar[bool] = True + + def __init__(self, *, view: str) -> None: + """Initialize instance state. + + :param view: view. + :raises ValueError: Raised on invalid input. + """ + if not view or not view.strip(): + msg = "raw featurizer requires an explicit view" + raise ValueError(msg) + super().__init__(view=view) diff --git a/drevalpy/components/featurizers/cell_line/scaled_gene_expression.py b/drevalpy/components/featurizers/cell_line/scaled_gene_expression.py new file mode 100644 index 000000000..2e483d2c6 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/scaled_gene_expression.py @@ -0,0 +1,89 @@ +"""Scaled gene-expression featurizer for cell lines.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.feature_source import FeatureSource + + +@register( + "scaledGeneExpression", + description="Landmark gene expression with arcsinh transform and scaling.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class ScaledGeneExpressionFeaturizer(DenseViewCellLineFeaturizer): + """Match sklearn baseline gene-expression preprocessing.""" + + input_views: ClassVar[tuple[str, ...]] = ("gene_expression",) + requires_fit: ClassVar[bool] = True + fit_on_unique_ids: ClassVar[bool] = True + + def __init__(self, *, view: str = "gene_expression") -> None: + """Initialize instance state. + + :param view: view. + """ + from sklearn.preprocessing import StandardScaler + + super().__init__(view=view) + self._scaler = StandardScaler() + + def _fit_state(self, source: FeatureSource, entity_ids: np.ndarray) -> int: + """Fit the scaler on arcsinh-transformed training rows. + + :param source: Feature source providing view matrices. + :param entity_ids: Deduplicated cell-line identifiers to fit on. + :returns: Output feature dimension. + """ + matrix = np.arcsinh(self._raw_matrix(source, entity_ids)) + self._scaler.fit(matrix) + return int(matrix.shape[1]) + + def _compute_matrix(self, source: FeatureSource, matrix: np.ndarray) -> np.ndarray: + """Arcsinh-transform and scale *matrix*. + + :param source: Feature source the matrix came from. + :param matrix: Raw view matrix for the requested entity IDs. + :returns: Scaled feature matrix. + """ + _ = source + return self._scaler.transform(np.arcsinh(matrix)) + + def _block_name(self) -> str: + """Publish under the canonical gene-expression block name. + + :returns: Block name. + """ + return "gene_expression" + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + if not self._is_fitted: + return {} + return { + "gene_expression_scaler": self._scaler, + "view": self._view, + "output_dim": self._output_dim, + "fitted": True, + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + from sklearn.preprocessing import StandardScaler + + scaler = state.get("gene_expression_scaler") + if isinstance(scaler, StandardScaler): + self._scaler = scaler + self._restore_dense_state(state) diff --git a/drevalpy/components/featurizers/cell_line/sparsego_ontology.py b/drevalpy/components/featurizers/cell_line/sparsego_ontology.py new file mode 100644 index 000000000..b29078b40 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/sparsego_ontology.py @@ -0,0 +1,202 @@ +"""SparseGO ontology-aligned cell-line featurizer.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line._sparsego_metadata import ( + read_sparsego_ontology_metadata, +) +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec, FeatureBlock, numeric_feature_block +from drevalpy.types.data.feature_source import FeatureSource + +_INPUT_TYPES = frozenset({"expression", "mutations"}) + + +def _view_for_input_type(input_type: str) -> str: + """Map a SparseGO ``input_type`` to the omics view it reads. + + :param input_type: Either ``expression`` or ``mutations``. + :returns: Omics view name backing that input type. + """ + return "mutations" if input_type == "mutations" else "gene_expression" + + +@register( + "sparsegoOntology", + description="SparseGO ontology-aligned expression or mutation inputs.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class SparseGOOntologyFeaturizer(CellLineFeaturizer): + """Align an active omics view with the SparseGO ontology gene ordering.""" + + output_block_specs = (BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX, metadata=True),) + + @classmethod + def output_block_specs_for_config(cls, config: Any) -> tuple[BlockSpec, ...]: + """Name the active SparseGO block from ``input_type``. + + :param config: Featurizer template; uses space default when unresolved. + :returns: Single metadata-bearing numeric block for the active omics view. + """ + raw_space = getattr(config, "hyperparameter_space", None) or {} + space = dict(raw_space) if isinstance(raw_space, Mapping) else {} + if not space: + space = dict(cls.get_hyperparameter_space()) + spec = space.get("input_type") + if isinstance(spec, Mapping) and "default" in spec: + input_type = str(spec["default"]) + else: + input_type = "expression" + name = _view_for_input_type(input_type) + return (BlockSpec(name, FeatureFormat.NUMERIC_MATRIX, metadata=True),) + + @classmethod + def resolve_input_views(cls, **kwargs: Any) -> tuple[str, ...]: + """Return the omics view selected by ``input_type``. + + :param kwargs: Featurizer kwargs; ``input_type`` selects expression vs mutations. + :returns: Single-element tuple with the active omics view. + """ + return (_view_for_input_type(str(kwargs.get("input_type", "expression"))),) + + def __init__(self, *, input_type: str = "expression") -> None: + """Validate *input_type* and initialize ontology metadata placeholders. + + :param input_type: Either ``expression`` or ``mutations``. + :raises ValueError: If *input_type* is not supported. + """ + if input_type not in _INPUT_TYPES: + raise ValueError("input_type must be 'expression' or 'mutations'") + self._input_type = input_type + self._view = _view_for_input_type(input_type) + self._layer_connections: list[np.ndarray] | None = None + self._gene2id_mapping_ont: dict[str, int] | None = None + self._ontology_gene_order: tuple[str, ...] = () + self._gene_dim_input = 0 + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> SparseGOOntologyFeaturizer: + """Copy ontology metadata produced by ``load_features`` into fitted state. + + :param source: Feature source with ``sparsego_ontology`` metadata. + :param entity_ids: Unused. + :param pair_expanded_ids: Unused training IDs with duplicates. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: Fitted featurizer instance. + :raises ValueError: If ontology metadata is missing on *source*. + """ + _ = entity_ids, pair_expanded_ids, pair_expanded_es_ids + metadata = read_sparsego_ontology_metadata(source) + if metadata is None: + raise ValueError("SparseGO ontology metadata is missing; load features through sparsegoOntology") + self._layer_connections = list(metadata["layer_connections"]) + self._gene2id_mapping_ont = dict(metadata["gene2id_mapping_ont"]) + self._ontology_gene_order = tuple(metadata["ontology_gene_order"]) + self._gene_dim_input = int(metadata["gene_dim_input"]) + return self + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Return ontology-aligned omics matrix rows. + + :param source: Feature source providing view matrices. + :param entity_ids: Cell-line identifiers to transform. + :returns: Float matrix aligned to ontology gene order. + :raises RuntimeError: If called before ``fit``. + """ + if self._gene_dim_input == 0: + raise RuntimeError("SparseGOOntologyFeaturizer must be fit before transform") + mdata = getattr(source, "mdata", None) + precomputed = self.fetch(mdata, entity_ids) if mdata is not None else None + if precomputed is not None: + return precomputed.astype(np.float32) + return source.get_view_matrix(self._view, entity_ids).astype(np.float32) + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Return an omics block with SparseGO ontology metadata attached. + + :param source: Feature source providing view matrices. + :param entity_ids: Cell-line identifiers to transform. + :returns: Mapping with one metadata-rich numeric block. + """ + metadata: dict[str, Any] = { + "layer_connections": self._layer_connections, + "gene2id_mapping_ont": self._gene2id_mapping_ont, + "ontology_gene_order": self._ontology_gene_order, + "gene_dim_input": self._gene_dim_input, + } + return { + self._view: numeric_feature_block( + self.transform(source, entity_ids), + feature_names=source.get_feature_names(self._view), + metadata=metadata, + ) + } + + @property + def output_dim(self) -> int: + """Return ontology gene dimensionality. + + :returns: Number of ontology-aligned genes. + """ + return self._gene_dim_input + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable SparseGO input type. + + :returns: Ray Tune-style hyperparameter space mapping. + """ + return {"input_type": {"type": "categorical", "choices": ["expression", "mutations"], "default": "expression"}} + + def get_state(self) -> dict[str, object]: + """Serialize ontology metadata and input type. + + :returns: Fitted state mapping, or empty dict before fitting. + """ + if self._gene_dim_input == 0: + return {} + return { + "input_type": self._input_type, + "layer_connections": self._layer_connections, + "gene2id_mapping_ont": self._gene2id_mapping_ont, + "ontology_gene_order": self._ontology_gene_order, + "gene_dim_input": self._gene_dim_input, + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore ontology metadata from ``get_state``. + + :param state: Mapping previously returned by ``get_state``. + :raises ValueError: If stored ``input_type`` is invalid. + """ + input_type = state.get("input_type") + if isinstance(input_type, str): + if input_type not in _INPUT_TYPES: + raise ValueError("input_type must be 'expression' or 'mutations'") + self._input_type = input_type + self._view = _view_for_input_type(input_type) + mapping = state.get("gene2id_mapping_ont") + if isinstance(mapping, dict): + self._gene2id_mapping_ont = {str(key): int(value) for key, value in mapping.items()} + order = state.get("ontology_gene_order") + if isinstance(order, tuple): + self._ontology_gene_order = tuple(str(gene) for gene in order) + connections = state.get("layer_connections") + if isinstance(connections, list): + self._layer_connections = [np.asarray(connection) for connection in connections] + gene_dim_input = state.get("gene_dim_input") + if isinstance(gene_dim_input, int): + self._gene_dim_input = gene_dim_input diff --git a/drevalpy/components/featurizers/cell_line/superfeltr_omics.py b/drevalpy/components/featurizers/cell_line/superfeltr_omics.py new file mode 100644 index 000000000..363d49b81 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/superfeltr_omics.py @@ -0,0 +1,157 @@ +"""SuperFELTR multi-omics feature-selection featurizer.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec, FeatureBlock, numeric_feature_block +from drevalpy.types.data.feature_source import FeatureSource + +_VIEWS = ("gene_expression", "mutations", "copy_number_variation_gistic") + + +@register( + "superfeltrOmics", + description="SuperFELTR variance-selected multi-omics inputs.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class SuperFELTROmicsFeaturizer(CellLineFeaturizer): + """Select high-variance features independently in each SuperFELTR view.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("mutations", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("copy_number_variation_gistic", FeatureFormat.NUMERIC_MATRIX), + ) + input_views: ClassVar[tuple[str, ...]] = _VIEWS + + def __init__(self, *, n_features_per_view: int = 1000) -> None: + """Store per-view variance-selection feature count and initialize selectors. + + :param n_features_per_view: Number of features to keep per omics view. + """ + self._n_features = int(n_features_per_view) + self._masks: dict[str, np.ndarray] = {} + self._feature_names: dict[str, tuple[str, ...]] = {} + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> SuperFELTROmicsFeaturizer: + """Fit variance selectors independently in each omics view. + + :param source: Feature source providing view matrices. + :param entity_ids: Optional explicit fit ids. + :param pair_expanded_ids: Unused training IDs with duplicates. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: Fitted featurizer instance. + """ + _ = pair_expanded_ids, pair_expanded_es_ids + ids = np.unique(entity_ids if entity_ids is not None else source.identifiers) + mdata = getattr(source, "mdata", None) + precomputed = self.fetch(mdata, ids) if mdata is not None else None + if precomputed is not None: + for view in _VIEWS: + names = source.get_feature_names(view) + self._masks[view] = np.ones(1, dtype=bool) + self._feature_names[view] = tuple(names) if names else () + return self + for view in _VIEWS: + matrix = source.get_view_matrix(view, ids) + variances = np.var(matrix, axis=0) + mask = np.zeros(len(variances), dtype=bool) + mask[np.argsort(variances)[::-1][: min(self._n_features, len(variances))]] = True + self._masks[view] = mask + + names = source.get_feature_names(view) + if names is not None: + self._feature_names[view] = tuple(np.array(names)[mask]) + else: + self._feature_names[view] = () + return self + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Return variance-selected gene-expression features only. + + :param source: Feature source providing view matrices. + :param entity_ids: Cell-line identifiers to transform. + :returns: Float matrix of selected gene-expression features. + """ + mdata = getattr(source, "mdata", None) + precomputed = self.fetch(mdata, entity_ids) if mdata is not None else None + if precomputed is not None: + return precomputed.astype(np.float32) + mask = self._masks["gene_expression"] + return source.get_view_matrix("gene_expression", entity_ids)[:, mask].astype(np.float32) + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Return per-omics numeric blocks with variance-selected columns. + + :param source: Feature source providing view matrices. + :param entity_ids: Cell-line identifiers to transform. + :returns: Mapping of omics view name to numeric blocks. + """ + mdata = getattr(source, "mdata", None) + precomputed = self.fetch(mdata, entity_ids) if mdata is not None else None + if precomputed is not None: + return { + "gene_expression": numeric_feature_block( + precomputed.astype(np.float32), + feature_names=self._feature_names.get("gene_expression"), + ) + } + return { + view: numeric_feature_block( + source.get_view_matrix(view, entity_ids)[:, mask].astype(np.float32), + feature_names=self._feature_names.get(view), + ) + for view, mask in self._masks.items() + } + + @property + def output_dim(self) -> int: + """Return total selected features across all views. + + :returns: Sum of selected features in every view. + """ + return int(sum(mask.sum() for mask in self._masks.values())) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable per-view variance-selection feature count. + + :returns: Ray Tune-style hyperparameter space mapping. + """ + return {"n_features_per_view": {"type": "int", "low": 1, "high": 1000, "default": 1000}} + + def get_state(self) -> dict[str, object]: + """Serialize masks and feature-name metadata. + + :returns: Fitted state mapping. + """ + return { + "masks": self._masks, + "feature_names": self._feature_names, + "n_features_per_view": self._n_features, + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore masks and feature names from ``get_state``. + + :param state: Mapping previously returned by ``get_state``. + """ + masks = state.get("masks") + if isinstance(masks, dict) and all(isinstance(value, np.ndarray) for value in masks.values()): + self._masks = {str(key): value for key, value in masks.items()} + names = state.get("feature_names") + if isinstance(names, dict): + self._feature_names = {str(key): tuple(value) for key, value in names.items()} diff --git a/drevalpy/components/featurizers/cell_line/tissue.py b/drevalpy/components/featurizers/cell_line/tissue.py new file mode 100644 index 000000000..c55af84a0 --- /dev/null +++ b/drevalpy/components/featurizers/cell_line/tissue.py @@ -0,0 +1,140 @@ +"""Tissue metadata featurizer for cell lines.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers._one_hot import OneHotCategoryEncoder +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.data.utils import TISSUE_IDENTIFIER +from drevalpy.registry.cell_line_featurizer import register +from drevalpy.types.data.batch.feature_block import FeatureBlock, metadata_feature_block, numeric_feature_block +from drevalpy.types.data.feature_source import FeatureSource + + +def _tissue_label(source: FeatureSource, entity_id: str) -> str | None: + raw = source.get_entity_view(str(entity_id), TISSUE_IDENTIFIER) + if raw is None: + return None + return str(np.asarray(raw).reshape(-1)[0]) + + +@register( + "tissue", + description="One-hot encoding of tissue or lineage labels for cell-line entities.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class TissueFeaturizer(CellLineFeaturizer): + """Map each cell line to a dense one-hot tissue vector.""" + + input_views: ClassVar[tuple[str, ...]] = () + + def __init__(self, *, allow_missing: bool = False) -> None: + """Initialize instance state. + + :param allow_missing: allow missing. + """ + self._encoder = OneHotCategoryEncoder() + self._allow_missing = bool(allow_missing) + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> TissueFeaturizer: + """Fit on training data. + + :param source: Feature source providing views for the entity type. + :param entity_ids: entity ids. + :param pair_expanded_ids: Unused training IDs with duplicates. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: Result. + :raises ValueError: Raised on invalid input. + """ + _ = pair_expanded_ids, pair_expanded_es_ids + ids = entity_ids if entity_ids is not None else source.identifiers + available: list[str] = [] + for entity_id in ids: + label = _tissue_label(source, str(entity_id)) + if label is None: + if not self._allow_missing: + msg = "TissueFeaturizer requires tissue annotations in cell_line_input" + raise ValueError(msg) + continue + available.append(label) + if not available: + if self._allow_missing: + self._encoder.fit_categories(np.array([], dtype=str)) + return self + msg = "TissueFeaturizer requires tissue annotations in cell_line_input" + raise ValueError(msg) + self._encoder.fit_categories(np.asarray(available, dtype=str)) + return self + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Transform inputs into feature payloads. + + :param source: Feature source providing views for the entity type. + :param entity_ids: entity ids. + :returns: Result. + :raises ValueError: Raised on invalid input. + """ + if self._encoder.output_dim == 0: + return np.empty((len(entity_ids), 0), dtype=np.float32) + categories: list[str] = [] + for entity_id in entity_ids: + label = _tissue_label(source, str(entity_id)) + if label is None: + if not self._allow_missing: + msg = "TissueFeaturizer requires tissue annotations in cell_line_input" + raise ValueError(msg) + categories.append("__missing__") + else: + categories.append(label) + return self._encoder.transform(np.asarray(categories, dtype=str)) + + def _transform_blocks( + self, + source: FeatureSource, + entity_ids: np.ndarray, + ) -> dict[str, FeatureBlock]: + """Transform blocks. + + :param source: Feature source providing views for the entity type. + :param entity_ids: entity ids. + :returns: Result. + """ + return { + "tissue": numeric_feature_block(self._transform(source, entity_ids)), + "tissue_categories": metadata_feature_block( + np.asarray(self._encoder.categories, dtype=str), + ), + } + + @property + def output_dim(self) -> int: + """Return output feature dimension after fitting. + + :returns: Result. + """ + return self._encoder.output_dim + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + return self._encoder.get_state() + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + self._encoder.set_state(state) diff --git a/drevalpy/components/featurizers/drug/__init__.py b/drevalpy/components/featurizers/drug/__init__.py new file mode 100644 index 000000000..d898c0cbb --- /dev/null +++ b/drevalpy/components/featurizers/drug/__init__.py @@ -0,0 +1 @@ +"""Drug featurizers.""" diff --git a/drevalpy/components/featurizers/drug/_molgnet_network.py b/drevalpy/components/featurizers/drug/_molgnet_network.py new file mode 100644 index 000000000..14500275f --- /dev/null +++ b/drevalpy/components/featurizers/drug/_molgnet_network.py @@ -0,0 +1,405 @@ +"""MolGNet model and graph conversion utilities (adapted from DIPK). + +These classes were originally in ``scripts/featurizer/create_molgnet_embeddings.py`` +and are used at runtime by :class:`MolGNetDrugFeaturizer` to compute embeddings +on the fly when precomputed views are missing. +""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np +import torch +import torch.nn.functional as torch_nn_f +from torch import nn +from torch.nn import Parameter +from torch_geometric.data import Data +from torch_geometric.utils import add_self_loops, scatter, softmax + +try: + from rdkit import Chem + from rdkit.Chem.rdchem import Mol as RDMol +except ImportError as err: + raise ImportError("Please install rdkit package for MolGNet featurizer: pip install rdkit") from err + +allowable_features: dict[str, list[Any]] = { + "atomic_num": list(range(1, 122)), + "formal_charge": ["unk", -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5], + "chirality": [ + "unk", + Chem.rdchem.ChiralType.CHI_UNSPECIFIED, + Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CW, + Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CCW, + Chem.rdchem.ChiralType.CHI_OTHER, + ], + "hybridization": [ + "unk", + Chem.rdchem.HybridizationType.S, + Chem.rdchem.HybridizationType.SP, + Chem.rdchem.HybridizationType.SP2, + Chem.rdchem.HybridizationType.SP3, + Chem.rdchem.HybridizationType.SP3D, + Chem.rdchem.HybridizationType.SP3D2, + Chem.rdchem.HybridizationType.UNSPECIFIED, + ], + "numH": ["unk", 0, 1, 2, 3, 4, 5, 6, 7, 8], + "implicit_valence": ["unk", 0, 1, 2, 3, 4, 5, 6], + "degree": ["unk", 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "isaromatic": [False, True], + "bond_type": [ + "unk", + Chem.rdchem.BondType.SINGLE, + Chem.rdchem.BondType.DOUBLE, + Chem.rdchem.BondType.TRIPLE, + Chem.rdchem.BondType.AROMATIC, + ], + "bond_dirs": [ + Chem.rdchem.BondDir.NONE, + Chem.rdchem.BondDir.ENDUPRIGHT, + Chem.rdchem.BondDir.ENDDOWNRIGHT, + ], + "bond_isconjugated": [False, True], + "bond_inring": [False, True], + "bond_stereo": [ + "STEREONONE", + "STEREOANY", + "STEREOZ", + "STEREOE", + "STEREOCIS", + "STEREOTRANS", + ], +} + +atom_dic = [ + len(allowable_features["atomic_num"]), + len(allowable_features["formal_charge"]), + len(allowable_features["chirality"]), + len(allowable_features["hybridization"]), + len(allowable_features["numH"]), + len(allowable_features["implicit_valence"]), + len(allowable_features["degree"]), + len(allowable_features["isaromatic"]), +] +bond_dic = [ + len(allowable_features["bond_type"]), + len(allowable_features["bond_dirs"]), + len(allowable_features["bond_isconjugated"]), + len(allowable_features["bond_inring"]), + len(allowable_features["bond_stereo"]), +] +atom_cumsum = np.cumsum(atom_dic) +bond_cumsum = np.cumsum(bond_dic) + + +def mol_to_graph_data_obj_complex(mol: RDMol) -> Data: + """Convert an RDKit Mol into a torch_geometric Data object. + + :param mol: RDKit Mol instance. + :return: torch_geometric.data.Data with node and edge fields. + :raises ValueError: If mol is None. + """ + if mol is None: + raise ValueError("mol must not be None") + atom_features_list: list = [] + fc_list = allowable_features["formal_charge"] + ch_list = allowable_features["chirality"] + hyb_list = allowable_features["hybridization"] + numh_list = allowable_features["numH"] + imp_list = allowable_features["implicit_valence"] + deg_list = allowable_features["degree"] + isa_list = allowable_features["isaromatic"] + bt_list = allowable_features["bond_type"] + bd_list = allowable_features["bond_dirs"] + bic_list = allowable_features["bond_isconjugated"] + bir_list = allowable_features["bond_inring"] + bs_list = allowable_features["bond_stereo"] + for atom in mol.GetAtoms(): + a_idx = allowable_features["atomic_num"].index(atom.GetAtomicNum()) + fc_idx = fc_list.index(atom.GetFormalCharge()) + atom_cumsum[0] + ch_idx = ch_list.index(atom.GetChiralTag()) + atom_cumsum[1] + hyb_idx = hyb_list.index(atom.GetHybridization()) + atom_cumsum[2] + numh_idx = numh_list.index(atom.GetTotalNumHs()) + atom_cumsum[3] + imp_idx = imp_list.index(atom.GetValence(Chem.ValenceType.IMPLICIT)) + atom_cumsum[4] + deg_idx = deg_list.index(atom.GetDegree()) + atom_cumsum[5] + isa_idx = isa_list.index(atom.GetIsAromatic()) + atom_cumsum[6] + atom_feature = [a_idx, fc_idx, ch_idx, hyb_idx, numh_idx, imp_idx, deg_idx, isa_idx] + atom_features_list.append(atom_feature) + x = torch.tensor(np.array(atom_features_list), dtype=torch.long) + + num_bond_features = 5 + if len(mol.GetBonds()) > 0: + edges_list = [] + edge_features_list = [] + for bond in mol.GetBonds(): + i = bond.GetBeginAtomIdx() + j = bond.GetEndAtomIdx() + bt = bt_list.index(bond.GetBondType()) + bd = bd_list.index(bond.GetBondDir()) + bond_cumsum[0] + bic = bic_list.index(bond.GetIsConjugated()) + bond_cumsum[1] + bir = bir_list.index(bond.IsInRing()) + bond_cumsum[2] + bs = bs_list.index(str(bond.GetStereo())) + bond_cumsum[3] + edge_feature = [bt, bd, bic, bir, bs] + edges_list.append((i, j)) + edge_features_list.append(edge_feature) + edges_list.append((j, i)) + edge_features_list.append(edge_feature) + edge_index = torch.tensor(np.array(edges_list).T, dtype=torch.long) + edge_attr = torch.tensor(np.array(edge_features_list), dtype=torch.long) + else: + edge_index = torch.empty((2, 0), dtype=torch.long) + edge_attr = torch.empty((0, num_bond_features), dtype=torch.long) + + return Data(x=x, edge_index=edge_index, edge_attr=edge_attr) + + +class SelfLoop: + """Append self-loops and matching edge attributes to a Data object.""" + + def __call__(self, data: Data) -> Data: + """Add self-loop indices and corresponding edge attributes. + + :param data: torch_geometric.data.Data to modify. + :return: The modified Data object. + """ + num_nodes = data.num_nodes + data.edge_index, _ = add_self_loops(data.edge_index, num_nodes=num_nodes) + self_loop_attr = torch.LongTensor([0, 5, 8, 10, 12]).repeat(num_nodes, 1) + data.edge_attr = torch.cat((data.edge_attr, self_loop_attr), dim=0) + return data + + +class AddSegId: + """Attach zero-valued segment id tensors to nodes and edges.""" + + def __call__(self, data: Data) -> Data: + """Attach zero-filled node_seg and edge_seg tensors. + + :param data: torch_geometric.data.Data to modify. + :return: The modified Data object. + :raises ValueError: If the graph does not report a node count. + """ + num_nodes = data.num_nodes + if num_nodes is None: + msg = "Cannot add segment ids: graph reports no node count (data.num_nodes is None)." + raise ValueError(msg) + data.edge_seg = torch.LongTensor([0] * data.num_edges) + data.node_seg = torch.LongTensor([0] * num_nodes) + return data + + +class BertLayerNorm(nn.Module): + """Layer normalization compatible with BERT-style implementations.""" + + def __init__(self, hidden_size, eps=1e-12): + super().__init__() + self.eps = eps + self.weight = nn.Parameter(torch.ones(hidden_size)) + self.bias = nn.Parameter(torch.zeros(hidden_size)) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + u = x.mean(-1, keepdim=True) + s = (x - u).pow(2).mean(-1, keepdim=True) + x = (x - u) / torch.sqrt(s + self.eps) + return self.weight * x + self.bias + + +def _gelu(x: torch.Tensor) -> torch.Tensor: + return x * 0.5 * (1.0 + torch.erf(x / math.sqrt(2))) + + +def _bias_gelu(bias: torch.Tensor, y: torch.Tensor) -> torch.Tensor: + x = bias + y + return x * 0.5 * (1.0 + torch.erf(x / math.sqrt(2))) + + +class LinearActivation(nn.Module): + def __init__(self, in_features: int, out_features: int, bias: bool = True) -> None: + super().__init__() + if bias: + self.biased_act_fn = _bias_gelu + else: + self.act_fn = _gelu + self.weight = Parameter(torch.Tensor(out_features, in_features)) + if bias: + self.bias = Parameter(torch.Tensor(out_features)) + else: + self.register_parameter("bias", None) + self.reset_parameters() + + def reset_parameters(self) -> None: + nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5)) + if self.bias is not None: + fan_in, _ = nn.init._calculate_fan_in_and_fan_out(self.weight) + bound = 1 / math.sqrt(fan_in) + nn.init.uniform_(self.bias, -bound, bound) + + def forward(self, input: torch.Tensor) -> torch.Tensor: + if self.bias is not None: + return self.biased_act_fn(self.bias, torch_nn_f.linear(input, self.weight, None)) + return self.act_fn(torch_nn_f.linear(input, self.weight, self.bias)) + + +class Intermediate(nn.Module): + def __init__(self, hidden: int) -> None: + super().__init__() + self.dense_act = LinearActivation(hidden, 4 * hidden) + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + return self.dense_act(hidden_states) + + +class AttentionOut(nn.Module): + def __init__(self, hidden: int, dropout: float) -> None: + super().__init__() + self.dense = nn.Linear(hidden, hidden) + self.LayerNorm = BertLayerNorm(hidden, eps=1e-12) + self.dropout = nn.Dropout(dropout) + + def forward(self, hidden_states: torch.Tensor, input_tensor: torch.Tensor) -> torch.Tensor: + hidden_states = self.dense(hidden_states) + hidden_states = self.dropout(hidden_states) + return self.LayerNorm(hidden_states + input_tensor) + + +class GTOut(nn.Module): + def __init__(self, hidden: int, dropout: float) -> None: + super().__init__() + self.dense = nn.Linear(hidden * 4, hidden) + self.LayerNorm = BertLayerNorm(hidden, eps=1e-12) + self.dropout = nn.Dropout(dropout) + + def forward(self, hidden_states: torch.Tensor, input_tensor: torch.Tensor) -> torch.Tensor: + hidden_states = self.dense(hidden_states) + hidden_states = self.dropout(hidden_states) + return self.LayerNorm(hidden_states + input_tensor) + + +class _MessagePassing(nn.Module): + """Minimal MessagePassing base for MolGNet layers.""" + + def __init__(self, aggr: str = "add", flow: str = "source_to_target", node_dim: int = 0) -> None: + super().__init__() + self.aggr = aggr + self.flow = flow + self.node_dim = node_dim + + def propagate(self, edge_index: torch.Tensor, size=None, **kwargs) -> torch.Tensor: + i = 1 if self.flow == "source_to_target" else 0 + j = 0 if i == 1 else 1 + x = kwargs.get("x") + if x is None: + raise ValueError("propagate requires 'x'") + x_i = x[edge_index[i]] + x_j = x[edge_index[j]] + msg = self.message(edge_index_i=edge_index[i], edge_index_j=edge_index[j], x_i=x_i, x_j=x_j, **kwargs) + dim_size = x.size(0) if hasattr(x, "size") else len(x) + out = self.aggregate(msg, index=edge_index[i], dim_size=dim_size) + return self.update(out) + + def message(self, *args: Any, **kwargs: Any) -> torch.Tensor: + x_j = kwargs.get("x_j") + if x_j is None: + raise ValueError("message requires 'x_j'") + return x_j + + def aggregate(self, inputs: torch.Tensor, index: torch.Tensor, dim_size: int | None = None) -> torch.Tensor: + return scatter(inputs, index, dim=0, dim_size=dim_size, reduce=self.aggr) + + def update(self, inputs: torch.Tensor) -> torch.Tensor: + return inputs + + +class GraphAttentionConv(_MessagePassing): + def __init__(self, hidden: int, heads: int = 3, dropout: float = 0.0) -> None: + super().__init__() + self.hidden = hidden + self.heads = heads + self.query = nn.Linear(hidden, heads * int(hidden / heads)) + self.key = nn.Linear(hidden, heads * int(hidden / heads)) + self.value = nn.Linear(hidden, heads * int(hidden / heads)) + self.attn_drop = nn.Dropout(dropout) + + def forward(self, x, edge_index, edge_attr, size=None) -> torch.Tensor: + pseudo = edge_attr.unsqueeze(-1) if edge_attr.dim() == 1 else edge_attr + return self.propagate(edge_index=edge_index, x=x, pseudo=pseudo) + + def message(self, edge_index_i, x_i, x_j, pseudo, size_i=None, **kwargs) -> torch.Tensor: + head_dim = int(self.hidden / self.heads) + query = self.query(x_i).view(-1, self.heads, head_dim) + key = self.key(x_j + pseudo).view(-1, self.heads, head_dim) + value = self.value(x_j + pseudo).view(-1, self.heads, head_dim) + alpha = (query * key).sum(dim=-1) / math.sqrt(head_dim) + alpha = softmax(src=alpha, index=edge_index_i, num_nodes=size_i) + alpha = self.attn_drop(alpha.view(-1, self.heads, 1)) + return alpha * value + + def update(self, aggr_out: torch.Tensor) -> torch.Tensor: + return aggr_out.view(-1, self.heads * int(self.hidden / self.heads)) + + +class GTLayer(nn.Module): + def __init__(self, hidden: int, heads: int, dropout: float, num_message_passing: int) -> None: + super().__init__() + self.attention = GraphAttentionConv(hidden, heads, dropout) + self.att_out = AttentionOut(hidden, dropout) + self.intermediate = Intermediate(hidden) + self.output = GTOut(hidden, dropout) + self.gru = nn.GRU(hidden, hidden) + self.LayerNorm = BertLayerNorm(hidden, eps=1e-12) + self.time_step = num_message_passing + + def forward(self, x, edge_index, edge_attr) -> torch.Tensor: + h = x.unsqueeze(0) + for _ in range(self.time_step): + attention_output = self.attention.forward(x, edge_index, edge_attr) + attention_output = self.att_out.forward(attention_output, x) + intermediate_output = self.intermediate.forward(attention_output) + m = self.output.forward(intermediate_output, attention_output) + x, h = self.gru(m.unsqueeze(0), h) + x = self.LayerNorm.forward(x.squeeze(0)) + return x + + +class MolGNet(torch.nn.Module): + """MolGNet model for node embeddings.""" + + def __init__(self, num_layer: int, emb_dim: int, heads: int, num_message_passing: int, drop_ratio: float = 0): + super().__init__() + self.num_layer = num_layer + self.drop_ratio = drop_ratio + self.x_embedding = torch.nn.Embedding(178, emb_dim) + self.x_seg_embed = torch.nn.Embedding(3, emb_dim) + self.edge_embedding = torch.nn.Embedding(18, emb_dim) + self.edge_seg_embed = torch.nn.Embedding(3, emb_dim) + self.reset_parameters() + self.gnns = torch.nn.ModuleList( + [GTLayer(emb_dim, heads, drop_ratio, num_message_passing) for _ in range(num_layer)] + ) + + def reset_parameters(self) -> None: + torch.nn.init.xavier_uniform_(self.x_embedding.weight.data) + torch.nn.init.xavier_uniform_(self.x_seg_embed.weight.data) + torch.nn.init.xavier_uniform_(self.edge_embedding.weight.data) + torch.nn.init.xavier_uniform_(self.edge_seg_embed.weight.data) + + def forward(self, *argv: Any) -> torch.Tensor: + if len(argv) == 5: + x, edge_index, edge_attr, node_seg, edge_seg = argv + elif len(argv) == 1: + data = argv[0] + x, edge_index, edge_attr, node_seg, edge_seg = ( + data.x, + data.edge_index, + data.edge_attr, + data.node_seg, + data.edge_seg, + ) + else: + raise ValueError("unmatched number of arguments.") + x = self.x_embedding(x).sum(1) + self.x_seg_embed(node_seg) + edge_attr = self.edge_embedding(edge_attr).sum(1) + self.edge_seg_embed(edge_seg) + for gnn in self.gnns: + x = gnn(x, edge_index, edge_attr) + return x diff --git a/drevalpy/components/featurizers/drug/_smiles_utils.py b/drevalpy/components/featurizers/drug/_smiles_utils.py new file mode 100644 index 000000000..3b84359a7 --- /dev/null +++ b/drevalpy/components/featurizers/drug/_smiles_utils.py @@ -0,0 +1,29 @@ +"""Shared utilities for accessing SMILES strings from a FeatureSource.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.types.data.feature_source import FeatureSource + +if TYPE_CHECKING: + import pandas as pd + + +def get_smiles_for_entities(source: FeatureSource, entity_ids: np.ndarray) -> pd.Series | None: + """Get canonical SMILES indexed by entity_ids from the dataset. + + :param source: Feature source backed by a MuData object. + :param entity_ids: Drug identifiers to retrieve SMILES for. + :returns: Series of SMILES strings indexed by entity_ids, or None if unavailable. + """ + mdata = getattr(source, "mdata", None) + if mdata is None: + return None + response = mdata.mod["response"] + if "canonical_smiles" not in response.var.columns: + return None + smiles = response.var["canonical_smiles"] + return smiles.reindex(entity_ids) diff --git a/drevalpy/components/featurizers/drug/base.py b/drevalpy/components/featurizers/drug/base.py new file mode 100644 index 000000000..2cf52518f --- /dev/null +++ b/drevalpy/components/featurizers/drug/base.py @@ -0,0 +1,14 @@ +"""Base classes for drug featurizers.""" + +from __future__ import annotations + +from drevalpy.components.featurizers._dense_view import DenseViewFeaturizer +from drevalpy.components.featurizers.base import Featurizer + + +class DrugFeaturizer(Featurizer): + """Base for featurizers that read drug feature views.""" + + +class DenseViewDrugFeaturizer(DenseViewFeaturizer, DrugFeaturizer): + """Drug binding of the shared single-view dense featurizer base.""" diff --git a/drevalpy/components/featurizers/drug/bpe_pharmaformer.py b/drevalpy/components/featurizers/drug/bpe_pharmaformer.py new file mode 100644 index 000000000..794ce58db --- /dev/null +++ b/drevalpy/components/featurizers/drug/bpe_pharmaformer.py @@ -0,0 +1,201 @@ +"""BPE PharmaFormer drug featurizer with proper fit/transform separation.""" + +from __future__ import annotations + +import codecs +import os +import tempfile +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.drug._smiles_utils import get_smiles_for_entities +from drevalpy.components.featurizers.drug.base import DrugFeaturizer +from drevalpy.log import get_logger +from drevalpy.registry.drug_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec, FeatureBlock, numeric_feature_block +from drevalpy.types.data.feature_source import FeatureSource +from drevalpy.types.enums.literature_reference import LiteratureReference + +_logger = get_logger(__name__) + +_BPE_PHARMAFORMER_REFERENCE = LiteratureReference( + repo_url="https://github.com/zhouyuru1205/PharmaFormer", + citation_doi="10.1038/s41698-025-01082-6", + deviations=( + "Set-dependent featurizer: BPE codes are learned from training SMILES " + "at fit time and applied to any SMILES at transform time." + ), +) + + +@register( + "bpePharmaformer", + description="BPE PharmaFormer token rows computed via fit/transform (set-dependent).", + reference=_BPE_PHARMAFORMER_REFERENCE, + contract=FeatureFormat.NUMERIC_MATRIX, +) +class BpePharmaformerDrugFeaturizer(DrugFeaturizer): + """BPE PharmaFormer drug featurizer with proper fit/transform. + + Set-dependent: BPE codes are learned from the training SMILES during fit + and applied to encode any SMILES during transform. + """ + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("bpe_smiles", FeatureFormat.NUMERIC_MATRIX),) + storage_key: ClassVar[str] = "bpe_smiles" + input_views: ClassVar[tuple[str, ...]] = ("bpe_smiles",) + source_views: ClassVar[tuple[str, ...]] = ("canonical_smiles",) + precompute: ClassVar[bool] = False + + def __init__(self, *, view: str = "bpe_smiles", num_symbols: int = 10000, max_length: int = 128) -> None: + """Initialize instance state. + + :param view: view. + :param num_symbols: Number of BPE merge operations (vocabulary size). + :param max_length: Maximum encoded sequence length. + """ + self._view = view + self._num_symbols = int(num_symbols) + self._max_length = int(max_length) + self._output_dim = self._max_length + self._bpe = None + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable hyperparameter specs. + + :returns: HP space mapping. + """ + return { + "num_symbols": {"type": "categorical", "choices": [5000, 10000, 20000], "default": 10000}, + "max_length": {"type": "pow2", "low": 6, "high": 8, "default": 128}, + } + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> BpePharmaformerDrugFeaturizer: + """Learn BPE codes from training SMILES. + + :param source: Feature source providing drug views. + :param entity_ids: Training drug identifiers. + :param pair_expanded_ids: Unused training IDs with duplicates. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: Fitted featurizer instance. + """ + _ = pair_expanded_ids, pair_expanded_es_ids + ids = entity_ids if entity_ids is not None else source.identifiers + + smiles = get_smiles_for_entities(source, ids) + if smiles is None: + msg = "Cannot learn BPE codes: no SMILES available." + raise ValueError(msg) + + self._bpe = self._learn_bpe(smiles) + self._output_dim = self._max_length + return self + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Apply learned BPE codes to encode SMILES. + + :param source: Feature source providing drug views. + :param entity_ids: Drug identifiers to transform. + :returns: BPE token matrix of shape (n_drugs, max_length). + """ + if self._bpe is None: + msg = "BpePharmaformerDrugFeaturizer must be fit before transform" + raise RuntimeError(msg) + + smiles = get_smiles_for_entities(source, entity_ids) + if smiles is None: + msg = "Cannot encode BPE: no SMILES available." + raise ValueError(msg) + return self._apply_bpe(smiles, entity_ids).astype(np.float32) + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Transform blocks. + + :param source: Feature source providing drug views. + :param entity_ids: entity ids. + :returns: Mapping with one numeric block. + """ + return { + "bpe_smiles": numeric_feature_block( + self._transform(source, entity_ids), + feature_names=None, + ) + } + + @property + def output_dim(self) -> int: + """Return output feature dimension. + + :returns: Always 128 (max_length). + """ + return self._output_dim + + def _learn_bpe(self, smiles_series) -> object: + """Learn BPE codes from a set of SMILES strings. + + :param smiles_series: Series of SMILES indexed by entity IDs. + :returns: Fitted BPE object. + """ + try: + from subword_nmt.apply_bpe import BPE + from subword_nmt.learn_bpe import learn_bpe + except ImportError as err: + msg = "subword-nmt is required for BPE computation: pip install subword-nmt" + raise ImportError(msg) from err + + all_smiles = smiles_series.dropna() + + with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", delete=False, suffix=".txt") as tmp_file: + tmp_path = tmp_file.name + for smi in all_smiles: + tmp_file.write(f"{smi}\n") + + bpe_codes_file = tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", delete=False, suffix=".codes") + bpe_codes_path = bpe_codes_file.name + bpe_codes_file.close() + + from unittest.mock import patch + + try: + with codecs.open(tmp_path, encoding="utf-8") as f_in: + with codecs.open(bpe_codes_path, "w", encoding="utf-8") as f_out: + with patch("subword_nmt.learn_bpe.tqdm", side_effect=lambda it, *a, **kw: it): + learn_bpe(f_in, f_out, num_symbols=self._num_symbols, verbose=False) + finally: + os.unlink(tmp_path) + + with codecs.open(bpe_codes_path, encoding="utf-8") as f_in: + bpe = BPE(f_in) + + os.unlink(bpe_codes_path) + return bpe + + def _apply_bpe(self, smiles_series, entity_ids: np.ndarray) -> np.ndarray: + """Apply stored BPE codes to encode SMILES strings. + + :param smiles_series: Series of SMILES indexed by entity IDs. + :param entity_ids: Drug identifiers. + :returns: BPE token matrix of shape (n_drugs, max_length). + """ + results = np.zeros((len(entity_ids), self._max_length), dtype=np.int32) + for i, drug_id in enumerate(entity_ids): + smi = smiles_series.get(drug_id) + if smi and isinstance(smi, str): + bpe_processed = self._bpe.process_line(smi) + encoded = [ord(char) for char in bpe_processed] + if len(encoded) > self._max_length: + encoded = encoded[: self._max_length] + else: + encoded = list(np.pad(encoded, (0, self._max_length - len(encoded)), "constant")) + results[i] = encoded + return results.astype(np.float32) diff --git a/drevalpy/components/featurizers/drug/chemberta.py b/drevalpy/components/featurizers/drug/chemberta.py new file mode 100644 index 000000000..8a9a2dbae --- /dev/null +++ b/drevalpy/components/featurizers/drug/chemberta.py @@ -0,0 +1,154 @@ +"""ChemBERTa drug featurizer with on-the-fly computation fallback.""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.drug._smiles_utils import get_smiles_for_entities +from drevalpy.components.featurizers.drug.base import DenseViewDrugFeaturizer +from drevalpy.log import get_logger +from drevalpy.registry.drug_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.feature_source import FeatureSource + +_logger = get_logger(__name__) + +_CHEMBERTA_MODEL = "seyonec/ChemBERTa-zinc-base-v1" +_CHEMBERTA_REVISION = "761d6a1" + +# Weights are mirrored to the drevalpy artifacts bucket rather than pulled from the +# HuggingFace Hub: Hub downloads are rate-limited per source IP, which fails en masse +# when hundreds of pipeline workers behind one NAT gateway start with a cold cache. +_CHEMBERTA_ARTIFACT = "chemberta_zinc_base_v1_761d6a1" +_CHEMBERTA_ARTIFACT_FILES = ( + "config.json", + "merges.txt", + "pytorch_model.bin", + "special_tokens_map.json", + "tokenizer_config.json", + "vocab.json", +) + + +@lru_cache(maxsize=1) +def load_chemberta() -> tuple[Any, Any]: + """Return the cached ChemBERTa tokenizer and model. + + The weights are fetched once from the artifacts location and then reused for + the lifetime of the process, so repeated HPO trials do not reload them. + + :returns: Tuple of (tokenizer, model) with the model in eval mode. + :raises ImportError: If transformers or torch are unavailable. + :raises RuntimeError: If the weights cannot be fetched or loaded. + """ + try: + from transformers import AutoModel, AutoTokenizer + except ImportError as err: + msg = "transformers and torch are required for on-the-fly ChemBERTa computation: pip install transformers torch" + raise ImportError(msg) from err + + from drevalpy.data.artifacts import get_artifact_dir, get_artifacts_uri + + try: + model_dir = str(get_artifact_dir(_CHEMBERTA_ARTIFACT, _CHEMBERTA_ARTIFACT_FILES)) + tokenizer = AutoTokenizer.from_pretrained(model_dir, local_files_only=True) + model = AutoModel.from_pretrained(model_dir, local_files_only=True) + except (ImportError, RuntimeError): + raise + except Exception as err: + msg = ( + f"Could not load ChemBERTa weights ({_CHEMBERTA_MODEL} @ {_CHEMBERTA_REVISION}) " + f"from artifact {_CHEMBERTA_ARTIFACT!r} at {get_artifacts_uri()!r}: {err}. " + "Check credentials and connectivity for that location, or point " + "DREVALPY_ARTIFACTS_URI at a reachable mirror." + ) + raise RuntimeError(msg) from err + + model.eval() + return tokenizer, model + + +@register( + "chemberta", + description="ChemBERTa embeddings loaded from pre-computed view or computed on the fly via transformers.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class ChemBertaFeaturizer(DenseViewDrugFeaturizer): + """ChemBERTa drug featurizer with on-the-fly fallback.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("chemberta", FeatureFormat.NUMERIC_MATRIX),) + storage_key: ClassVar[str] = "chemberta" + input_views: ClassVar[tuple[str, ...]] = ("chemberta",) + source_views: ClassVar[tuple[str, ...]] = ("canonical_smiles",) + precompute: ClassVar[bool] = True + + def __init__(self, *, view: str = "chemberta", pooling: str = "mean", max_length: int = 256) -> None: + """Initialize instance state. + + :param view: view. + :param pooling: Token aggregation strategy ("mean", "cls", "max"). + :param max_length: Tokenizer truncation length. + """ + super().__init__(view=view) + self._pooling = pooling + self._max_length = int(max_length) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable hyperparameter specs. + + :returns: HP space mapping. + """ + return { + "pooling": {"type": "categorical", "choices": ["mean", "cls", "max"], "default": "mean"}, + "max_length": {"type": "categorical", "choices": [64, 128, 256], "default": 256}, + } + + def _compute_from_source(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Compute ChemBERTa embeddings from SMILES via pooled hidden states. + + :param source: Feature source. + :param entity_ids: Drug identifiers. + :returns: Embedding matrix of shape (n_drugs, hidden_dim). + :raises ValueError: If no SMILES are available. + :raises ImportError: If transformers or torch are not installed. + """ + smiles = get_smiles_for_entities(source, entity_ids) + if smiles is None: + msg = f"Cannot obtain {self.storage_key}: no SMILES available." + raise ValueError(msg) + + import torch + + tokenizer, model = load_chemberta() + + embeddings = [] + for drug_id in entity_ids: + smi = smiles.get(drug_id) + if smi and isinstance(smi, str): + inputs = tokenizer(smi, return_tensors="pt", truncation=True, max_length=self._max_length) + with torch.no_grad(): + outputs = model(**inputs) + hidden_states = outputs.last_hidden_state + embedding = self._pool(hidden_states) + else: + embedding = np.full(model.config.hidden_size, np.nan, dtype=np.float32) + embeddings.append(embedding) + + return np.vstack(embeddings).astype(np.float32) + + def _pool(self, hidden_states) -> np.ndarray: + """Apply pooling strategy to hidden states. + + :param hidden_states: Tensor of shape (1, seq_len, hidden_dim). + :returns: Pooled embedding vector. + """ + if self._pooling == "cls": + return hidden_states[:, 0, :].squeeze(0).numpy() + if self._pooling == "max": + return hidden_states.max(dim=1).values.squeeze(0).numpy() + return hidden_states.mean(dim=1).squeeze(0).numpy() diff --git a/drevalpy/components/featurizers/drug/drug_graph.py b/drevalpy/components/featurizers/drug/drug_graph.py new file mode 100644 index 000000000..c927804ee --- /dev/null +++ b/drevalpy/components/featurizers/drug/drug_graph.py @@ -0,0 +1,269 @@ +"""Molecular graph drug featurizer with on-the-fly fallback.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.drug._smiles_utils import get_smiles_for_entities +from drevalpy.components.featurizers.drug.base import DrugFeaturizer +from drevalpy.log import get_logger +from drevalpy.registry.drug_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec, FeatureBlock, graph_feature_block +from drevalpy.types.data.feature_source import FeatureSource + +_logger = get_logger(__name__) + + +@register( + "drugGraph", + description="PyG molecular graphs loaded from pre-computed view or computed on the fly via rdkit.", + contract=FeatureFormat.GRAPH, +) +class DrugGraphFeaturizer(DrugFeaturizer): + """Expose drug graphs for graph predictors, with on-the-fly fallback.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("drug_graph", FeatureFormat.GRAPH),) + input_views: ClassVar[tuple[str, ...]] = ("drug_graph",) + source_views: ClassVar[tuple[str, ...]] = ("canonical_smiles",) + precompute: ClassVar[bool] = True + + def __init__(self, *, view: str = "drug_graph", add_hydrogens: bool = False) -> None: + """Store the graph view name and initialize empty caches. + + :param view: Feature view name containing graph payloads. + :param add_hydrogens: Whether to add explicit hydrogen atoms to the graph. + """ + self._view = view + self._add_hydrogens = bool(add_hydrogens) + self._graphs: dict[str, object] = {} + self._output_dim = 0 + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable hyperparameter specs. + + :returns: HP space mapping. + """ + return { + "add_hydrogens": {"type": "categorical", "choices": [True, False], "default": False}, + } + + def _compute_from_source(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Compute drug graphs from SMILES for all requested entities. + + :param source: Feature source. + :param entity_ids: Drug identifiers. + :returns: Object array of graph payloads. + """ + graphs: list[object] = [] + for drug_id in entity_ids: + graph = self._compute_graph_from_smiles_for_entity(source, str(drug_id)) + if graph is not None: + graphs.append(graph) + else: + graphs.append(None) + payloads = np.empty(len(graphs), dtype=object) + payloads[:] = graphs + return payloads + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> DrugGraphFeaturizer: + """Cache graph payloads and infer node feature width from the first graph. + + :param source: Feature source providing drug graph views. + :param entity_ids: Drug identifiers to fit on; all entities when ``None``. + :param pair_expanded_ids: Unused training IDs with duplicates. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: Fitted featurizer instance. + """ + _ = pair_expanded_ids, pair_expanded_es_ids + ids = entity_ids if entity_ids is not None else source.identifiers + self._graphs = {} + has_fallback = False + for drug_id in ids: + graph = source.get_entity_view(str(drug_id), self._view) + if graph is not None: + self._graphs[str(drug_id)] = graph + else: + if not has_fallback: + _logger.warning("Computing %s on the fly. Consider ds.precompute().", "drug_graph") + has_fallback = True + computed = self._compute_graph_from_smiles_for_entity(source, str(drug_id)) + if computed is not None: + self._graphs[str(drug_id)] = computed + if self._graphs: + first = next(iter(self._graphs.values())) + self._output_dim = int(getattr(first, "num_node_features", 0)) + return self + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Return one graph payload per drug id. + + :param source: Feature source providing drug graph views. + :param entity_ids: Drug identifiers to transform. + :returns: Object array of graph payloads. + """ + graphs: list[object] = [] + for drug_id in entity_ids: + drug_key = str(drug_id) + if drug_key in self._graphs: + graphs.append(self._graphs[drug_key]) + continue + graph = source.get_entity_view(drug_key, self._view) + if graph is not None: + graphs.append(graph) + else: + computed = self._compute_graph_from_smiles_for_entity(source, drug_key) + if computed is not None: + graphs.append(computed) + else: + msg = f"View {self._view!r} missing for drug {drug_key!r} and SMILES-based graph computation failed" + raise KeyError(msg) + payloads = np.empty(len(graphs), dtype=object) + payloads[:] = graphs + return payloads + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Return a single ``drug_graph`` graph block. + + :param source: Feature source providing drug graph views. + :param entity_ids: Drug identifiers to transform. + :returns: Mapping with one graph block. + """ + return {"drug_graph": graph_feature_block(self._transform(source, entity_ids))} + + @property + def output_dim(self) -> int: + """Return node feature width inferred during ``fit``. + + :returns: Node feature dimensionality. + """ + return self._output_dim + + @property + def graph_by_drug(self) -> dict[str, object]: + """Return fitted graph payloads keyed by drug id. + + :returns: Cached graph object per drug id. + """ + return self._graphs + + def _compute_graph_from_smiles_for_entity(self, source: FeatureSource, drug_id: str) -> object | None: + """Compute a single graph from SMILES for a given drug. + + :param source: Feature source. + :param drug_id: Drug identifier. + :returns: torch_geometric Data or None. + """ + smiles_series = get_smiles_for_entities(source, np.array([drug_id])) + if smiles_series is None: + return None + smi = smiles_series.get(drug_id) + if not smi or not isinstance(smi, str): + return None + return _smiles_to_graph(smi, add_hydrogens=self._add_hydrogens) + + +def _smiles_to_graph(smiles: str, *, add_hydrogens: bool = False): + """Convert a SMILES string to a torch_geometric Data graph. + + :param smiles: SMILES string. + :param add_hydrogens: Whether to add explicit hydrogen atoms. + :returns: torch_geometric.data.Data or None if parsing fails. + """ + try: + from rdkit import Chem + except ImportError as err: + msg = "rdkit is required for on-the-fly drug graph computation: pip install rdkit" + raise ImportError(msg) from err + try: + import torch + from torch_geometric.data import Data + except ImportError as err: + msg = "torch and torch_geometric are required for on-the-fly drug graph computation" + raise ImportError(msg) from err + + mol = Chem.MolFromSmiles(smiles) + if mol is None: + return None + if add_hydrogens: + mol = Chem.AddHs(mol) + + atom_feature_defs = { + "atomic_num": list(range(1, 119)), + "degree": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "formal_charge": [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5], + "num_hs": [0, 1, 2, 3, 4, 5, 6, 7, 8], + "hybridization": [ + Chem.rdchem.HybridizationType.SP, + Chem.rdchem.HybridizationType.SP2, + Chem.rdchem.HybridizationType.SP3, + Chem.rdchem.HybridizationType.SP3D, + Chem.rdchem.HybridizationType.SP3D2, + ], + } + + bond_feature_defs = { + "bond_type": [ + Chem.rdchem.BondType.SINGLE, + Chem.rdchem.BondType.DOUBLE, + Chem.rdchem.BondType.TRIPLE, + Chem.rdchem.BondType.AROMATIC, + ] + } + + atom_features_list = [] + for atom in mol.GetAtoms(): + features = [] + features.extend(_one_hot_encode(atom.GetAtomicNum(), atom_feature_defs["atomic_num"])) + features.extend(_one_hot_encode(atom.GetDegree(), atom_feature_defs["degree"])) + features.extend(_one_hot_encode(atom.GetFormalCharge(), atom_feature_defs["formal_charge"])) + features.extend(_one_hot_encode(atom.GetTotalNumHs(), atom_feature_defs["num_hs"])) + features.extend(_one_hot_encode(atom.GetHybridization(), atom_feature_defs["hybridization"])) + features.append(atom.GetIsAromatic()) + features.append(atom.IsInRing()) + atom_features_list.append(features) + x = torch.tensor(atom_features_list, dtype=torch.float) + + edge_indices = [] + edge_features_list = [] + for bond in mol.GetBonds(): + i = bond.GetBeginAtomIdx() + j = bond.GetEndAtomIdx() + features = [] + features.extend(_one_hot_encode(bond.GetBondType(), bond_feature_defs["bond_type"])) + features.append(bond.GetIsConjugated()) + features.append(bond.IsInRing()) + edge_indices.extend([[i, j], [j, i]]) + edge_features_list.extend([features, features]) + + if edge_indices: + edge_index = torch.tensor(edge_indices, dtype=torch.long).t().contiguous() + edge_attr = torch.tensor(edge_features_list, dtype=torch.float) + else: + edge_index = torch.empty((2, 0), dtype=torch.long) + edge_attr = torch.empty((0, 6), dtype=torch.float) + + return Data(x=x, edge_index=edge_index, edge_attr=edge_attr) + + +def _one_hot_encode(value, choices: list) -> list[int]: + """One-hot encode a value given a list of choices, with an extra 'unknown' bin. + + :param value: Value to encode. + :param choices: Valid choices. + :returns: One-hot encoded list of length len(choices) + 1. + """ + encoding = [0] * (len(choices) + 1) + index = choices.index(value) if value in choices else -1 + encoding[index] = 1 + return encoding diff --git a/drevalpy/components/featurizers/drug/fingerprints.py b/drevalpy/components/featurizers/drug/fingerprints.py new file mode 100644 index 000000000..31e3da295 --- /dev/null +++ b/drevalpy/components/featurizers/drug/fingerprints.py @@ -0,0 +1,109 @@ +"""Morgan fingerprint drug featurizer.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.drug._smiles_utils import get_smiles_for_entities +from drevalpy.components.featurizers.drug.base import DenseViewDrugFeaturizer +from drevalpy.log import get_logger +from drevalpy.registry.drug_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.feature_source import FeatureSource + +_logger = get_logger(__name__) + + +@register( + "fingerprints", + description="Morgan fingerprints loaded from pre-computed view or computed on the fly via rdkit.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class FingerprintsFeaturizer(DenseViewDrugFeaturizer): + """Morgan fingerprints featurizer with on-the-fly fallback.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("fingerprints", FeatureFormat.NUMERIC_MATRIX),) + storage_key: ClassVar[str] = "morgan_fingerprint" + input_views: ClassVar[tuple[str, ...]] = ("morgan_fingerprint",) + source_views: ClassVar[tuple[str, ...]] = ("canonical_smiles",) + precompute: ClassVar[bool] = True + + def __init__( + self, + *, + view: str = "morgan_fingerprint", + radius: int = 2, + n_bits: int = 2048, + use_chirality: bool = False, + use_counts: bool = False, + ) -> None: + """Initialize instance state. + + :param view: view. + :param radius: Morgan fingerprint radius (neighborhood extent). + :param n_bits: Fingerprint bit length. + :param use_chirality: Whether to include stereochemistry information. + :param use_counts: Whether to use count-based (True) or binary (False) fingerprints. + """ + super().__init__(view=view) + self._radius = int(radius) + self._n_bits = int(n_bits) + self._use_chirality = bool(use_chirality) + self._use_counts = bool(use_counts) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable hyperparameter specs. + + :returns: HP space mapping. + """ + return { + "radius": {"type": "int", "low": 1, "high": 3, "default": 2}, + "n_bits": {"type": "pow2", "low": 9, "high": 11, "default": 2048}, + "use_chirality": {"type": "categorical", "choices": [True, False], "default": False}, + "use_counts": {"type": "categorical", "choices": [True, False], "default": False}, + } + + def _compute_from_source(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Compute Morgan fingerprints from SMILES. + + :param source: Feature source. + :param entity_ids: Drug identifiers. + :returns: Fingerprint matrix of shape (n_drugs, n_bits). + """ + smiles = get_smiles_for_entities(source, entity_ids) + if smiles is None: + msg = f"Cannot obtain {self.storage_key}: no SMILES available." + raise ValueError(msg) + + try: + from rdkit.Chem import rdFingerprintGenerator + except ImportError as err: + msg = "rdkit is required for on-the-fly fingerprint computation: pip install rdkit" + raise ImportError(msg) from err + + generator = rdFingerprintGenerator.GetMorganGenerator( + radius=self._radius, fpSize=self._n_bits, includeChirality=self._use_chirality + ) + results = np.zeros((len(entity_ids), self._n_bits), dtype=np.float32) + for i, drug_id in enumerate(entity_ids): + smi = smiles.get(drug_id) + results[i] = _fingerprint_for_smiles(smi, generator, self._n_bits, self._use_counts) + return results + + +def _fingerprint_for_smiles(smi, generator, n_bits: int, use_counts: bool) -> np.ndarray: + """Compute a fingerprint for one SMILES string.""" + from rdkit import Chem + + if not smi or not isinstance(smi, str): + return np.full(n_bits, np.nan, dtype=np.float32) + mol = Chem.MolFromSmiles(smi) + if mol is None: + return np.full(n_bits, np.nan, dtype=np.float32) + if use_counts: + return generator.GetCountFingerprintAsNumPy(mol).astype(np.float32) + return generator.GetFingerprintAsNumPy(mol).astype(np.float32) diff --git a/drevalpy/components/featurizers/drug/molgnet.py b/drevalpy/components/featurizers/drug/molgnet.py new file mode 100644 index 000000000..56864ecaf --- /dev/null +++ b/drevalpy/components/featurizers/drug/molgnet.py @@ -0,0 +1,211 @@ +"""MolGNet drug featurizer for DIPK with on-the-fly computation fallback.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.drug._smiles_utils import get_smiles_for_entities +from drevalpy.components.featurizers.drug.base import DrugFeaturizer +from drevalpy.data.artifacts import get_artifact +from drevalpy.log import get_logger +from drevalpy.registry.drug_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec, FeatureBlock, ragged_feature_block +from drevalpy.types.data.feature_source import FeatureSource + +_logger = get_logger(__name__) + + +@register( + "molgnet", + description="MolGNet drug embeddings loaded from pre-computed view or computed on the fly.", + contract=FeatureFormat.RAGGED_SEQUENCE, +) +class MolGNetDrugFeaturizer(DrugFeaturizer): + """Expose variable-size MolGNet tensors with on-the-fly fallback.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("molgnet_features", FeatureFormat.RAGGED_SEQUENCE), + ) + storage_key: ClassVar[str] = "molgnet_features" + input_views: ClassVar[tuple[str, ...]] = ("molgnet_features",) + source_views: ClassVar[tuple[str, ...]] = ("canonical_smiles",) + precompute: ClassVar[bool] = True + + def __init__(self, *, view: str = "molgnet_features") -> None: + """Store the MolGNet view name and initialize empty caches. + + :param view: Feature view name containing MolGNet tensors. + """ + self._view = view + self._features_by_drug: dict[str, np.ndarray] = {} + self._output_dim = 0 + + def _compute_from_source(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Compute MolGNet embeddings from SMILES for all requested entities. + + :param source: Feature source. + :param entity_ids: Drug identifiers. + :returns: Object array of MolGNet embedding tensors. + """ + rows: list[np.ndarray] = [] + for drug_id in entity_ids: + computed = self._compute_single_embedding(source, str(drug_id)) + if computed is not None: + rows.append(computed) + else: + rows.append(np.empty((0, 768), dtype=np.float32)) + return np.array(rows, dtype=object) + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> MolGNetDrugFeaturizer: + """Cache MolGNet tensors and infer embedding width. + + :param source: Feature source providing MolGNet views. + :param entity_ids: Drug identifiers to fit on; all entities when ``None``. + :param pair_expanded_ids: Unused training IDs with duplicates. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: Fitted featurizer instance. + """ + _ = pair_expanded_ids, pair_expanded_es_ids + ids = entity_ids if entity_ids is not None else source.identifiers + self._features_by_drug = {} + has_fallback = False + for drug_id in ids: + entity_view = source.get_entity_view(str(drug_id), self._view) + if entity_view is not None: + self._features_by_drug[str(drug_id)] = np.asarray(entity_view) + else: + if not has_fallback: + _logger.warning("Computing %s on the fly. Consider ds.precompute().", self.storage_key) + has_fallback = True + computed = self._compute_single_embedding(source, str(drug_id)) + if computed is not None: + self._features_by_drug[str(drug_id)] = computed + + if self._features_by_drug: + first = next(iter(self._features_by_drug.values())) + self._output_dim = int(first.shape[1]) if first.ndim == 2 else int(first.size) + return self + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Return one MolGNet tensor per drug id. + + :param source: Feature source providing MolGNet views. + :param entity_ids: Drug identifiers to transform. + :returns: Object array of MolGNet embedding tensors. + """ + rows: list[np.ndarray] = [] + for drug_id in entity_ids: + drug_key = str(drug_id) + if drug_key in self._features_by_drug: + rows.append(self._features_by_drug[drug_key]) + continue + entity_view = source.get_entity_view(drug_key, self._view) + if entity_view is not None: + rows.append(np.asarray(entity_view)) + else: + computed = self._compute_single_embedding(source, drug_key) + if computed is not None: + rows.append(computed) + else: + msg = f"View {self._view!r} missing for drug {drug_key!r} and on-the-fly computation failed" + raise KeyError(msg) + return np.array(rows, dtype=object) + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Return a single ``molgnet_features`` ragged block. + + :param source: Feature source providing MolGNet views. + :param entity_ids: Drug identifiers to transform. + :returns: Mapping with one ragged block. + """ + return {"molgnet_features": ragged_feature_block(self._transform(source, entity_ids))} + + @property + def output_dim(self) -> int: + """Return embedding width inferred during ``fit``. + + :returns: MolGNet embedding dimensionality. + """ + return self._output_dim + + def _compute_single_embedding(self, source: FeatureSource, drug_id: str) -> np.ndarray | None: + """Compute MolGNet embedding for a single drug from SMILES. + + Auto-downloads the MolGNet checkpoint on first use. + + :param source: Feature source. + :param drug_id: Drug identifier. + :returns: Embedding array or None. + """ + smiles_series = get_smiles_for_entities(source, np.array([drug_id])) + if smiles_series is None: + return None + smi = smiles_series.get(drug_id) + if not smi or not isinstance(smi, str): + return None + return _compute_molgnet_embedding(smi) + + +def _get_molgnet_checkpoint() -> str: + """Return the local path to the MolGNet checkpoint, downloading if needed.""" + return str(get_artifact("MolGNet.pt")) + + +def _compute_molgnet_embedding(smiles: str) -> np.ndarray | None: + """Compute MolGNet node embedding for a SMILES using the pre-trained checkpoint. + + :param smiles: SMILES string. + :returns: Numpy array of shape (n_atoms, 768) or None. + """ + try: + import torch + except ImportError as err: + msg = "torch and torch_geometric are required for on-the-fly MolGNet computation" + raise ImportError(msg) from err + try: + from rdkit import Chem + except ImportError as err: + msg = "rdkit is required for on-the-fly MolGNet computation: pip install rdkit" + raise ImportError(msg) from err + + mol = Chem.MolFromSmiles(smiles) + if mol is None: + return None + + checkpoint_path = _get_molgnet_checkpoint() + + from drevalpy.components.featurizers.drug._molgnet_network import ( + AddSegId, + MolGNet, + SelfLoop, + mol_to_graph_data_obj_complex, + ) + + graph = mol_to_graph_data_obj_complex(mol) + self_loop = SelfLoop() + add_seg = AddSegId() + prepared = add_seg(self_loop(graph)) + + device = torch.device("cpu") + model = MolGNet(num_layer=5, emb_dim=768, heads=12, num_message_passing=3, drop_ratio=0.0) + ckpt = torch.load(checkpoint_path, map_location=device, weights_only=True) + if isinstance(ckpt, dict) and "state_dict" in ckpt: + model.load_state_dict(ckpt["state_dict"]) + else: + model.load_state_dict(ckpt) + model.to(device) + model.eval() + + with torch.no_grad(): + emb = model(prepared.to(device)) + return emb.cpu().numpy() diff --git a/drevalpy/components/featurizers/drug/smilesvec.py b/drevalpy/components/featurizers/drug/smilesvec.py new file mode 100644 index 000000000..0bf1b4a3a --- /dev/null +++ b/drevalpy/components/featurizers/drug/smilesvec.py @@ -0,0 +1,107 @@ +"""SMILESVec drug featurizer with auto-download of pre-trained Word2Vec model.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.drug._smiles_utils import get_smiles_for_entities +from drevalpy.components.featurizers.drug.base import DenseViewDrugFeaturizer +from drevalpy.data.artifacts import get_artifact +from drevalpy.log import get_logger +from drevalpy.registry.drug_featurizer import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.feature_source import FeatureSource + +_logger = get_logger(__name__) + +_SMILESVEC_MODEL_FILE = "drug.pubchem.canon.l8.ws20.txt" + + +@register( + "smilesvec", + description="SMILESVec drug embeddings computed on the fly via a pre-trained Word2Vec model.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class SmilesVecDrugFeaturizer(DenseViewDrugFeaturizer): + """SMILESVec featurizer using Word2Vec k-mer embeddings from PubChem corpus.""" + + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("smilesvec", FeatureFormat.NUMERIC_MATRIX),) + storage_key: ClassVar[str] = "smilesvec" + input_views: ClassVar[tuple[str, ...]] = ("smilesvec",) + source_views: ClassVar[tuple[str, ...]] = ("canonical_smiles",) + precompute: ClassVar[bool] = True + + def __init__(self, *, view: str = "smilesvec", k: int = 8) -> None: + """Initialize instance state. + + :param view: view. + :param k: Length of SMILES subsequences (chemical words). + """ + super().__init__(view=view) + self._k = int(k) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable hyperparameter specs. + + :returns: HP space mapping. + """ + return { + "k": {"type": "categorical", "choices": [4, 6, 8, 10, 12], "default": 8}, + } + + def _compute_from_source(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Compute SMILESVec embeddings from SMILES using a pre-trained k-mer Word2Vec model. + + Auto-downloads the model from the artifacts bucket on first use. + + :param source: Feature source. + :param entity_ids: Drug identifiers. + :returns: Embedding matrix of shape (n_drugs, dim). + """ + smiles = get_smiles_for_entities(source, entity_ids) + if smiles is None: + msg = f"Cannot obtain {self.storage_key}: no SMILES available." + raise ValueError(msg) + + try: + from gensim.models import KeyedVectors + except ImportError as err: + msg = "gensim is required for SMILESVec computation: pip install gensim" + raise ImportError(msg) from err + + model_path = get_artifact(_SMILESVEC_MODEL_FILE) + kv = KeyedVectors.load_word2vec_format(str(model_path), binary=False) + k = self._k + dim = kv.vector_size + + results = np.zeros((len(entity_ids), dim), dtype=np.float32) + for i, drug_id in enumerate(entity_ids): + smi = smiles.get(drug_id) + if smi and isinstance(smi, str): + results[i] = _smilesvec_embed(smi, kv, k=k, dim=dim) + else: + results[i] = np.nan + return results + + +def _smilesvec_embed(smiles: str, kv, *, k: int = 8, dim: int = 100) -> np.ndarray: + """Compute a single SMILESVec embedding using k-mer averaging. + + :param smiles: SMILES string. + :param kv: Gensim KeyedVectors model. + :param k: Length of substrings (chemical words). + :param dim: Embedding dimensionality. + :returns: Embedding vector. + """ + if len(smiles) < k: + words = [smiles] + else: + words = [smiles[i : i + k] for i in range(len(smiles) - k + 1)] + vecs = [kv[w] for w in words if w in kv.key_to_index] + if not vecs: + return np.zeros(dim, dtype=np.float32) + return np.mean(vecs, axis=0).astype(np.float32) diff --git a/drevalpy/components/featurizers/drug/view.py b/drevalpy/components/featurizers/drug/view.py new file mode 100644 index 000000000..bc2c7df7d --- /dev/null +++ b/drevalpy/components/featurizers/drug/view.py @@ -0,0 +1,20 @@ +"""Single-view drug featurizer.""" + +from __future__ import annotations + +from typing import ClassVar + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.drug.base import DenseViewDrugFeaturizer +from drevalpy.registry.drug_featurizer import register + + +@register( + "view", + description="Pass through one dense drug view from a FeatureSource.", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class ViewDrugFeaturizer(DenseViewDrugFeaturizer): + """Featurize one drug view without additional transformation.""" + + input_views: ClassVar[tuple[str, ...]] = ("morgan_fingerprint",) diff --git a/drevalpy/components/featurizers/shared/__init__.py b/drevalpy/components/featurizers/shared/__init__.py new file mode 100644 index 000000000..cdbb589e9 --- /dev/null +++ b/drevalpy/components/featurizers/shared/__init__.py @@ -0,0 +1,7 @@ +"""Featurizers whose implementation is identical on both entity sides. + +Each module here declares one implementation and binds it to the cell-line and +drug registries with ``register_for_sides``; the generated per-side subclasses are +injected into the defining module's namespace. ``register_native_components`` in +``drevalpy/registry/_builtins.py`` scans this directory once, not once per side. +""" diff --git a/drevalpy/components/featurizers/shared/concat.py b/drevalpy/components/featurizers/shared/concat.py new file mode 100644 index 000000000..299751a66 --- /dev/null +++ b/drevalpy/components/featurizers/shared/concat.py @@ -0,0 +1,36 @@ +"""Concatenating featurizer, shared by both entity sides.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers._concat import ConcatFeaturizersMixin +from drevalpy.components.featurizers._side_binding import register_for_sides +from drevalpy.components.featurizers.base import Featurizer + + +@register_for_sides( + "concatFeaturizers", + description={ + "cell_line": "Concatenate dense outputs from multiple cell-line featurizers.", + "drug": "Concatenate dense outputs from multiple drug featurizers.", + }, + contract=FeatureFormat.NUMERIC_MATRIX, +) +class SharedConcatFeaturizer(ConcatFeaturizersMixin, Featurizer): + """Fit child featurizers independently and concatenate their dense outputs.""" + + def __init__( + self, + *, + featurizers: list[Any] | None = None, + ) -> None: + """Initialize instance state. + + Children are resolved against the registry of this binding's own side, which + registration stamped onto the class. + + :param featurizers: featurizers. + """ + self._init_concat(featurizers=featurizers, registry=self.side) diff --git a/drevalpy/components/featurizers/shared/constant.py b/drevalpy/components/featurizers/shared/constant.py new file mode 100644 index 000000000..932c2011f --- /dev/null +++ b/drevalpy/components/featurizers/shared/constant.py @@ -0,0 +1,20 @@ +"""Constant (one-category / intercept) featurizer, shared by both entity sides.""" + +from __future__ import annotations + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers._constant import ConstantFeaturizerMixin +from drevalpy.components.featurizers._side_binding import register_for_sides +from drevalpy.components.featurizers.base import Featurizer + + +@register_for_sides( + "constant", + description={ + "cell_line": "Constant one-column intercept features with no cell-line identity.", + "drug": "Constant one-column intercept features with no drug identity.", + }, + contract=FeatureFormat.NUMERIC_MATRIX, +) +class SharedConstantFeaturizer(ConstantFeaturizerMixin, Featurizer): + """Emit ones for every entity, on either side.""" diff --git a/drevalpy/components/featurizers/shared/identity.py b/drevalpy/components/featurizers/shared/identity.py new file mode 100644 index 000000000..f8fe963cb --- /dev/null +++ b/drevalpy/components/featurizers/shared/identity.py @@ -0,0 +1,109 @@ +"""One-hot identity featurizer, shared by both entity sides.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers._one_hot import OneHotCategoryEncoder +from drevalpy.components.featurizers._side_binding import register_for_sides +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.types.data.batch.feature_block import ( + BlockSpec, + FeatureBlock, + metadata_feature_block, + numeric_feature_block, +) +from drevalpy.types.data.feature_source import FeatureSource + + +@register_for_sides( + "identity", + description={ + "cell_line": "One-hot encoding of cell-line entity identifiers.", + "drug": "One-hot encoding of drug entity identifiers.", + }, + contract=FeatureFormat.NUMERIC_MATRIX, +) +class SharedIdentityFeaturizer(Featurizer): + """Encode entity IDs as dense one-hot vectors.""" + + entity_id_only: ClassVar[bool] = True + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("identity", FeatureFormat.NUMERIC_MATRIX),) + + def __init__(self) -> None: + """Initialize instance state.""" + self._encoder = OneHotCategoryEncoder() + + def _fit( + self, + source: FeatureSource, + *, + entity_ids: np.ndarray | None = None, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> SharedIdentityFeaturizer: + """Learn the category order from the training entity IDs. + + :param source: Feature source; only consulted for its identifiers. + :param entity_ids: entity ids. + :param pair_expanded_ids: Unused training IDs with duplicates. + :param pair_expanded_es_ids: Unused early-stopping IDs. + :returns: Result. + """ + _ = pair_expanded_ids, pair_expanded_es_ids + ids = entity_ids if entity_ids is not None else source.identifiers + self._encoder.fit_categories(ids) + return self + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Transform inputs into feature payloads. + + :param source: Feature source (unused). + :param entity_ids: entity ids. + :returns: Result. + """ + _ = source + return self._encoder.transform(entity_ids) + + def _transform_blocks( + self, + source: FeatureSource, + entity_ids: np.ndarray, + ) -> dict[str, FeatureBlock]: + """Transform blocks. + + :param source: Feature source (unused). + :param entity_ids: entity ids. + :returns: Result. + """ + return { + "identity": numeric_feature_block(self._transform(source, entity_ids)), + "identity_categories": metadata_feature_block( + np.asarray(self._encoder.categories, dtype=str), + ), + } + + @property + def output_dim(self) -> int: + """Return output feature dimension after fitting. + + :returns: Result. + """ + return self._encoder.output_dim + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + return self._encoder.get_state() + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + self._encoder.set_state(state) diff --git a/drevalpy/components/featurizers/storage.py b/drevalpy/components/featurizers/storage.py new file mode 100644 index 000000000..26964d407 --- /dev/null +++ b/drevalpy/components/featurizers/storage.py @@ -0,0 +1,292 @@ +"""Featurizer variant storage: the MuData helpers and the mixin built on them.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +if TYPE_CHECKING: + from drevalpy.types.data.feature_source import FeatureSource + +VARIANTS_UNS_KEY_CELL_LINE = "cell_line_featurizer_variants" +VARIANTS_UNS_KEY_DRUG = "drug_featurizer_variants" + + +def _variants_key_for_side(side: str) -> str: + """Return the uns key for the given side.""" + if side == "drug": + return VARIANTS_UNS_KEY_DRUG + return VARIANTS_UNS_KEY_CELL_LINE + + +def _variant_registry(mdata, side: str) -> dict[str, dict[str, dict[str, Any]]]: + """Read the featurizer variant registry from mdata.uns. + + Format: {storage_key: {mudata_key: params_dict, ...}, ...} + """ + uns_key = _variants_key_for_side(side) + raw = mdata.uns.get(uns_key) + if raw is None: + return {} + if isinstance(raw, str): + return json.loads(raw) + return dict(raw) + + +def _write_variant_registry(mdata, registry: dict[str, dict[str, dict[str, Any]]], side: str) -> None: + """Write the featurizer variant registry to mdata.uns.""" + uns_key = _variants_key_for_side(side) + mdata.uns[uns_key] = json.dumps(registry) + + +def find_variant_key( + mdata, + storage_key: str, + hyperparameters: dict[str, Any] | None = None, + *, + side: str = "cell_line", +) -> str | None: + """Find the MuData storage key for a featurizer variant matching the given HPs. + + :param mdata: MuData object. + :param storage_key: Base storage key for the featurizer. + :param hyperparameters: HP dict to match against stored variants. + :param side: Entity side ("cell_line" or "drug"). + :returns: The actual MuData key, or None if not found. + """ + registry = _variant_registry(mdata, side) + variants = registry.get(storage_key, {}) + + target_params = hyperparameters or {} + for key, params in variants.items(): + if params == target_params: + return key + return None + + +def list_variants(mdata, storage_key: str, *, side: str = "cell_line") -> dict[str, dict[str, Any]]: + """List all stored HP variants for a featurizer. + + :param mdata: MuData object. + :param storage_key: Base storage key for the featurizer. + :param side: Entity side ("cell_line" or "drug"). + :returns: Dict of {mudata_key: params_dict}. + """ + registry = _variant_registry(mdata, side) + return registry.get(storage_key, {}) + + +def register_variant( + mdata, + storage_key: str, + actual_key: str, + hyperparameters: dict[str, Any] | None = None, + *, + side: str = "cell_line", +) -> None: + """Register a new variant in the featurizer variant registry. + + :param mdata: MuData object. + :param storage_key: Base storage key for the featurizer. + :param actual_key: The actual MuData key where data is stored. + :param hyperparameters: HP settings for this variant. + :param side: Entity side ("cell_line" or "drug"). + """ + registry = _variant_registry(mdata, side) + variants = registry.setdefault(storage_key, {}) + variants[actual_key] = hyperparameters or {} + _write_variant_registry(mdata, registry, side) + + +def make_variant_key(storage_key: str, index: int) -> str: + """Generate the indexed storage key for a variant. + + :param storage_key: Base featurizer storage key. + :param index: Variant index. + :returns: Key like "pca_0". + """ + safe_key = storage_key.replace("[", "_").replace("]", "").replace(":", "_") + return f"{safe_key}_{index}" + + +def next_variant_index(mdata, storage_key: str, *, side: str = "cell_line") -> int: + """Return the next available variant index.""" + variants = list_variants(mdata, storage_key, side=side) + return len(variants) + + +def fetch_from_modality(mdata, modality: str, entity_ids: np.ndarray) -> np.ndarray | None: + """Fetch a matrix from a MuData modality, aligned to entity_ids. + + :returns: Float array or None if modality doesn't exist. + """ + import pandas as pd + + if modality not in mdata.mod: + return None + mod = mdata.mod[modality] + + idx = pd.Index(mod.obs_names) + positions = idx.get_indexer(entity_ids) + found = positions >= 0 + if not found.all(): + result = np.full((len(entity_ids), mod.X.shape[1]), np.nan, dtype=np.float32) + result[found] = np.asarray(mod.X[positions[found]], dtype=np.float32) + return result + return np.asarray(mod.X[positions], dtype=np.float32) + + +def fetch_from_varm(mdata, key: str, entity_ids: np.ndarray) -> np.ndarray | None: + """Fetch a matrix from response.varm, aligned to entity_ids. + + :returns: Float array or None if key doesn't exist. + """ + import pandas as pd + + response = mdata.mod.get("response") + if response is None or response.varm is None or key not in response.varm: + return None + + varm_data = np.asarray(response.varm[key]) + idx = pd.Index(response.var_names) + positions = idx.get_indexer(entity_ids) + found = positions >= 0 + if not found.all(): + result = np.full((len(entity_ids), varm_data.shape[1]), np.nan, dtype=np.float32) + result[found] = varm_data[positions[found]].astype(np.float32) + return result + return varm_data[positions].astype(np.float32) + + +def fetch_from_obsm(mdata, key: str, entity_ids: np.ndarray) -> np.ndarray | None: + """Fetch a matrix from response.obsm, aligned to entity_ids. + + :returns: Float array or None if key doesn't exist. + """ + import pandas as pd + + response = mdata.mod.get("response") + if response is None or response.obsm is None or key not in response.obsm: + return None + + obsm_data = np.asarray(response.obsm[key]) + idx = pd.Index(response.obs_names) + positions = idx.get_indexer(entity_ids) + found = positions >= 0 + if not found.all(): + result = np.full((len(entity_ids), obsm_data.shape[1]), np.nan, dtype=np.float32) + result[found] = obsm_data[positions[found]].astype(np.float32) + return result + return obsm_data[positions].astype(np.float32) + + +class FeaturizerStorageMixin: + """Read and write pre-computed featurizer matrices in a MuData store. + + Extracted from ``Featurizer``, which it is mixed back into: these five methods + were the one cohesive cluster on that class, and they reach nothing beyond the + two class attributes declared below. Keeping them here means a featurizer that + only ever computes on the fly still gets the storage protocol, without the + protocol being tangled into the fit/transform contract. + """ + + #: Base key the variant registry files this featurizer's matrices under. + #: Registration defaults it to the registry name. + storage_key: ClassVar[str] = "" + #: Entity side, stamped on by the registry the featurizer is registered in. + side: ClassVar[str] = "" + + def fetch( + self, mdata: Any, entity_ids: np.ndarray, hyperparameters: dict[str, Any] | None = None + ) -> np.ndarray | None: + """Fetch pre-computed representations from MuData for the given HPs. + + :param mdata: MuData object. + :param entity_ids: Entity IDs to fetch for. + :param hyperparameters: HP setting to match. None matches default (empty params). + :returns: Feature matrix or None if not pre-computed for these HPs. + """ + key = find_variant_key(mdata, self.storage_key, hyperparameters, side=self.side) + if key is None: + return None + return self._fetch_by_key(mdata, key, entity_ids) + + def fetch_precomputed( + self, + source: FeatureSource, + entity_ids: np.ndarray, + hyperparameters: dict[str, Any] | None = None, + ) -> np.ndarray | None: + """Fetch a pre-computed matrix through a feature source, if it carries one. + + Every featurizer that can be pre-computed by ``Dataset.precompute()`` opens + ``_fit`` and ``_transform`` by asking whether the work is already done. Only + dataset-backed sources expose ``mdata``, so the question has to tolerate a + source without one. + + :param source: Feature source, which may or may not be MuData-backed. + :param entity_ids: Entity IDs to align the stored matrix to. + :param hyperparameters: HP setting to match; ``None`` matches the default variant. + :returns: Stored feature matrix, or ``None`` when nothing matches. + """ + mdata = getattr(source, "mdata", None) + if mdata is None: + return None + return self.fetch(mdata, entity_ids, hyperparameters) + + def _fetch_by_key(self, mdata: Any, key: str, entity_ids: np.ndarray) -> np.ndarray | None: + """Fetch data from MuData by resolved key. Override for custom storage. + + :param mdata: MuData object. + :param key: Resolved storage key (e.g., "pca_expression_0"). + :param entity_ids: Entity IDs to align to. + :returns: Feature matrix or None. + """ + result = fetch_from_modality(mdata, key, entity_ids) + if result is not None: + return result + if self.side == "drug": + return fetch_from_varm(mdata, key, entity_ids) + return fetch_from_obsm(mdata, key, entity_ids) + + def store( + self, mdata: Any, entity_ids: np.ndarray, data: np.ndarray, hyperparameters: dict[str, Any] | None = None + ) -> None: + """Store computed representations into MuData with HP metadata. + + :param mdata: MuData object. + :param entity_ids: Entity IDs the data is aligned to. + :param data: Feature matrix to store. + :param hyperparameters: HP settings this data was computed with. + """ + index = next_variant_index(mdata, self.storage_key, side=self.side) + actual_key = make_variant_key(self.storage_key, index) + self._store_by_key(mdata, actual_key, entity_ids, data) + register_variant(mdata, self.storage_key, actual_key, hyperparameters, side=self.side) + + def _store_by_key(self, mdata: Any, key: str, entity_ids: np.ndarray, data: np.ndarray) -> None: + """Write data to MuData under the given key. Override for custom storage. + + Default stores in response.obsm for cell-line-side, varm for drug-side. + + :param mdata: MuData object. + :param key: Storage key. + :param entity_ids: Entity IDs. + :param data: Data matrix. + """ + response = mdata.mod["response"] + if self.side == "drug": + response.varm[key] = data + else: + response.obsm[key] = data + + @classmethod + def list_stored_variants(cls, mdata: Any) -> dict[str, dict[str, Any]]: + """Return available pre-computed HP settings for this featurizer. + + :param mdata: MuData object. + :returns: Dict of {mudata_key: params_dict}. + """ + return list_variants(mdata, cls.storage_key, side=cls.side) diff --git a/drevalpy/components/predictors/__init__.py b/drevalpy/components/predictors/__init__.py new file mode 100644 index 000000000..60ca85fd6 --- /dev/null +++ b/drevalpy/components/predictors/__init__.py @@ -0,0 +1,5 @@ +"""Predictors for drug response.""" + +from .abstract.base import Predictor + +__all__ = ["Predictor"] diff --git a/drevalpy/components/predictors/_boosted_trees.py b/drevalpy/components/predictors/_boosted_trees.py new file mode 100644 index 000000000..57bb619e5 --- /dev/null +++ b/drevalpy/components/predictors/_boosted_trees.py @@ -0,0 +1,89 @@ +"""Shared base for the two third-party gradient-boosting predictors. + +``lightgbm_pred.py`` and ``xgboost_pred.py`` wrap different libraries but read the +same tree/shrinkage/subsampling knobs out of ``self._h`` with the same coercions, +and tune overlapping slices of one search space. That common part lives here so a +change to a default or a bound is made once. + +The module is ``_``-prefixed on purpose: ``registry/_builtins.py`` registers +predictors by scanning the directory, and a public module here would be imported +as a component. It also imports nothing outside ``drevalpy``, so the boosting +libraries stay off the ``import drevalpy`` path (see +``tests/test_import_cost_policy.py``) - only the concrete subclasses reach for +them, inside ``_make_estimator``. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from drevalpy.components.predictors.sklearn_tabular import SklearnTabularPredictor + +#: Constructor arguments both libraries accept, with the defaults they agree on. +#: The type of each value also fixes the coercion applied to an override, so a +#: hyperparameter arriving as a string from a config file still reaches the +#: estimator as a number. +SHARED_DEFAULTS: dict[str, Any] = { + "n_estimators": 100, + "max_depth": 6, + "learning_rate": 0.1, + "subsample": 1.0, + "colsample_bytree": 1.0, + "reg_alpha": 0.0, + "random_state": 42, +} + +#: Search-space specs for every knob either predictor tunes. A subclass selects +#: the ones it exposes and adjusts individual fields; it never restates a spec. +#: ``learning_rate`` carries no ``log`` flag here because the two predictors +#: disagree on it - LightGBM samples it log-uniformly, XGBoost uniformly. +SHARED_SPACE: dict[str, dict[str, Any]] = { + "n_estimators": {"type": "int", "low": 50, "high": 300, "default": 100}, + "max_depth": {"type": "int", "low": 3, "high": 12, "default": 6}, + "learning_rate": {"type": "float", "low": 0.01, "high": 0.3, "default": 0.1}, + "num_leaves": {"type": "int", "low": 15, "high": 255, "default": 63}, + "subsample": {"type": "float", "low": 0.5, "high": 1.0, "default": 0.8}, + "colsample_bytree": {"type": "float", "low": 0.5, "high": 1.0, "default": 0.8}, + "reg_alpha": {"type": "float", "low": 0.0, "high": 10.0, "default": 0.0}, + "reg_lambda": {"type": "float", "low": 0.0, "high": 10.0, "default": 0.0}, +} + + +class BoostedTreesPredictor(SklearnTabularPredictor): + """Hyperparameter plumbing shared by the LightGBM and XGBoost regressors.""" + + #: Entries of :data:`SHARED_DEFAULTS` this library defaults differently. + boosting_default_overrides: ClassVar[dict[str, Any]] = {} + + #: Constructor arguments only this library accepts, with their defaults. + #: Resolved from ``self._h`` exactly like the shared ones. + boosting_extra_defaults: ClassVar[dict[str, Any]] = {} + + #: Names from :data:`SHARED_SPACE` this predictor tunes, in declaration order. + tuned_hyperparameters: ClassVar[tuple[str, ...]] = () + + #: Per-name adjustments to the shared spec, merged over it. + boosting_space_overrides: ClassVar[dict[str, dict[str, Any]]] = {} + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return the tunable space assembled from the shared specs. + + :returns: One spec per name in :attr:`tuned_hyperparameters`. + """ + return { + name: {**SHARED_SPACE[name], **cls.boosting_space_overrides.get(name, {})} + for name in cls.tuned_hyperparameters + } + + def _estimator_params(self) -> dict[str, Any]: + """Resolve every constructor argument this library takes from hyperparameters. + + :returns: Hyperparameter values coerced to the type of their default. + """ + defaults = { + **SHARED_DEFAULTS, + **self.boosting_default_overrides, + **self.boosting_extra_defaults, + } + return {name: type(default)(self._h.get(name, default)) for name, default in defaults.items()} diff --git a/drevalpy/components/predictors/_state_helpers.py b/drevalpy/components/predictors/_state_helpers.py new file mode 100644 index 000000000..ca927fb4f --- /dev/null +++ b/drevalpy/components/predictors/_state_helpers.py @@ -0,0 +1,35 @@ +"""Small helpers for restoring component state from serialized dicts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from numbers import Real +from typing import Any + + +def state_float(state: Mapping[str, object], key: str) -> float | None: + """Return *key* from *state* as float when present. + + :param state: state. + :param key: key. + :returns: Result. + """ + value = state.get(key) + if isinstance(value, Real): + return float(value) + if isinstance(value, str): + return float(value) + return None + + +def state_mapping(state: Mapping[str, object], key: str) -> dict[str, Any]: + """Return a mapping stored under *key*. + + :param state: state. + :param key: key. + :returns: Result. + """ + value = state.get(key) + if not isinstance(value, dict): + return {} + return dict(value) diff --git a/drevalpy/components/predictors/abstract/__init__.py b/drevalpy/components/predictors/abstract/__init__.py new file mode 100644 index 000000000..3297ad352 --- /dev/null +++ b/drevalpy/components/predictors/abstract/__init__.py @@ -0,0 +1,8 @@ +"""Abstract predictor base classes.""" + +from .base import Predictor +from .block import BlockPredictor +from .feature_free import FeatureFreePredictor +from .matrix import MatrixPredictor + +__all__ = ["BlockPredictor", "FeatureFreePredictor", "MatrixPredictor", "Predictor"] diff --git a/drevalpy/components/predictors/abstract/base.py b/drevalpy/components/predictors/abstract/base.py new file mode 100644 index 000000000..3ea96a55c --- /dev/null +++ b/drevalpy/components/predictors/abstract/base.py @@ -0,0 +1,173 @@ +"""Base class for predictors.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureContract, normalize_feature_contract +from drevalpy.components.contracts.hyperparameter_space import TunableComponentMixin +from drevalpy.log import get_logger +from drevalpy.types.enums.model_scope import ModelScope +from drevalpy.types.enums.prediction_mode import PredictionMode + +if TYPE_CHECKING: + from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + +_logger = get_logger(__name__) + + +class Predictor(TunableComponentMixin, ABC): + """Train and predict drug response from a ``ModelInputBatch``. + + Predictors take featurizer outputs and predict a response for each drug/cell-line pair in the batch. + Subclasses must be registered to the predictor registry using ``@register``, + so that they can be discovered and used in models. + + ``cell_line_contract`` and ``drug_contract`` may be declared on the class body or + passed to ``@register``. When both are given the decorator argument wins. + + The HPO-space and checkpoint hooks come from ``TunableComponentMixin`` in + ``contracts/hyperparameter_space.py``, which ``Featurizer`` mixes in as well; + ``is_fitted`` below is predictor-only and stays here. + """ + + cell_line_contract: ClassVar[FeatureContract] + drug_contract: ClassVar[FeatureContract] + supports_early_stopping: ClassVar[bool] = False + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + scope: ClassVar[ModelScope] = ModelScope.MULTI_DRUG + required_cell_line_blocks: ClassVar[tuple[str, ...]] = () + required_drug_blocks: ClassVar[tuple[str, ...]] = () + nan_threshold: ClassVar[float] = 0.2 + + def __init_subclass__(cls, **kwargs: object) -> None: + """Normalize class-body contract declarations, if there are any. + + A ``FeatureFormat`` shorthand is widened to a ``FeatureContract`` so the + class body and the ``@register`` arguments accept the same spellings. + Subclasses that declare nothing are registered with the decorator's + ``cell_line_contract=`` / ``drug_contract=`` instead. + + :param kwargs: Forwarded to ``ABC.__init_subclass__``. + :raises TypeError: If a class-body contract is neither a ``FeatureContract`` + nor a ``FeatureFormat``. + """ + super().__init_subclass__(**kwargs) + for attr_name in ("cell_line_contract", "drug_contract"): + if attr_name not in cls.__dict__: + continue + try: + setattr(cls, attr_name, normalize_feature_contract(cls.__dict__[attr_name])) + except TypeError as exc: + msg = f"{cls.__name__}: class-body {attr_name} is invalid: {exc}" + raise TypeError(msg) from exc + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Store hyperparameters merged with class defaults. + + :param hyperparameters: Optional overrides applied on top of + """ + self._hyperparameters: dict[str, Any] = { + **self.get_default_hyperparameters(), + **(hyperparameters or {}), + } + + def fit(self, batch: ModelInputBatch) -> None: + """Validate the batch, filter NaN pairs, and delegate to ``_fit``. + + :param batch: Featurized cell-line/drug pairs with training responses. + :raises ValueError: If *batch* has no response values. + """ + if batch.response is None: + msg = "Predictors require response values during fit" + raise ValueError(msg) + valid_mask = self._valid_pair_mask(batch) + self._warn_if_above_threshold(valid_mask, f"{type(self).__name__}.fit") + if valid_mask.all(): + self._fit(batch) + else: + self._fit(batch.subset_pairs(valid_mask)) + + @abstractmethod + def _fit(self, batch: ModelInputBatch) -> None: + """Subclass fitting logic (response is guaranteed non-None). + + :param batch: Featurized cell-line/drug pairs with training responses. + """ + + def predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict response, returning NaN for pairs with NaN features. + + :param batch: Featurized cell-line/drug pairs to score. + + :returns: One predicted response per pair in *batch*. + """ + valid_mask = self._valid_pair_mask(batch) + if valid_mask.all(): + return self._predict(batch) + result = np.full(batch.n_pairs, np.nan, dtype=np.float64) + if valid_mask.any(): + result[valid_mask] = self._predict(batch.subset_pairs(valid_mask)) + return result + + @abstractmethod + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Subclass prediction logic on pre-validated (non-NaN) pairs. + + :param batch: Featurized cell-line/drug pairs to score. + + :returns: One predicted response per pair in *batch*. + """ + + # ------------------------------------------------------------------ + # NaN detection helpers + # ------------------------------------------------------------------ + + def _valid_pair_mask(self, batch: ModelInputBatch) -> np.ndarray: + """Return boolean mask over pairs where features are non-NaN. + + :param batch: Input batch. + :returns: Boolean array of shape ``(batch.n_pairs,)``. + """ + cl_feats = batch.cell_line_features + cl_pair_idx = batch.cell_line_pair_idx + valid = np.ones(batch.n_pairs, dtype=bool) + + if cl_feats.size > 0 and cl_feats.dtype.kind == "f": + pair_cl = cl_feats[cl_pair_idx] + valid &= ~np.isnan(pair_cl).any(axis=1) + + if batch.drug_features is not None and batch.drug_features.size > 0 and batch.drug_features.dtype.kind == "f": + drug_pair_idx = batch.drug_pair_idx + if drug_pair_idx is not None: + pair_dr = batch.drug_features[drug_pair_idx] + valid &= ~np.isnan(pair_dr).any(axis=1) + + return valid + + def _warn_if_above_threshold(self, valid_mask: np.ndarray, context: str) -> None: + """Log a warning when the fraction of invalid pairs exceeds the threshold. + + :param valid_mask: Boolean array (True = valid). + :param context: Human-readable label for the warning message. + """ + if len(valid_mask) == 0: + return + invalid_frac = 1.0 - valid_mask.mean() + if invalid_frac > self.nan_threshold: + _logger.warning( + "%s: %.0f%% of pairs have NaN features (threshold: %.0f%%)", + context, + invalid_frac * 100, + self.nan_threshold * 100, + ) + + def is_fitted(self) -> bool: + """Return whether the predictor has been fit. + + :returns: ``True`` when ``get_state`` returns a non-empty mapping. + """ + return bool(self.get_state()) diff --git a/drevalpy/components/predictors/abstract/block.py b/drevalpy/components/predictors/abstract/block.py new file mode 100644 index 000000000..1edbecc52 --- /dev/null +++ b/drevalpy/components/predictors/abstract/block.py @@ -0,0 +1,37 @@ +"""Base helpers for predictors that consume named feature blocks.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import ClassVar + +import numpy as np + +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +class BlockPredictor(Predictor): + """Predictor that reads side-specific or named featurizer output blocks. + + "Block" includes named matrices (for example ``identity`` / ``tissue``) and + side-specific design matrices that must not be flattened indiscriminately. + """ + + input_interface: ClassVar[str] = "block" + + @abstractmethod + def _fit(self, batch: ModelInputBatch) -> None: + """Fit on a featurized predictor input batch. + + :param batch: Featurized pairs with training responses. + """ + + @abstractmethod + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict on a featurized predictor input batch. + + :param batch: Featurized pairs to score. + + :returns: One predicted response per pair in *batch*. + """ diff --git a/drevalpy/components/predictors/abstract/feature_free.py b/drevalpy/components/predictors/abstract/feature_free.py new file mode 100644 index 000000000..f40ce2f3a --- /dev/null +++ b/drevalpy/components/predictors/abstract/feature_free.py @@ -0,0 +1,13 @@ +"""Feature-free predictors that consume only response values.""" + +from __future__ import annotations + +from typing import ClassVar + +from drevalpy.components.predictors.abstract.base import Predictor + + +class FeatureFreePredictor(Predictor): + """Predictors that do not consume featurizer outputs or raw feature datasets.""" + + input_interface: ClassVar[str] = "feature_free" diff --git a/drevalpy/components/predictors/abstract/matrix.py b/drevalpy/components/predictors/abstract/matrix.py new file mode 100644 index 000000000..ca8b016b3 --- /dev/null +++ b/drevalpy/components/predictors/abstract/matrix.py @@ -0,0 +1,53 @@ +"""Predictors that consume flattened dense feature matrices.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import ClassVar + +import numpy as np + +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +class MatrixPredictor(Predictor): + """Predictor that flattens ``ModelInputBatch`` into one design matrix.""" + + input_interface: ClassVar[str] = "matrix" + + def _fit(self, batch: ModelInputBatch) -> None: + """Fit on a dense pair-level design matrix built from *batch*. + + :param batch: Featurized pairs with training responses. + :raises RuntimeError: If batch.response is None. + """ + x = batch.to_feature_matrix() + if batch.response is None: + raise RuntimeError("batch.response is required for fit") + self._fit_matrix(x, batch.response) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict from a dense pair-level design matrix built from *batch*. + + :param batch: Featurized pairs to score. + + :returns: One predicted response per pair in *batch*. + """ + return self._predict_matrix(batch.to_feature_matrix()) + + @abstractmethod + def _fit_matrix(self, x: np.ndarray, y: np.ndarray) -> None: + """Fit on a dense pair-level design matrix. + + :param x: Pair-level feature matrix. + :param y: Training responses aligned with *x*. + """ + + @abstractmethod + def _predict_matrix(self, x: np.ndarray) -> np.ndarray: + """Predict from a dense pair-level design matrix. + + :param x: Pair-level feature matrix. + :returns: Predicted responses aligned with rows of *x*. + """ diff --git a/drevalpy/components/predictors/lightgbm_pred.py b/drevalpy/components/predictors/lightgbm_pred.py new file mode 100644 index 000000000..54dcb06ae --- /dev/null +++ b/drevalpy/components/predictors/lightgbm_pred.py @@ -0,0 +1,62 @@ +"""LightGBM tabular predictor. + +``lightgbm`` is imported inside ``_make_estimator``: ``drevalpy.registry`` imports +this module to register the ``lightgbm`` predictor on ``import drevalpy``, and +``lightgbm.compat`` pulls in ``sklearn`` (and through it ``scipy.stats``), which +costs ~0.39s. See ``tests/test_import_cost_policy.py``. + +Everything this shares with ``xgboost_pred.py`` lives in ``_boosted_trees.py``. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors._boosted_trees import BoostedTreesPredictor +from drevalpy.registry.predictor import register + + +@register( + "lightgbm", + description="LightGBM regressor on concatenated dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class LightGBMPredictor(BoostedTreesPredictor): + """LightGBM regressor for dense tabular pair features.""" + + boosting_default_overrides: ClassVar[dict[str, Any]] = { + "subsample": 0.8, + "colsample_bytree": 0.8, + } + + boosting_extra_defaults: ClassVar[dict[str, Any]] = { + "num_leaves": 63, + "reg_lambda": 0.0, + "n_jobs": -1, + } + + boosting_space_overrides: ClassVar[dict[str, dict[str, Any]]] = { + "learning_rate": {"log": True}, + } + + tuned_hyperparameters: ClassVar[tuple[str, ...]] = ( + "n_estimators", + "learning_rate", + "max_depth", + "num_leaves", + "subsample", + "colsample_bytree", + "reg_alpha", + "reg_lambda", + ) + + def _make_estimator(self): + """Return an unfitted LightGBM regressor. + + :returns: Unfitted ``LGBMRegressor`` configured from hyperparameters. + """ + import lightgbm as lgb + + return lgb.LGBMRegressor(**self._estimator_params(), verbosity=-1) diff --git a/drevalpy/components/predictors/literature/__init__.py b/drevalpy/components/predictors/literature/__init__.py new file mode 100644 index 000000000..cb322a5b1 --- /dev/null +++ b/drevalpy/components/predictors/literature/__init__.py @@ -0,0 +1,5 @@ +"""Literature modular predictors (predictor-owned packages under this tree).""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/drevalpy/components/predictors/literature/_early_stopping.py b/drevalpy/components/predictors/literature/_early_stopping.py new file mode 100644 index 000000000..0c8a6f881 --- /dev/null +++ b/drevalpy/components/predictors/literature/_early_stopping.py @@ -0,0 +1,105 @@ +"""Checkpointed early-stopping loop shared by the hand-rolled torch training loops. + +``pharmaformer`` and ``dipk`` do not use Lightning; both ran the same skeleton +around their epoch functions - track the best validation loss, write the best +weights to a freshly randomised checkpoint file, stop after *patience* epochs +without improvement, then reload the best weights. Only the per-epoch work and +whether progress is printed differed. + +``torch`` is imported inside the entry point: both callers live in a ``predictor.py`` +that ``drevalpy.registry`` imports on ``import drevalpy``. See +``tests/test_import_cost_policy.py``. The leading underscore keeps the module out of +``registry/_builtins.py::_discover_modules``. +""" + +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from upath import UPath + +from drevalpy.utils.torch_io import load_state_dict, save_torch_payload + +if TYPE_CHECKING: + from collections.abc import Callable + + import torch + + +@dataclass(frozen=True) +class EarlyStoppingRun: + """How long to train, how long to wait, and where the best weights go.""" + + epochs: int + patience: int + checkpoint_dir: str | UPath + #: Used both in the checkpoint filename and as the log prefix. + model_name: str + verbose: bool = False + + +def train_with_early_stopping( + model: Any, + run: EarlyStoppingRun, + train_epoch: Callable[[], float], + val_epoch: Callable[[], float], + device: torch.device, +) -> None: + """Run *train_epoch*/*val_epoch* until patience runs out, then reload the best weights. + + :param model: Torch module being trained; its ``state_dict`` is checkpointed. + :param run: Epoch budget, patience, checkpoint location and logging. + :param train_epoch: Runs one training epoch and returns its mean loss. + :param val_epoch: Runs one validation epoch and returns its mean loss. + :param device: Device the reloaded weights are mapped onto. + """ + checkpoint_path = _prepare_checkpoint_path(run) + best_val_loss = float("inf") + epochs_without_improvement = 0 + + _log(run, f"Training {run.model_name} model") + for epoch in range(run.epochs): + train_loss = train_epoch() + _log(run, f"{run.model_name}: Epoch [{epoch + 1}/{run.epochs}] Training Loss: {train_loss:.4f}") + val_loss = val_epoch() + _log(run, f"{run.model_name}: Epoch [{epoch + 1}/{run.epochs}] Validation Loss: {val_loss:.4f}") + + if val_loss < best_val_loss: + best_val_loss = val_loss + epochs_without_improvement = 0 + save_torch_payload(model.state_dict(), checkpoint_path) + _log(run, f"{run.model_name}: Saved best model at epoch {epoch + 1}") + continue + + epochs_without_improvement += 1 + if epochs_without_improvement >= run.patience: + _log(run, f"{run.model_name}: Early stopping triggered at epoch {epoch + 1}") + break + + _log(run, f"{run.model_name}: Reloading the best model") + model.load_state_dict(load_state_dict(checkpoint_path, map_location=device)) + model.to(device) + + +def _prepare_checkpoint_path(run: EarlyStoppingRun) -> UPath: + """Create the checkpoint directory and return a collision-free file path. + + :param run: The run whose checkpoint directory and model name to use. + :returns: Path the best weights are written to. + """ + directory = UPath(run.checkpoint_dir) + directory.mkdir(parents=True, exist_ok=True) + version = "version-" + "".join(secrets.choice("0123456789abcdef") for _ in range(20)) + return directory / f"{version}_best_{run.model_name}_model.pth" + + +def _log(run: EarlyStoppingRun, message: str) -> None: + """Print *message* when the run is verbose. + + :param run: The run whose verbosity to honour. + :param message: Line to print. + """ + if run.verbose: + print(message) diff --git a/drevalpy/components/predictors/literature/_lightning_training.py b/drevalpy/components/predictors/literature/_lightning_training.py new file mode 100644 index 000000000..92b514b8a --- /dev/null +++ b/drevalpy/components/predictors/literature/_lightning_training.py @@ -0,0 +1,104 @@ +"""Early-stopped Lightning fits shared by MOLIR and SuperFELTR. + +``MOLIModel.fit`` and ``train_superfeltr_model`` assembled the same trainer by hand: +monitor the validation loss when there is a validation loader and the training loss +otherwise, early-stop on it, checkpoint the best epoch into a freshly randomised +subdirectory, and silence the progress bar. Only MOLIR's ``save_weights_only`` and +pinned single device differed, so those are fields rather than forks. + +``pytorch_lightning`` is imported inside the entry point. The leading underscore +keeps the module out of ``registry/_builtins.py::_discover_modules``. +""" + +from __future__ import annotations + +import secrets +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from upath import UPath + +if TYPE_CHECKING: + import pytorch_lightning as pl + from torch.utils.data import DataLoader + + +@dataclass(frozen=True) +class LightningRun: + """Trainer settings for one early-stopped Lightning fit.""" + + max_epochs: int + patience: int = 5 + checkpoint_dir: str | UPath = "checkpoints" + wandb_project: str | None = None + save_weights_only: bool = False + #: Pinned by MOLIR, left to Lightning's auto-detection elsewhere. + devices: int | str | None = None + enable_model_summary: bool = True + + +def run_lightning_fit( + model: pl.LightningModule, + train_loader: DataLoader, + val_loader: DataLoader | None, + run: LightningRun, +) -> pl.callbacks.ModelCheckpoint: + """Fit *model* with early stopping and return the checkpoint callback. + + :param model: The Lightning module to train. + :param train_loader: Training loader. + :param val_loader: Validation loader, or ``None`` to monitor the training loss. + :param run: Trainer settings. + :returns: The checkpoint callback holding the best epoch's path. + """ + import pytorch_lightning as pl + from pytorch_lightning.callbacks import EarlyStopping, TQDMProgressBar + + monitor = "train_loss" if val_loader is None else "val_loss" + checkpoint_callback = pl.callbacks.ModelCheckpoint( + dirpath=UPath(run.checkpoint_dir) / _versioned_name(), + monitor=monitor, + mode="min", + save_top_k=1, + save_weights_only=run.save_weights_only, + ) + trainer_kwargs: dict[str, Any] = {"enable_model_summary": run.enable_model_summary} + if run.devices is not None: + trainer_kwargs["devices"] = run.devices + + trainer = pl.Trainer( + max_epochs=run.max_epochs, + logger=_loggers(run.wandb_project), + callbacks=[ + EarlyStopping(monitor=monitor, mode="min", patience=run.patience), + checkpoint_callback, + TQDMProgressBar(refresh_rate=0), + ], + **trainer_kwargs, + ) + if val_loader is None: + trainer.fit(model, train_loader) + else: + trainer.fit(model, train_loader, val_loader) + return checkpoint_callback + + +def _loggers(wandb_project: str | None) -> list[Any] | bool: + """Resolve the Lightning ``logger`` argument for *wandb_project*. + + :param wandb_project: Weights & Biases project name, or ``None``. + :returns: A one-element logger list, or ``True`` for Lightning's default logger. + """ + if wandb_project is None: + return True + from pytorch_lightning.loggers import WandbLogger + + return [WandbLogger(project=wandb_project, log_model=False)] + + +def _versioned_name() -> str: + """Return a random subdirectory name, so concurrent fits cannot collide. + + :returns: A ``version-`` directory name. + """ + return "version-" + "".join(secrets.choice("0123456789abcdef") for _ in range(20)) diff --git a/drevalpy/components/predictors/literature/_metadata.py b/drevalpy/components/predictors/literature/_metadata.py new file mode 100644 index 000000000..30172030d --- /dev/null +++ b/drevalpy/components/predictors/literature/_metadata.py @@ -0,0 +1,63 @@ +"""Shared literature references for literature predictors.""" + +from __future__ import annotations + +from drevalpy.types.enums.literature_reference import LiteratureReference + +LITERATURE_INTEGRATION_DEVIATIONS = ( + "Modular drevalpy port; trainable encoders remain in the predictor. " + "Preprocessing, tensor layout, and default hyperparameters may differ from " + "reference repository scripts." +) + +DRUGGNN_REFERENCE = LiteratureReference( + repo_url="https://github.com/hauldhut/GraphDRP", + citation_text="DrugGNN-style GCN on molecular graphs with dense cell-line features (GraphDRP codebase).", + deviations=LITERATURE_INTEGRATION_DEVIATIONS, +) + +PRECILY_REFERENCE = LiteratureReference( + repo_url="https://github.com/SmritiChawla/Precily", + citation_text="Precily pathway and SMILESVec drug response model.", + deviations=LITERATURE_INTEGRATION_DEVIATIONS, +) + +SRMF_REFERENCE = LiteratureReference( + repo_url="https://github.com/linwang1982/SRMF", + citation_text="Similarity-regularized matrix factorization for drug response prediction.", + deviations=LITERATURE_INTEGRATION_DEVIATIONS, +) + +MOLIR_REFERENCE = LiteratureReference( + repo_url="https://github.com/hosseinshn/MOLI", + citation_doi="10.1186/s12859-023-05166-7", + citation_text="Multi-omics late integration regression (MOLIR / MOLI family).", + deviations=LITERATURE_INTEGRATION_DEVIATIONS, +) + +SUPERFELTR_REFERENCE = LiteratureReference( + repo_url="https://github.com/DMCB-GIST/Super.FELT", + citation_doi="10.1186/s12859-023-05166-7", + citation_text="SuperFELTR multi-omics feature extraction and late integration model.", + deviations=LITERATURE_INTEGRATION_DEVIATIONS, +) + +PHARMAFORMER_REFERENCE = LiteratureReference( + repo_url="https://github.com/zhouyuru1205/PharmaFormer", + citation_doi="10.1038/s41698-025-01082-6", + citation_text="PharmaFormer integrates gene expression and compound views via a transformer encoder.", + deviations=LITERATURE_INTEGRATION_DEVIATIONS, +) + +DIPK_REFERENCE = LiteratureReference( + repo_url="https://github.com/user15632/DIPK", + citation_text="DIPK deep integration model with BIONIC and MolGNet features.", + deviations=LITERATURE_INTEGRATION_DEVIATIONS, +) + +SPARSEGO_REFERENCE = LiteratureReference( + repo_url="https://github.com/KatynaSada/SparseGO_lightning", + citation_doi="10.1016/j.ebiom.2023.104767", + citation_text="SparseGO visible neural network structured by the Gene Ontology hierarchy.", + deviations=LITERATURE_INTEGRATION_DEVIATIONS, +) diff --git a/drevalpy/components/predictors/literature/_omics_loaders.py b/drevalpy/components/predictors/literature/_omics_loaders.py new file mode 100644 index 000000000..09bab3c59 --- /dev/null +++ b/drevalpy/components/predictors/literature/_omics_loaders.py @@ -0,0 +1,80 @@ +"""Three-omic pair loaders shared by MOLIR and SuperFELTR. + +``molir/utils.py`` and ``superfeltr/utils.py`` built their train and validation +loaders with byte-identical code: reshape the response to a column, index the +``gene_expression``/``mutations``/``copy_number`` matrices by the same pair index +array, drop the last incomplete training batch, keep it for validation. + +Both used to take the three validation matrices as separately optional arguments and +raise when only some were given. :class:`OmicsSplit` makes that partial state +unrepresentable, so the runtime guard is gone rather than shared. + +Nothing here imports ``torch``: ``make_pair_loader`` defers it. The leading +underscore keeps the module out of +``registry/_builtins.py::_discover_modules``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import numpy as np + from torch.utils.data import DataLoader + + +@dataclass(frozen=True) +class OmicsSplit: + """One split's entity-level omics, plus the pair-level response and indices. + + The three matrices are entity-level (``[n_entities, n_features]``); ``pair_idx`` + maps each pair onto a row of all three at once. + """ + + gene_expression: np.ndarray + mutations: np.ndarray + copy_number: np.ndarray + response: np.ndarray + pair_idx: np.ndarray + + +def make_omics_loaders( + train: OmicsSplit, + val: OmicsSplit | None, + batch_size: int, +) -> tuple[DataLoader, DataLoader | None]: + """Build the train and (optional) validation loaders for a three-omic model. + + :param train: Training split. + :param val: Validation split, or ``None`` to train without one. + :param batch_size: Mini-batch size for both loaders. + :returns: The training loader and the validation loader, the latter ``None`` + when *val* is ``None``. + """ + train_loader = _loader(train, batch_size, drop_last=True) + val_loader = None if val is None else _loader(val, batch_size, drop_last=False) + return train_loader, val_loader + + +def _loader(split: OmicsSplit, batch_size: int, *, drop_last: bool) -> DataLoader: + """Build one loader over the three omic views of *split*. + + :param split: The split to iterate. + :param batch_size: Mini-batch size. + :param drop_last: Whether to drop a trailing incomplete batch. + :returns: A loader yielding ``(expression, mutations, copy_number, response)``. + """ + from drevalpy.types.data.tensor_data import make_pair_loader + + response = split.response + response_column = response.reshape(-1, 1) if response.ndim == 1 else response + return make_pair_loader( + (split.gene_expression, split.pair_idx), + (split.mutations, split.pair_idx), + (split.copy_number, split.pair_idx), + response=response_column, + batch_size=batch_size, + shuffle=False, + drop_last=drop_last, + ) diff --git a/drevalpy/components/predictors/literature/_pair_predict.py b/drevalpy/components/predictors/literature/_pair_predict.py new file mode 100644 index 000000000..c43d6e289 --- /dev/null +++ b/drevalpy/components/predictors/literature/_pair_predict.py @@ -0,0 +1,120 @@ +"""Eval-time pair inference shared by the predictors that run their own torch loop. + +``pharmaformer``, ``precily`` and ``sparsego`` each resolved the pair indices, built +an eval ``make_pair_loader`` and accumulated predictions under ``torch.no_grad()`` +themselves. Only the forward call genuinely differed, which is why it is passed in +rather than unified here. + +``torch`` is imported inside the entry points. Every caller lives in a +``predictor.py`` that ``drevalpy.registry`` imports on ``import drevalpy``, so a +module-scope import would put the training stack back on the CLI startup path. See +``tests/test_import_cost_policy.py``. + +The leading underscore keeps the module out of +``registry/_builtins.py::_discover_modules``, which imports every public ``*.py`` in +a component directory. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Sequence + + import numpy as np + import torch + + from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +@dataclass(frozen=True) +class PairEvalSpec: + """One eval pass: which entity matrices to index, how to batch, where to run. + + The two block groups are kept apart because they are indexed by different arrays: + cell-line blocks by ``cell_line_pair_idx``, drug blocks by ``drug_pair_idx``. + """ + + cell_line_blocks: Sequence[np.ndarray] + drug_blocks: Sequence[np.ndarray] + batch_size: int + device: torch.device + + +def predict_pairs( + model: Any, + batch: ModelInputBatch, + spec: PairEvalSpec, + forward: Callable[..., torch.Tensor] | None = None, +) -> np.ndarray: + """Score every pair in *batch* with *model* in eval mode, in pair order. + + :param model: Fitted torch module; switched to eval mode before the pass. + :param batch: Featurized pairs to score. + :param spec: Blocks, batch size and target device for this pass. + :param forward: Called with one tensor per block; defaults to ``model(*tensors)``. + :returns: One predicted response per pair, flattened. + """ + import numpy as np + import torch + + run_forward = model if forward is None else forward + model.eval() + chunks: list[np.ndarray] = [] + with torch.no_grad(): + for tensors in _iter_pair_batches(batch, spec): + outputs = run_forward(*tensors) + chunks.append(outputs.detach().cpu().numpy().reshape(-1)) + if not chunks: + return np.empty(0, dtype=np.float64) + return np.concatenate(chunks).astype(np.float64) + + +def concatenated_forward(model: Any) -> Callable[..., torch.Tensor]: + """Wrap *model* so it receives the per-block tensors concatenated feature-wise. + + :param model: Torch module taking a single ``[batch, sum(dims)]`` tensor. + :returns: A forward callable accepting one tensor per block. + """ + import torch + + def forward(*tensors: torch.Tensor) -> torch.Tensor: + return model(torch.cat(tensors, dim=1)) + + return forward + + +def require_drug_pair_idx(drug_pair_idx: np.ndarray | None) -> np.ndarray: + """Narrow a batch's ``drug_pair_idx`` to non-``None``. + + :param drug_pair_idx: The batch's drug pair indices, possibly ``None``. + :returns: The same array. + :raises RuntimeError: If the batch carries no drug pair indices. + """ + if drug_pair_idx is None: + raise RuntimeError("drug_pair_idx is required for this predictor") + return drug_pair_idx + + +def _iter_pair_batches(batch: ModelInputBatch, spec: PairEvalSpec) -> Iterator[tuple[Any, ...]]: + """Yield mini-batches of entity features for every pair in *batch*, in order. + + :param batch: Featurized pairs to score. + :param spec: Blocks, batch size and target device for this pass. + :yields: One tuple of device-resident tensors per mini-batch. + """ + from drevalpy.types.data.tensor_data import make_pair_loader + + drug_pair_idx = require_drug_pair_idx(batch.drug_pair_idx) + cell_line_pair_idx = batch.cell_line_pair_idx + + loader = make_pair_loader( + *((values, cell_line_pair_idx) for values in spec.cell_line_blocks), + *((values, drug_pair_idx) for values in spec.drug_blocks), + batch_size=spec.batch_size, + shuffle=False, + ) + for tensors in loader: + yield tuple(tensor.to(spec.device) for tensor in tensors) diff --git a/drevalpy/components/predictors/literature/_single_drug_omics.py b/drevalpy/components/predictors/literature/_single_drug_omics.py new file mode 100644 index 000000000..fbb0ae399 --- /dev/null +++ b/drevalpy/components/predictors/literature/_single_drug_omics.py @@ -0,0 +1,219 @@ +"""Per-drug three-omic plumbing shared by MOLIR and SuperFELTR. + +Both are single-drug models over the same three cell-line views, and both carried +their own copy of: the per-drug checkpoint directory, the record of which feature +names a model was trained on, the column realignment applied at predict time, the +early-stopping index lookup, and the feature-name (de)serialization. + +Only ``numpy`` is imported at module scope - both predictors are registered on +``import drevalpy``, so the training stack must stay out. The leading underscore +keeps the module out of ``registry/_builtins.py::_discover_modules``. +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, replace +from typing import TYPE_CHECKING, Any + +import numpy as np +from upath import UPath + +from drevalpy.components.predictors.literature._omics_loaders import OmicsSplit +from drevalpy.components.predictors.literature.molir._omics import _realign_omic_matrix + +if TYPE_CHECKING: + from collections.abc import Iterator + + from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + +#: The three cell-line views both models consume, in encoder order. +OMIC_BLOCK_NAMES = ("gene_expression", "mutations", "copy_number_variation_gistic") + + +@dataclass(frozen=True) +class OmicFeatureNames: + """The feature names a per-drug model was trained on, one tuple per omic view.""" + + gene_expression: tuple[str, ...] | None + mutations: tuple[str, ...] | None + copy_number_variation: tuple[str, ...] | None + + def as_tuple(self) -> tuple[tuple[str, ...] | None, ...]: + """Return the three tuples in :data:`OMIC_BLOCK_NAMES` order. + + :returns: Feature names per view, aligned with ``OMIC_BLOCK_NAMES``. + """ + return (self.gene_expression, self.mutations, self.copy_number_variation) + + +@dataclass(frozen=True) +class OmicMatrices: + """One batch's three entity-level omic matrices, as float32.""" + + gene_expression: np.ndarray + mutations: np.ndarray + copy_number_variation: np.ndarray + + def widths(self) -> tuple[int, int, int]: + """Return the feature width of each view. + + :returns: Tuple of ``(expression, mutation, cnv)`` widths. + """ + return ( + self.gene_expression.shape[1], + self.mutations.shape[1], + self.copy_number_variation.shape[1], + ) + + def split(self, pair_idx: np.ndarray, response: np.ndarray) -> OmicsSplit: + """Pair these entity matrices with a pair-level response and index array. + + :param pair_idx: Row of each matrix to use for each pair. + :param response: Pair-level response values. + :returns: A loader-ready split. + """ + return OmicsSplit( + gene_expression=self.gene_expression, + mutations=self.mutations, + copy_number=self.copy_number_variation, + response=response, + pair_idx=pair_idx, + ) + + +def omic_matrices(batch: ModelInputBatch) -> OmicMatrices: + """Read the three cell-line views out of *batch*. + + :param batch: Batch carrying all three blocks. + :returns: The entity-level matrices as float32. + """ + values = [np.asarray(batch.cell_line_blocks[name].values, dtype=np.float32) for name in OMIC_BLOCK_NAMES] + return OmicMatrices(*values) + + +def omic_feature_names(batch: ModelInputBatch) -> OmicFeatureNames: + """Record the feature names of the three cell-line views of *batch*. + + :param batch: Batch carrying all three blocks. + :returns: The recorded feature names. + """ + return OmicFeatureNames(*(batch.cell_line_blocks[name].feature_names for name in OMIC_BLOCK_NAMES)) + + +def checkpoint_dir_for_drug(base_dir: UPath | str, drug_id: str) -> UPath: + """Return a unique checkpoint directory for a given drug. + + Hashing keeps the segment filesystem-safe: drug ids come from the dataset and + routinely contain ``/`` and other separators. + + :param base_dir: Base directory for checkpoints. + :param drug_id: Drug identifier to hash. + :returns: Path to the drug-specific checkpoint directory. + """ + digest = hashlib.sha256(drug_id.encode()).hexdigest()[:16] + return UPath(base_dir) / f"drug_{digest}" + + +def iter_drug_subsets(batch: ModelInputBatch) -> Iterator[tuple[str, ModelInputBatch]]: + """Split *batch* per drug, giving each sub-batch its own checkpoint directory. + + :param batch: Full training batch. + :yields: ``(drug_id, sub_batch)`` for every drug present. + """ + from drevalpy.components.contracts.training_context import TrainingContext + from drevalpy.components.predictors.single_drug_routing import iter_drug_masks + + base_dir = batch.training_context.checkpoint_dir + for drug_id, mask in iter_drug_masks(batch): + context = TrainingContext(checkpoint_dir=checkpoint_dir_for_drug(base_dir, drug_id)) + yield drug_id, replace(batch.subset_pairs(mask), training_context=context) + + +def early_stopping_indices(batch: ModelInputBatch) -> tuple[np.ndarray | None, np.ndarray | None]: + """Map the batch's early-stopping response onto cell-line entity rows. + + :param batch: Training batch. + :returns: Tuple of ``(val_pair_idx, val_response)``, both ``None`` when there is + no usable early-stopping split. + """ + es_resp = batch.early_stopping_response + if es_resp is None or len(es_resp) < 2: + return None, None + + entity_map = {str(eid): row for row, eid in enumerate(batch.cell_line_entity_ids)} + val_idx = np.array([entity_map[str(cl_id)] for cl_id in es_resp.cell_line_ids], dtype=np.intp) + return val_idx, np.asarray(es_resp.response, dtype=np.float32) + + +def validation_split(matrices: OmicMatrices, batch: ModelInputBatch) -> OmicsSplit | None: + """Build the early-stopping split over the same entity matrices as training. + + :param matrices: The batch's entity-level omic matrices. + :param batch: Training batch carrying the early-stopping response. + :returns: The validation split, or ``None`` when there is no usable one. + """ + val_pair_idx, val_response = early_stopping_indices(batch) + if val_pair_idx is None or val_response is None: + return None + return matrices.split(val_pair_idx, val_response) + + +def aligned_pair_matrices( + batch: ModelInputBatch, + feature_names: OmicFeatureNames, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Expand the three views to pair level and realign them onto the trained order. + + :param batch: Batch to score. + :param feature_names: Feature names the model was trained on. + :returns: Tuple of ``(expression, mutation, cnv)`` pair-level matrices. + """ + pair_idx = batch.cell_line_pair_idx + aligned = [] + for name, trained in zip(OMIC_BLOCK_NAMES, feature_names.as_tuple(), strict=True): + block = batch.cell_line_blocks[name] + values = np.asarray(block.values[pair_idx], dtype=np.float32) + aligned.append(_align(values, trained, block.feature_names)) + return aligned[0], aligned[1], aligned[2] + + +def feature_names_payload(feature_names: OmicFeatureNames | None) -> dict[str, list[str] | None]: + """Render feature names into the serializable form both predictors persist. + + :param feature_names: Recorded feature names, or ``None`` when unknown. + :returns: Mapping of payload key to feature-name list. + """ + names = feature_names.as_tuple() if feature_names is not None else (None, None, None) + keys = ("gene_expression_features", "mutations_features", "copy_number_variation_features") + return {key: list(value) if value else None for key, value in zip(keys, names, strict=True)} + + +def feature_names_from_payload(payload: dict[str, Any]) -> OmicFeatureNames: + """Read feature names back out of a persisted payload. + + :param payload: Deserialized per-drug payload. + :returns: The recorded feature names. + """ + keys = ("gene_expression_features", "mutations_features", "copy_number_variation_features") + values = [tuple(payload[key]) if payload.get(key) else None for key in keys] + return OmicFeatureNames(*values) + + +def _align( + values: np.ndarray, + model_features: tuple[str, ...] | None, + current_features: tuple[str, ...] | None, +) -> np.ndarray: + """Align omic matrix columns to match the training feature order. + + :param values: Input matrix to realign. + :param model_features: Feature names expected by the model. + :param current_features: Feature names in the current batch. + :returns: Realigned matrix. + """ + if model_features is None or current_features is None: + return values + if len(model_features) == values.shape[1] and model_features == current_features: + return values + return _realign_omic_matrix(values, model_features, current_features) diff --git a/drevalpy/models/DIPK/__init__.py b/drevalpy/components/predictors/literature/dipk/__init__.py similarity index 100% rename from drevalpy/models/DIPK/__init__.py rename to drevalpy/components/predictors/literature/dipk/__init__.py diff --git a/drevalpy/models/DIPK/attention_utils.py b/drevalpy/components/predictors/literature/dipk/attention_utils.py similarity index 94% rename from drevalpy/models/DIPK/attention_utils.py rename to drevalpy/components/predictors/literature/dipk/attention_utils.py index e7cd88b11..e5182b444 100644 --- a/drevalpy/models/DIPK/attention_utils.py +++ b/drevalpy/components/predictors/literature/dipk/attention_utils.py @@ -8,13 +8,13 @@ class MultiHeadAttentionLayer(nn.Module): """Custom multi-head attention layer for the DIPK model.""" def __init__(self, hid_dim: int, n_heads: int, dropout: float, device: str | torch.device | int | None): - """ - Initialize the multi-head attention layer. + """Initialize the multi-head attention layer. :param hid_dim: dimension of hidden layer :param n_heads: number of heads :param dropout: dropout rate :param device: which device to use, e.g. "cuda" or "cpu" + :raises ValueError: if hidden dimension is not divisible by the number of heads """ super().__init__() @@ -41,14 +41,14 @@ def __init__(self, hid_dim: int, n_heads: int, dropout: float, device: str | tor def forward( self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor, mask: torch.Tensor | None = None ) -> tuple[torch.Tensor, torch.Tensor]: - """ - Forward pass of the multi-head attention layer. + """Forward pass of the multi-head attention layer. :param query: query tensor :param key: key tensor :param value: value tensor :param mask: mask tensor - :returns: output tensor and attention tensor + + :returns: returns: output tensor and attention tensor """ batch_size = query.size(0) diff --git a/drevalpy/models/DIPK/gene_expression_encoder.py b/drevalpy/components/predictors/literature/dipk/gene_expression_encoder.py similarity index 95% rename from drevalpy/models/DIPK/gene_expression_encoder.py rename to drevalpy/components/predictors/literature/dipk/gene_expression_encoder.py index 5748897a5..f0d1570cd 100644 --- a/drevalpy/models/DIPK/gene_expression_encoder.py +++ b/drevalpy/components/predictors/literature/dipk/gene_expression_encoder.py @@ -50,7 +50,8 @@ def forward(self, input): """Forward pass of the gene expression encoder. :param input: input data - :return: encoded data + + :returns: return: encoded data """ result = self.encoder(input) embedding = functional.relu(self.bottleneck(result)) @@ -89,11 +90,11 @@ def __init__(self, input_dim, latent_dim=512, h_dims=None, drop_out_rate=0.3): self.decoder_output = nn.Linear(hidden_dims[-2], hidden_dims[-1]) def forward(self, embedding): - """ - Forward pass of the gene expression decoder. + """Forward pass of the gene expression decoder. :param embedding: input data - :return: decoded data + + :returns: return: decoded data """ result = self.decoder_input(embedding) result = self.decoder(result) @@ -108,7 +109,8 @@ def __call__(self, batch): """Collate the batch. :param batch: batch of PyG Data objects - :returns: PyG Batch, gene features, and bionic features + + :returns: returns: PyG Batch, gene features, and bionic features """ batch_data = torch.stack(batch) return batch_data @@ -128,7 +130,8 @@ def __getitem__(self, idx): """Return the data at the given index. :param idx: index - :return: data + + :returns: return: data """ data = self._data[idx] return data @@ -136,7 +139,7 @@ def __getitem__(self, idx): def __len__(self): """Return the length of the dataset. - :return: length of the dataset + :returns: return: length of the dataset """ return len(self._data) @@ -149,7 +152,8 @@ def train_gene_expession_autoencoder( :param gene_expression_input: gene expression data :param gene_expression_input_early_stopping: validation data for early stopping :param epochs_autoencoder: number of epochs for training the autoencoder - :return: trained encoder model + + :returns: return: trained encoder model """ lr = 1e-4 batch_size = 1024 @@ -231,7 +235,8 @@ def encode_gene_expression(gene_expression_input: np.ndarray, encoder: GeneExpre :param gene_expression_input: gene expression data :param encoder: trained encoder model - :return: encoded gene expression data + + :returns: return: encoded gene expression data """ encoder.eval() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") diff --git a/drevalpy/models/DIPK/model_utils.py b/drevalpy/components/predictors/literature/dipk/model_utils.py similarity index 88% rename from drevalpy/models/DIPK/model_utils.py rename to drevalpy/components/predictors/literature/dipk/model_utils.py index 0b4a8775a..8d6b7ba82 100644 --- a/drevalpy/models/DIPK/model_utils.py +++ b/drevalpy/components/predictors/literature/dipk/model_utils.py @@ -1,147 +1,144 @@ -"""Includes custom torch.nn.Modules for the DIPK model: AttentionLayer, DenseLayer, Predictor.""" - -import torch -import torch.nn as nn - -from .attention_utils import MultiHeadAttentionLayer - -features_dim_gene = 512 -features_dim_bionic = 512 -DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") - - -class AttentionLayer(nn.Module): - """Custom attention layer for the DIPK model.""" - - def __init__(self, heads: int = 1): - """ - Initialize the attention layer with a multi-head attention layer with a specified number of heads. - - :param heads: number of heads for the multi-head attention layer - """ - super().__init__() - self.fc_layer_0 = nn.Linear(features_dim_gene, 768) - self.fc_layer_1 = nn.Linear(features_dim_bionic, 768) - self.attention_0 = MultiHeadAttentionLayer(hid_dim=768, n_heads=heads, dropout=0.3, device=DEVICE) - self.attention_1 = MultiHeadAttentionLayer(hid_dim=768, n_heads=heads, dropout=0.3, device=DEVICE) - - def forward( - self, molgnet_features: torch.Tensor, mask: torch.Tensor, gene_expression: torch.Tensor, bionic: torch.Tensor - ) -> torch.Tensor: - """ - Forward pass of the attention layer. - - :param molgnet_features: MolGNet features - :param mask: mask for the MolGNet features, as molecules have varying sizes (valid atom features are True) - :param gene_expression: gene expression features of the graph data - :param bionic: bionic network features of the graph data - :returns: tensor of MolGNet features after attention layer - """ - gene_expression = nn.functional.relu(self.fc_layer_0(gene_expression)) # Shape: [batch_size, feature_dim_gene] - bionic = nn.functional.relu(self.fc_layer_1(bionic)) # Shape: [batch_size, feature_dim_bionic] - - # Preparing query, key, value for attention layers - query_0 = torch.unsqueeze(gene_expression, 1) # Shape: [batch_size, 1, 768] for gene - query_1 = torch.unsqueeze(bionic, 1) # Shape: [batch_size, 1, 768] for bionic - key = molgnet_features # Shape: [batch_size, seq_len, 768] (features from MolGNet) - value = molgnet_features # Shape: [batch_size, seq_len, 768] (same as key) - - mask = torch.unsqueeze(mask, 1).unsqueeze(2) - - # Apply the first attention layer - x_att = self.attention_0(query_0, key, value, mask) # Output: [batch_size, seq_len, hid_dim] - x = torch.squeeze(x_att[0]) # Squeeze to remove the extra dimension (1) - - # Apply the second attention layer - x_att = self.attention_1(query_1, key, value, mask) # Output: [batch_size, seq_len, hid_dim] - x += torch.squeeze(x_att[0]) # Add the result of the second attention to the first - - return x - - -class DenseLayers(nn.Module): - """Custom dense layers for the DIPK model.""" - - def __init__(self, fc_layer_num: int, fc_layer_dim: list[int], dropout_rate: float): - """ - Initialize the dense layers of the DIPK model which follow the attention layer. - - :param fc_layer_num: number of fully connected layers - :param fc_layer_dim: list of dimensions for each fully connected layer - :param dropout_rate: dropout rate for all fully connected layers - """ - super().__init__() - self.fc_layer_num = fc_layer_num - self.fc_layer_0 = nn.Linear(features_dim_gene, 512) - self.fc_layer_1 = nn.Linear(features_dim_bionic, 512) - self.fc_input = nn.Linear(768 + 512, 768 + 512) - self.fc_layers = torch.nn.Sequential( - nn.Linear(768 + 512, 512), - nn.Linear(512, fc_layer_dim[0]), - nn.Linear(fc_layer_dim[0], fc_layer_dim[1]), - nn.Linear(fc_layer_dim[1], fc_layer_dim[2]), - nn.Linear(fc_layer_dim[2], fc_layer_dim[3]), - nn.Linear(fc_layer_dim[3], fc_layer_dim[4]), - nn.Linear(fc_layer_dim[4], fc_layer_dim[5]), - ) - self.dropout_layers = torch.nn.ModuleList([nn.Dropout(p=dropout_rate) for _ in range(fc_layer_num)]) - self.fc_output = nn.Linear(fc_layer_dim[fc_layer_num - 2], 1) - - def forward(self, x: torch.Tensor, gene: torch.Tensor, bionic: torch.Tensor) -> torch.Tensor: - """ - Forward pass of the dense layers. - - :param x: output tensor from the attention layer - :param gene: gene expression features (GEF) of the graph data - :param bionic: biological network features (BNF) of the graph data - :returns: output tensor after the dense layers - """ - if len(x.shape) == 1: # happens if the batch size is 1 - x = torch.unsqueeze(x, 0) - - gene = torch.nn.functional.relu(self.fc_layer_0(gene)) - bionic = torch.nn.functional.relu(self.fc_layer_1(bionic)) - f = torch.cat((x, gene + bionic), 1) - f = torch.nn.functional.relu(self.fc_input(f)) - for layer_index in range(self.fc_layer_num): - f = torch.nn.functional.relu(self.fc_layers[layer_index](f)) - f = self.dropout_layers[layer_index](f) - f = self.fc_output(f) - return f - - -class Predictor(nn.Module): - """Whole DIPK model.""" - - def __init__(self, heads: int, fc_layer_num: int, fc_layer_dim: list[int], dropout_rate: float): - """ - Initialize the DIPK model with the specified hyperparameters. - - :param heads: number of heads for the multi-head attention layer - :param fc_layer_num: number of fully connected layers for the dense layers - :param fc_layer_dim: number of neurons for each fully connected layer - :param dropout_rate: dropout rate for all fully connected layers - """ - super().__init__() - self.attention_layer = AttentionLayer(heads=heads) - self.dense_layers = DenseLayers(fc_layer_num=fc_layer_num, fc_layer_dim=fc_layer_dim, dropout_rate=dropout_rate) - - def forward( - self, - molgnet_drug_features: torch.Tensor, - gene_expression: torch.Tensor, - bionic: torch.Tensor, - molgnet_mask: torch.Tensor, - ) -> torch.Tensor: - """ - Forward pass of the DIPK model. - - :param molgnet_drug_features: tensor of MolGNet features from graph data - :param gene_expression: gene expression features (GEF) of the graph data - :param bionic: biological network features (BNF) of the graph data - :param molgnet_mask: mask for the MolGNet features, as molecules have varying sizes - :returns: output tensor of the DIPK model - """ - molgnet_drug_features = self.attention_layer(molgnet_drug_features, molgnet_mask, gene_expression, bionic) - f = self.dense_layers(molgnet_drug_features, gene_expression, bionic) - return f +"""Includes custom torch.nn.Modules for the DIPK model: AttentionLayer, DenseLayer, Predictor.""" + +import torch +import torch.nn as nn + +from .attention_utils import MultiHeadAttentionLayer + +features_dim_gene = 512 +features_dim_bionic = 512 +DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +class AttentionLayer(nn.Module): + """Custom attention layer for the DIPK model.""" + + def __init__(self, heads: int = 1): + """Initialize the attention layer with a multi-head attention layer with a specified number of heads. + + :param heads: number of heads for the multi-head attention layer + """ + super().__init__() + self.fc_layer_0 = nn.Linear(features_dim_gene, 768) + self.fc_layer_1 = nn.Linear(features_dim_bionic, 768) + self.attention_0 = MultiHeadAttentionLayer(hid_dim=768, n_heads=heads, dropout=0.3, device=DEVICE) + self.attention_1 = MultiHeadAttentionLayer(hid_dim=768, n_heads=heads, dropout=0.3, device=DEVICE) + + def forward( + self, molgnet_features: torch.Tensor, mask: torch.Tensor, gene_expression: torch.Tensor, bionic: torch.Tensor + ) -> torch.Tensor: + """Forward pass of the attention layer. + + :param molgnet_features: MolGNet features + :param mask: mask for the MolGNet features, as molecules have varying sizes (valid atom features are True) + :param gene_expression: gene expression features of the graph data + :param bionic: bionic network features of the graph data + + :returns: returns: tensor of MolGNet features after attention layer + """ + gene_expression = nn.functional.relu(self.fc_layer_0(gene_expression)) # Shape: [batch_size, feature_dim_gene] + bionic = nn.functional.relu(self.fc_layer_1(bionic)) # Shape: [batch_size, feature_dim_bionic] + + # Preparing query, key, value for attention layers + query_0 = torch.unsqueeze(gene_expression, 1) # Shape: [batch_size, 1, 768] for gene + query_1 = torch.unsqueeze(bionic, 1) # Shape: [batch_size, 1, 768] for bionic + key = molgnet_features # Shape: [batch_size, seq_len, 768] (features from MolGNet) + value = molgnet_features # Shape: [batch_size, seq_len, 768] (same as key) + + mask = torch.unsqueeze(mask, 1).unsqueeze(2) + + # Apply the first attention layer + x_att = self.attention_0(query_0, key, value, mask) # Output: [batch_size, seq_len, hid_dim] + x = torch.squeeze(x_att[0]) # Squeeze to remove the extra dimension (1) + + # Apply the second attention layer + x_att = self.attention_1(query_1, key, value, mask) # Output: [batch_size, seq_len, hid_dim] + x += torch.squeeze(x_att[0]) # Add the result of the second attention to the first + + return x + + +class DenseLayers(nn.Module): + """Custom dense layers for the DIPK model.""" + + def __init__(self, fc_layer_num: int, fc_layer_dim: list[int], dropout_rate: float): + """Initialize the dense layers of the DIPK model which follow the attention layer. + + :param fc_layer_num: number of fully connected layers + :param fc_layer_dim: list of dimensions for each fully connected layer + :param dropout_rate: dropout rate for all fully connected layers + """ + super().__init__() + self.fc_layer_num = fc_layer_num + self.fc_layer_0 = nn.Linear(features_dim_gene, 512) + self.fc_layer_1 = nn.Linear(features_dim_bionic, 512) + self.fc_input = nn.Linear(768 + 512, 768 + 512) + self.fc_layers = torch.nn.Sequential( + nn.Linear(768 + 512, 512), + nn.Linear(512, fc_layer_dim[0]), + nn.Linear(fc_layer_dim[0], fc_layer_dim[1]), + nn.Linear(fc_layer_dim[1], fc_layer_dim[2]), + nn.Linear(fc_layer_dim[2], fc_layer_dim[3]), + nn.Linear(fc_layer_dim[3], fc_layer_dim[4]), + nn.Linear(fc_layer_dim[4], fc_layer_dim[5]), + ) + self.dropout_layers = torch.nn.ModuleList([nn.Dropout(p=dropout_rate) for _ in range(fc_layer_num)]) + self.fc_output = nn.Linear(fc_layer_dim[fc_layer_num - 2], 1) + + def forward(self, x: torch.Tensor, gene: torch.Tensor, bionic: torch.Tensor) -> torch.Tensor: + """Forward pass of the dense layers. + + :param x: output tensor from the attention layer + :param gene: gene expression features (GEF) of the graph data + :param bionic: biological network features (BNF) of the graph data + + :returns: returns: output tensor after the dense layers + """ + if len(x.shape) == 1: # happens if the batch size is 1 + x = torch.unsqueeze(x, 0) + + gene = torch.nn.functional.relu(self.fc_layer_0(gene)) + bionic = torch.nn.functional.relu(self.fc_layer_1(bionic)) + f = torch.cat((x, gene + bionic), 1) + f = torch.nn.functional.relu(self.fc_input(f)) + for layer_index in range(self.fc_layer_num): + f = torch.nn.functional.relu(self.fc_layers[layer_index](f)) + f = self.dropout_layers[layer_index](f) + f = self.fc_output(f) + return f + + +class Predictor(nn.Module): + """Whole DIPK model.""" + + def __init__(self, heads: int, fc_layer_num: int, fc_layer_dim: list[int], dropout_rate: float): + """Initialize the DIPK model with the specified hyperparameters. + + :param heads: number of heads for the multi-head attention layer + :param fc_layer_num: number of fully connected layers for the dense layers + :param fc_layer_dim: number of neurons for each fully connected layer + :param dropout_rate: dropout rate for all fully connected layers + """ + super().__init__() + self.attention_layer = AttentionLayer(heads=heads) + self.dense_layers = DenseLayers(fc_layer_num=fc_layer_num, fc_layer_dim=fc_layer_dim, dropout_rate=dropout_rate) + + def forward( + self, + molgnet_drug_features: torch.Tensor, + gene_expression: torch.Tensor, + bionic: torch.Tensor, + molgnet_mask: torch.Tensor, + ) -> torch.Tensor: + """Forward pass of the DIPK model. + + :param molgnet_drug_features: tensor of MolGNet features from graph data + :param gene_expression: gene expression features (GEF) of the graph data + :param bionic: biological network features (BNF) of the graph data + :param molgnet_mask: mask for the MolGNet features, as molecules have varying sizes + + :returns: returns: output tensor of the DIPK model + """ + molgnet_drug_features = self.attention_layer(molgnet_drug_features, molgnet_mask, gene_expression, bionic) + f = self.dense_layers(molgnet_drug_features, gene_expression, bionic) + return f diff --git a/drevalpy/components/predictors/literature/dipk/predictor.py b/drevalpy/components/predictors/literature/dipk/predictor.py new file mode 100644 index 000000000..069f6a3b0 --- /dev/null +++ b/drevalpy/components/predictors/literature/dipk/predictor.py @@ -0,0 +1,426 @@ +"""DIPK predictor consuming ModelInputBatch directly. + +``torch`` is imported inside the methods that use it, and the DIPK network comes +from ``.model_utils`` only when a model is actually built. ``drevalpy.registry`` +imports this module to register the ``dipk`` predictor on ``import drevalpy``, so +a module-scope ``import torch`` put ~0.35s on the startup path of every CLI +invocation. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.literature._early_stopping import ( + EarlyStoppingRun, + train_with_early_stopping, +) +from drevalpy.components.predictors.literature._metadata import DIPK_REFERENCE +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.utils.torch_io import load_state_dict, save_state_dict + +if TYPE_CHECKING: + import torch + from torch import nn, optim + from torch.utils.data import DataLoader + + from .model_utils import Predictor as DIPKNetwork + + +@register( + "dipk", + description="DIPK BIONIC + MolGNet model.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.RAGGED_SEQUENCE, + reference=DIPK_REFERENCE, +) +class DIPKPredictor(BlockPredictor): + """DIPK predictor that trains an attention-based model on gene expression, BIONIC, and MolGNet features.""" + + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ("gene_expression", "bionic_features") + required_drug_blocks: ClassVar[tuple[str, ...]] = ("molgnet_features",) + required_cell_line_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("bionic_features", FeatureFormat.NUMERIC_MATRIX), + ) + required_drug_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("molgnet_features", FeatureFormat.RAGGED_SEQUENCE), + ) + validate_drug_graphs: ClassVar[bool] = False + supports_early_stopping: ClassVar[bool] = True + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize the DIPK predictor. + + :param hyperparameters: Optional hyperparameter overrides. + """ + import torch + + super().__init__(hyperparameters) + self._model: DIPKNetwork | None = None + self._device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Return default hyperparameters for DIPK. + + :returns: Default hyperparameter mapping. + """ + return { + "batch_size": 64, + "lr": 0.0001, + "heads": 2, + "fc_layer_num": 3, + "fc_layer_dim": [256, 128, 64, 32, 16, 1], + "dropout_rate": 0.3, + "epochs": 100, + "patience": 10, + } + + def _build_model(self) -> DIPKNetwork: + """Construct the DIPK network from hyperparameters. + + :returns: Initialized DIPKNetwork placed on the target device. + """ + from .model_utils import Predictor as DIPKNetwork + + hp = self._hyperparameters + return DIPKNetwork( + heads=hp["heads"], + fc_layer_num=hp["fc_layer_num"], + fc_layer_dim=hp["fc_layer_dim"], + dropout_rate=hp["dropout_rate"], + ).to(self._device) + + def _build_samples( + self, + cell_line_pair_idx: np.ndarray, + drug_pair_idx: np.ndarray, + batch: ModelInputBatch, + response: np.ndarray | None = None, + ) -> list[dict[str, torch.Tensor]]: + """Construct per-sample dictionaries from batch blocks using pair indices. + + :param cell_line_pair_idx: Indices mapping pairs to cell-line entities. + :param drug_pair_idx: Indices mapping pairs to drug entities. + :param batch: Model input batch with feature blocks. + :param response: Optional response values per pair. + :returns: List of sample dictionaries with tensor values. + """ + import torch + + gene_block = batch.cell_line_blocks["gene_expression"].values + bionic_block = batch.cell_line_blocks["bionic_features"].values + drug_block = batch.drug_blocks["molgnet_features"].values + + samples: list[dict[str, torch.Tensor]] = [] + for i in range(len(cell_line_pair_idx)): + cl_idx = cell_line_pair_idx[i] + dr_idx = drug_pair_idx[i] + + sample: dict[str, torch.Tensor] = { + "molgnet_features": torch.tensor(np.asarray(drug_block[dr_idx], dtype=np.float32)), + "gene_expression": torch.tensor(np.asarray(gene_block[cl_idx], dtype=np.float32)), + "bionic_features": torch.tensor(np.asarray(bionic_block[cl_idx], dtype=np.float32)), + } + if response is not None: + sample["ic50"] = torch.tensor([response[i]], dtype=torch.float32) + samples.append(sample) + return samples + + def _build_loaders(self, batch: ModelInputBatch) -> tuple[DataLoader, DataLoader]: + """Build the training and early-stopping loaders. + + :param batch: Featurized training batch. + :returns: Tuple of ``(train_loader, es_loader)``. + :raises ValueError: If drug features or early stopping data is missing. + """ + if batch.drug_pair_idx is None: + msg = "DIPK requires drug features" + raise ValueError(msg) + if batch.early_stopping_response is None: + msg = "DIPK requires early stopping data" + raise ValueError(msg) + + train_samples = self._build_samples( + batch.cell_line_pair_idx, + batch.drug_pair_idx, + batch, + response=batch.response, + ) + + es_response = batch.early_stopping_response + cl_pair_idx_es, drug_pair_idx_es = batch._pair_indices_for(es_response) + if drug_pair_idx_es is None: + msg = "DIPK requires drug pair indices for early stopping" + raise ValueError(msg) + es_samples = self._build_samples( + cl_pair_idx_es, + drug_pair_idx_es, + batch, + response=es_response.response, + ) + + return ( + self._loader(train_samples, train=True, shuffle=True), + self._loader(es_samples, train=True, shuffle=True), + ) + + def _loader(self, samples: list[dict[str, torch.Tensor]], *, train: bool, shuffle: bool) -> DataLoader: + """Wrap *samples* in a padding-collated DataLoader. + + :param samples: Per-sample dictionaries from :meth:`_build_samples`. + :param train: Whether the collated batches carry target values. + :param shuffle: Whether to shuffle each epoch. + :returns: A DataLoader over the samples. + """ + from torch.utils.data import DataLoader + + return DataLoader( + _DIPKDataset(samples), + batch_size=self._hyperparameters["batch_size"], + shuffle=shuffle, + collate_fn=_CollateFn(train=train), + ) + + def _fit(self, batch: ModelInputBatch) -> None: + """Train the DIPK model on a batch of pairs. + + :param batch: Featurized batch with gene_expression, bionic_features, molgnet_features blocks. + """ + from torch import nn, optim + + train_loader, es_loader = self._build_loaders(batch) + + model = self._build_model() + self._model = model + hp = self._hyperparameters + + loss_func = nn.MSELoss() + optimizer = optim.Adam(model.parameters(), lr=hp["lr"]) + + train_with_early_stopping( + model, + EarlyStoppingRun( + epochs=hp["epochs"], + patience=hp["patience"], + checkpoint_dir=batch.training_context.checkpoint_dir, + model_name="DIPK", + ), + train_epoch=lambda: self._run_epoch(model, train_loader, loss_func, optimizer), + val_epoch=lambda: self._run_epoch(model, es_loader, loss_func, None), + device=self._device, + ) + + def _run_epoch( + self, + model: DIPKNetwork, + loader: DataLoader, + loss_func: nn.Module, + optimizer: optim.Optimizer | None, + ) -> float: + """Run one train or validation epoch. + + :param model: The DIPK network. + :param loader: Training or validation loader. + :param loss_func: Loss function. + :param optimizer: Optimizer for training; ``None`` for eval-only. + :returns: Mean epoch loss. + """ + import torch + + is_training = optimizer is not None + if is_training: + model.train() + else: + model.eval() + + epoch_loss = 0.0 + batch_count = 0 + context = torch.enable_grad() if is_training else torch.no_grad() + with context: + for dl_batch in loader: + prediction = self._forward(model, dl_batch) + loss = loss_func(torch.squeeze(prediction), torch.squeeze(dl_batch["ic50_values"].to(self._device))) + + if optimizer is not None: + optimizer.zero_grad() + loss.backward() + optimizer.step() + epoch_loss += loss.detach().item() + else: + epoch_loss += loss.item() + batch_count += 1 + + return epoch_loss / max(batch_count, 1) + + def _forward(self, model: DIPKNetwork, dl_batch: dict[str, torch.Tensor]) -> torch.Tensor: + """Move one collated batch onto the device and run the network. + + :param model: The DIPK network. + :param dl_batch: One collated batch from the loader. + :returns: The network's raw output. + """ + return model( + molgnet_drug_features=dl_batch["molgnet_features"].to(self._device), + gene_expression=dl_batch["gene_features"].to(self._device), + bionic=dl_batch["bionic_features"].to(self._device), + molgnet_mask=dl_batch["molgnet_mask"].to(self._device), + ) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Run DIPK inference on the given batch. + + :param batch: Featurized pairs to score. + :returns: One predicted response per pair. + :raises ValueError: If drug pair indices are missing. + """ + import torch + + if self._model is None: + return np.full(batch.n_pairs, np.nan, dtype=np.float64) + if batch.drug_pair_idx is None: + msg = "DIPK requires drug features for prediction" + raise ValueError(msg) + + samples = self._build_samples(batch.cell_line_pair_idx, batch.drug_pair_idx, batch) + test_loader = self._loader(samples, train=False, shuffle=False) + + self._model.eval() + chunks: list[np.ndarray] = [] + with torch.no_grad(): + for dl_batch in test_loader: + prediction = self._forward(self._model, dl_batch) + chunks.append(prediction.detach().cpu().numpy().reshape(-1)) + + if not chunks: + return np.empty(0, dtype=np.float64) + return np.concatenate(chunks).astype(np.float64) + + def is_fitted(self) -> bool: + """Return whether the model has been trained. + + :returns: True when the model is initialized. + """ + return self._model is not None + + def get_state(self) -> dict[str, object]: + """Serialize fitted model state for persistence. + + :returns: Mapping with binary payload and hyperparameters. + """ + if self._model is None: + return {} + return { + "payload": save_state_dict(self._model.state_dict()), + "hyperparameters": dict(self._hyperparameters), + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore fitted model from serialized state. + + :param state: State mapping from ``get_state``. + :raises PredictorStateError: If state is malformed. + """ + hp = state.get("hyperparameters") + if not isinstance(hp, dict): + msg = "DIPKPredictor state requires hyperparameters dict" + raise PredictorStateError(msg) + self._hyperparameters = dict(hp) + model_bytes = state.get("payload") + if not isinstance(model_bytes, (bytes, bytearray)): + msg = "DIPKPredictor state requires payload bytes" + raise PredictorStateError(msg) + self._model = self._build_model() + self._model.load_state_dict(load_state_dict(bytes(model_bytes))) + self._model.to(self._device) + + +class _CollateFn: + """Collate function for DIPK DataLoader batches.""" + + def __init__(self, train: bool = True) -> None: + """Initialize collation function. + + :param train: Whether to include target (ic50) values. + """ + self.train = train + + def __call__(self, batch: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]: + """Collate a list of sample dicts into padded batch tensors. + + :param batch: List of per-sample dictionaries. + :returns: Collated batch dictionary. + """ + import torch + + max_atoms = max(sample["molgnet_features"].size(0) for sample in batch) + + padded_molgnet: list[torch.Tensor] = [] + masks: list[torch.Tensor] = [] + + for sample in batch: + num_atoms = sample["molgnet_features"].size(0) + padding_size = max_atoms - num_atoms + padded = torch.cat( + [sample["molgnet_features"], torch.zeros(padding_size, sample["molgnet_features"].size(1))], + dim=0, + ) + padded_molgnet.append(padded) + mask = torch.cat( + [torch.ones(num_atoms, dtype=torch.bool), torch.zeros(padding_size, dtype=torch.bool)], + dim=0, + ) + masks.append(mask) + + result: dict[str, torch.Tensor] = { + "molgnet_features": torch.stack(padded_molgnet), + "molgnet_mask": torch.stack(masks), + "gene_features": torch.stack([s["gene_expression"] for s in batch]), + "bionic_features": torch.stack([s["bionic_features"] for s in batch]), + } + if self.train: + result["ic50_values"] = torch.stack([s["ic50"] for s in batch]) + return result + + +class _DIPKDataset: + """Simple list-backed dataset for DIPK samples. + + Deliberately not a ``torch.utils.data.Dataset`` subclass: that base class + contributes only ``__add__``, which nothing here uses, and inheriting from it + would force ``import torch`` at module scope - which is exactly what keeps + this predictor off the ``import drevalpy`` critical path. ``DataLoader`` + treats any object with ``__getitem__`` and ``__len__`` as a map-style dataset. + """ + + def __init__(self, samples: list[dict[str, torch.Tensor]]) -> None: + """Initialize dataset from a list of sample dicts. + + :param samples: Pre-built sample dictionaries. + """ + self._samples = samples + + def __getitem__(self, idx: int) -> dict[str, torch.Tensor]: + """Return sample at the given index. + + :param idx: Sample index. + :returns: Sample dictionary. + """ + return self._samples[idx] + + def __len__(self) -> int: + """Return number of samples. + + :returns: Dataset length. + """ + return len(self._samples) diff --git a/drevalpy/components/predictors/literature/druggnn/__init__.py b/drevalpy/components/predictors/literature/druggnn/__init__.py new file mode 100644 index 000000000..2bbf58acb --- /dev/null +++ b/drevalpy/components/predictors/literature/druggnn/__init__.py @@ -0,0 +1 @@ +"""DrugGNN literature algorithm package.""" diff --git a/drevalpy/components/predictors/literature/druggnn/algorithm.py b/drevalpy/components/predictors/literature/druggnn/algorithm.py new file mode 100644 index 000000000..a29ae2500 --- /dev/null +++ b/drevalpy/components/predictors/literature/druggnn/algorithm.py @@ -0,0 +1,160 @@ +"""DrugGNN neural network components.""" + +import pytorch_lightning as pl +import torch +import torch.nn as nn +from torch.optim import Adam +from torch_geometric.nn import GCNConv, global_mean_pool + + +class DrugGraphNet(nn.Module): + """Neural network for DrugGNN.""" + + def __init__(self, num_node_features, num_cell_features, hidden_dim=64, dropout=0.2): + """Initialize the network. + + :param num_node_features: Number of features for each node in the drug graph. + :param num_cell_features: Number of features for the cell line. + :param hidden_dim: The hidden dimension size. + :param dropout: The dropout rate. + """ + super().__init__() + self.dropout = dropout + + # Drug Encoder (GNN) + self.conv1 = GCNConv(num_node_features, hidden_dim) + self.conv2 = GCNConv(hidden_dim, hidden_dim * 2) + self.conv3 = GCNConv(hidden_dim * 2, hidden_dim * 4) + self.drug_embed_fc = nn.Linear(hidden_dim * 4, hidden_dim) + + # Cell Line Encoder (MLP) + self.cell_fc1 = nn.Linear(num_cell_features, hidden_dim * 2) + self.cell_fc2 = nn.Linear(hidden_dim * 2, hidden_dim) + + # Combiner and Regressor + self.combiner_fc1 = nn.Linear(hidden_dim * 2, hidden_dim) + self.combiner_fc2 = nn.Linear(hidden_dim, 32) + self.output_fc = nn.Linear(32, 1) + + def forward(self, drug_graph, cell_features): + """Forward pass of the network. + + :param drug_graph: The drug graph. + :param cell_features: The cell line features. + + :returns: return: The output of the network. + """ + # Process drug graph + x, edge_index, batch = drug_graph.x, drug_graph.edge_index, drug_graph.batch + + x = self.conv1(x, edge_index) + x = nn.functional.relu(x) + x = nn.functional.dropout(x, p=self.dropout, training=self.training) + + x = self.conv2(x, edge_index) + x = nn.functional.relu(x) + x = nn.functional.dropout(x, p=self.dropout, training=self.training) + + x = self.conv3(x, edge_index) + x = nn.functional.relu(x) + + drug_embedding = global_mean_pool(x, batch) + drug_embedding = self.drug_embed_fc(drug_embedding) + + # Process cell line features + cell_embedding = nn.functional.relu(self.cell_fc1(cell_features)) + cell_embedding = nn.functional.dropout(cell_embedding, p=self.dropout, training=self.training) + cell_embedding = self.cell_fc2(cell_embedding) + + # Concatenate and predict + combined = torch.cat([drug_embedding, cell_embedding], dim=1) + x = nn.functional.relu(self.combiner_fc1(combined)) + x = nn.functional.dropout(x, p=self.dropout, training=self.training) + x = nn.functional.relu(self.combiner_fc2(x)) + x = nn.functional.dropout(x, p=self.dropout, training=self.training) + out = self.output_fc(x) + return out.view(-1) + + +class DrugGNNModule(pl.LightningModule): + """The LightningModule for the DrugGNN model.""" + + def __init__( + self, + num_node_features: int, + num_cell_features: int, + hidden_dim: int = 64, + dropout: float = 0.2, + learning_rate: float = 0.001, + ): + """Initialize the LightningModule. + + :param num_node_features: Number of features for each node in the drug graph. + :param num_cell_features: Number of features for the cell line. + :param hidden_dim: The hidden dimension size. + :param dropout: The dropout rate. + :param learning_rate: The learning rate. + """ + super().__init__() + self.save_hyperparameters() + self.model = DrugGraphNet( + num_node_features=self.hparams["num_node_features"], + num_cell_features=self.hparams["num_cell_features"], + hidden_dim=self.hparams["hidden_dim"], + dropout=self.hparams["dropout"], + ) + self.criterion = nn.MSELoss() + + def forward(self, batch): + """Forward pass of the module. + + :param batch: The batch. + + :returns: return: The output of the model. + """ + drug_graph, cell_features, _ = batch + return self.model(drug_graph, cell_features) + + def training_step(self, batch, batch_idx): + """A single training step. + + :param batch: The batch. + :param batch_idx: The batch index. + + :returns: return: The loss. + """ + drug_graph, cell_features, responses = batch + outputs = self.model(drug_graph, cell_features) + loss = self.criterion(outputs, responses) + self.log("train_loss", loss, on_step=False, on_epoch=True, batch_size=responses.size(0)) + + return loss + + def validation_step(self, batch, batch_idx): + """A single validation step. + + :param batch: The batch. + :param batch_idx: The batch index. + """ + drug_graph, cell_features, responses = batch + outputs = self.model(drug_graph, cell_features) + loss = self.criterion(outputs, responses) + self.log("val_loss", loss, on_step=False, on_epoch=True, batch_size=responses.size(0)) + + def predict_step(self, batch, batch_idx, dataloader_idx=0): + """A single prediction step. + + :param batch: The batch. + :param batch_idx: The batch index. + :param dataloader_idx: The dataloader index. + + :returns: return: The output of the model. + """ + return self.forward(batch) + + def configure_optimizers(self): + """Configure the optimizer. + + :returns: return: The optimizer. + """ + return Adam(self.parameters(), lr=self.hparams.learning_rate) diff --git a/drevalpy/components/predictors/literature/druggnn/predictor.py b/drevalpy/components/predictors/literature/druggnn/predictor.py new file mode 100644 index 000000000..8b0002b95 --- /dev/null +++ b/drevalpy/components/predictors/literature/druggnn/predictor.py @@ -0,0 +1,396 @@ +"""DrugGNN block predictor – GCN on molecular graphs with dense cell-line features.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.literature._metadata import DRUGGNN_REFERENCE +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.utils.torch_io import load_state_dict, save_state_dict + +# torch, pytorch_lightning and torch_geometric are imported inside the methods +# that need them, not at module scope: this module is imported eagerly by +# register_builtin_components(), so a module-scope import would put ~2s of the +# training stack on the critical path of every `import drevalpy`. Guarded by +# tests/test_import_cost_policy.py. +if TYPE_CHECKING: + from torch_geometric.loader import DataLoader + + from drevalpy.components.predictors.literature.druggnn.algorithm import DrugGNNModule + + +class _DrugGNNDataset: + """Map-style dataset that yields (drug_graph, cell_features, response) tuples. + + Accepts pre-built index arrays mapping each pair to entity-level features. + + Deliberately not a ``torch.utils.data.Dataset`` subclass: that base class + contributes only ``__add__``, which nothing here uses, and inheriting from it + would require ``torch`` at class-definition time. + """ + + def __init__( + self, + cell_line_pair_idx: np.ndarray, + drug_pair_idx: np.ndarray, + cell_line_matrix: np.ndarray, + drug_graphs: np.ndarray, + response: np.ndarray, + ) -> None: + """Initialize the DrugGNN dataset. + + :param cell_line_pair_idx: Pair-level indices into the cell-line matrix. + :param drug_pair_idx: Pair-level indices into the drug graphs array. + :param cell_line_matrix: Cell-line feature matrix. + :param drug_graphs: Array of PyG Data objects for drugs. + :param response: Response values for each pair. + """ + import torch + + self._cl_pair_idx = cell_line_pair_idx + self._drug_pair_idx = drug_pair_idx + self._cl_tensors = torch.as_tensor(cell_line_matrix, dtype=torch.float32) + self._drug_graphs = drug_graphs + self._response = torch.as_tensor(response, dtype=torch.float32) + + def __len__(self) -> int: + """Return the number of samples. + + :returns: Dataset length. + """ + return len(self._response) + + def __getitem__(self, idx: int): + """Return (drug_graph, cell_features, response) for the given index. + + :param idx: Sample index. + :returns: Tuple of drug graph, cell tensor, and response scalar. + """ + cl_idx = self._cl_pair_idx[idx] + drug_idx = self._drug_pair_idx[idx] + return self._drug_graphs[drug_idx], self._cl_tensors[cl_idx], self._response[idx] + + +@register( + "drugGNN", + description="DrugGNN: GCN on molecular graphs with dense cell-line features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.GRAPH, + reference=DRUGGNN_REFERENCE, +) +class DrugGNNPredictor(BlockPredictor): + """Registered DrugGNN predictor consuming ModelInputBatch directly.""" + + supports_early_stopping: ClassVar[bool] = True + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ("gene_expression",) + required_drug_blocks: ClassVar[tuple[str, ...]] = ("drug_graph",) + required_cell_line_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX), + ) + required_drug_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("drug_graph", FeatureFormat.GRAPH),) + validate_drug_graphs: ClassVar[bool] = True + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize the DrugGNN predictor. + + :param hyperparameters: Optional hyperparameter overrides. + """ + super().__init__(hyperparameters) + self._model: DrugGNNModule | None = None + self._num_node_features: int = 0 + self._num_cell_features: int = 0 + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Return default hyperparameters. + + :returns: Default hyperparameter mapping. + """ + return { + "learning_rate": 0.001, + "epochs": 2, + "hidden_dim": 64, + "dropout": 0.2, + "batch_size": 8, + } + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return the tunable hyperparameter space. + + :returns: Ray Tune-style hyperparameter specs. + """ + return { + "hidden_dim": {"type": "int", "low": 16, "high": 128, "default": 64}, + "dropout": {"type": "float", "low": 0.0, "high": 0.5, "default": 0.2}, + "learning_rate": {"type": "float", "low": 1e-4, "high": 1e-2, "log": True, "default": 1e-3}, + "epochs": {"type": "int", "low": 1, "high": 10, "default": 2}, + "batch_size": {"type": "int", "low": 4, "high": 32, "default": 8}, + } + + # ------------------------------------------------------------------ + # Fit + # ------------------------------------------------------------------ + + @staticmethod + def _resolve_drug_pair_idx(batch: ModelInputBatch) -> np.ndarray: + """Return drug_pair_idx, computing it from entity IDs if absent. + + :param batch: Input batch. + :returns: Array mapping each pair to a drug entity index. + :raises ValueError: If neither drug_pair_idx nor drug_entity_ids are available. + """ + if batch.drug_pair_idx is not None: + return batch.drug_pair_idx + if batch.drug_entity_ids is None: + msg = "DrugGNN requires either drug_pair_idx or drug_entity_ids" + raise ValueError(msg) + entity_map = {str(eid): i for i, eid in enumerate(batch.drug_entity_ids)} + return np.array([entity_map[str(did)] for did in batch.drug_ids], dtype=np.int64) + + def _fit(self, batch: ModelInputBatch) -> None: + """Train the DrugGNN model on the batch. + + :param batch: Training batch with gene_expression and drug_graph blocks. + :raises ValueError: If response data or drug features are missing. + """ + if batch.response is None: + msg = "DrugGNN requires training response data" + raise ValueError(msg) + + cell_line_matrix = batch.cell_line_blocks["gene_expression"].values + drug_graphs = batch.drug_blocks["drug_graph"].values + + self._model = self._build_model(cell_line_matrix, drug_graphs) + train_loader = self._graph_loader( + batch.cell_line_pair_idx, + self._resolve_drug_pair_idx(batch), + cell_line_matrix, + drug_graphs, + batch.response, + batch_size=int(self._hyperparameters.get("batch_size", 1024)), + shuffle=True, + ) + val_loader = self._build_val_loader(batch, cell_line_matrix, drug_graphs) + self._run_training(train_loader, val_loader) + + def _build_model(self, cell_line_matrix: np.ndarray, drug_graphs: np.ndarray) -> DrugGNNModule: + """Instantiate the Lightning module and record the widths it was built for. + + :param cell_line_matrix: Cell-line feature matrix. + :param drug_graphs: Array of drug graph objects. + :returns: An untrained ``DrugGNNModule``. + """ + from drevalpy.components.predictors.literature.druggnn.algorithm import DrugGNNModule + + self._num_node_features = int(drug_graphs[0].num_node_features) + self._num_cell_features = int(cell_line_matrix.shape[1]) + return DrugGNNModule( + num_node_features=self._num_node_features, + num_cell_features=self._num_cell_features, + hidden_dim=int(self._hyperparameters.get("hidden_dim", 64)), + dropout=float(self._hyperparameters.get("dropout", 0.2)), + learning_rate=float(self._hyperparameters.get("learning_rate", 0.001)), + ) + + def _run_training(self, train_loader: DataLoader, val_loader: DataLoader | None) -> None: + """Fit the model, early-stopping on the validation loss when one is available. + + :param train_loader: Training loader. + :param val_loader: Validation loader, or ``None``. + """ + import pytorch_lightning as pl + + callbacks: list[pl.callbacks.Callback] | None = None + if val_loader is not None: + callbacks = [pl.callbacks.EarlyStopping(monitor="val_loss", mode="min", patience=5)] + + trainer = pl.Trainer( + max_epochs=int(self._hyperparameters.get("epochs", 100)), + accelerator="auto", + devices="auto", + callbacks=callbacks, + logger=False, + enable_progress_bar=True, + log_every_n_steps=int(self._hyperparameters.get("log_every_n_steps", 50)), + precision=self._hyperparameters.get("precision", 32), + ) + trainer.fit(self._model, train_dataloaders=train_loader, val_dataloaders=val_loader) + + @staticmethod + def _graph_loader( + cell_line_pair_idx: np.ndarray, + drug_pair_idx: np.ndarray, + cell_line_matrix: np.ndarray, + drug_graphs: np.ndarray, + response: np.ndarray, + *, + batch_size: int, + shuffle: bool = False, + ) -> DataLoader: + """Build one graph loader over the given pairs. + + :param cell_line_pair_idx: Cell-line row per pair. + :param drug_pair_idx: Drug row per pair. + :param cell_line_matrix: Cell-line feature matrix. + :param drug_graphs: Array of drug graph objects. + :param response: Pair-level response values. + :param batch_size: Mini-batch size. + :param shuffle: Whether to shuffle each epoch. + :returns: A ``torch_geometric`` loader. + """ + from torch_geometric.loader import DataLoader + + dataset = _DrugGNNDataset( + cell_line_pair_idx=cell_line_pair_idx, + drug_pair_idx=drug_pair_idx, + cell_line_matrix=cell_line_matrix, + drug_graphs=drug_graphs, + response=response, + ) + return DataLoader(dataset, batch_size=batch_size, shuffle=shuffle, num_workers=0, pin_memory=True) + + def _build_val_loader( + self, + batch: ModelInputBatch, + cell_line_matrix: np.ndarray, + drug_graphs: np.ndarray, + ) -> DataLoader | None: + """Build a validation DataLoader from the early-stopping response if available. + + :param batch: Full training batch (for entity ID lookups). + :param cell_line_matrix: Cell-line feature matrix. + :param drug_graphs: Array of drug graph objects. + :returns: Validation DataLoader or None if no early-stopping data. + """ + es = batch.early_stopping_response + if es is None or len(es) == 0: + return None + + cl_entity_map = {str(eid): i for i, eid in enumerate(batch.cell_line_entity_ids)} + drug_entity_map: dict[str, int] = {} + if batch.drug_entity_ids is not None: + drug_entity_map = {str(eid): i for i, eid in enumerate(batch.drug_entity_ids)} + + return self._graph_loader( + np.array([cl_entity_map[str(cid)] for cid in es.cell_line_ids], dtype=np.int64), + np.array([drug_entity_map[str(did)] for did in es.drug_ids], dtype=np.int64), + cell_line_matrix, + drug_graphs, + es.response, + batch_size=int(self._hyperparameters.get("batch_size", 32)), + ) + + # ------------------------------------------------------------------ + # Predict + # ------------------------------------------------------------------ + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict responses for the given batch. + + :param batch: Featurized pairs to score. + :returns: One predicted response per pair. + :raises RuntimeError: If the model has not been trained yet. + """ + import pytorch_lightning as pl + import torch + + if self._model is None: + raise RuntimeError("Model has not been trained yet.") + if batch.n_pairs == 0: + return np.array([]) + + predict_loader = self._graph_loader( + batch.cell_line_pair_idx, + self._resolve_drug_pair_idx(batch), + batch.cell_line_blocks["gene_expression"].values, + batch.drug_blocks["drug_graph"].values, + np.zeros(batch.n_pairs, dtype=np.float32), + batch_size=int(self._hyperparameters.get("batch_size", 32)), + ) + + trainer = pl.Trainer(accelerator="auto", devices="auto", enable_progress_bar=False, logger=False) + predictions_list = trainer.predict(self._model, dataloaders=predict_loader) + + if not predictions_list: + return np.array([]) + + predictions_flat = [ + item for sublist in predictions_list for item in (sublist if isinstance(sublist, list) else [sublist]) + ] + return torch.cat(predictions_flat).cpu().numpy() + + # ------------------------------------------------------------------ + # State serialization + # ------------------------------------------------------------------ + + def is_fitted(self) -> bool: + """Return whether the predictor has been fit. + + :returns: True when model has been trained. + """ + return self._model is not None + + def get_state(self) -> dict[str, object]: + """Serialize fitted predictor state. + + :returns: Mapping with binary payload blob and architecture metadata. + """ + if not self.is_fitted(): + return {} + if self._model is None: + return {} + return { + "model_state": save_state_dict(self._model.state_dict()), + "architecture": { + "num_node_features": self._num_node_features, + "num_cell_features": self._num_cell_features, + "hidden_dim": int(self._hyperparameters.get("hidden_dim", 64)), + "dropout": float(self._hyperparameters.get("dropout", 0.2)), + "learning_rate": float(self._hyperparameters.get("learning_rate", 0.001)), + }, + "hyperparameters": dict(self._hyperparameters), + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore predictor from get_state output. + + :param state: Serialized state previously returned by get_state. + :raises PredictorStateError: If payload is missing or invalid. + """ + from drevalpy.components.predictors.literature.druggnn.algorithm import DrugGNNModule + + model_state_blob = state.get("model_state") + if not isinstance(model_state_blob, (bytes, bytearray)): + msg = f"{self.__class__.__name__} state requires model_state bytes" + raise PredictorStateError(msg) + + architecture = state.get("architecture") + if not isinstance(architecture, dict): + msg = f"{self.__class__.__name__} state requires architecture dict" + raise PredictorStateError(msg) + + hyperparameters = state.get("hyperparameters") + if isinstance(hyperparameters, dict): + self._hyperparameters = dict(hyperparameters) + + self._num_node_features = int(architecture["num_node_features"]) + self._num_cell_features = int(architecture["num_cell_features"]) + + self._model = DrugGNNModule( + num_node_features=self._num_node_features, + num_cell_features=self._num_cell_features, + hidden_dim=int(architecture.get("hidden_dim", 64)), + dropout=float(architecture.get("dropout", 0.2)), + learning_rate=float(architecture.get("learning_rate", 0.001)), + ) + self._model.load_state_dict(load_state_dict(bytes(model_state_blob))) diff --git a/drevalpy/models/MOLIR/__init__.py b/drevalpy/components/predictors/literature/molir/__init__.py similarity index 100% rename from drevalpy/models/MOLIR/__init__.py rename to drevalpy/components/predictors/literature/molir/__init__.py diff --git a/drevalpy/components/predictors/literature/molir/_omics.py b/drevalpy/components/predictors/literature/molir/_omics.py new file mode 100644 index 000000000..53710718a --- /dev/null +++ b/drevalpy/components/predictors/literature/molir/_omics.py @@ -0,0 +1,36 @@ +"""Lightning-free omics helpers for the MOLIR/SuperFELTR predictors. + +Split out of :mod:`drevalpy.components.predictors.literature.molir.utils` so the +registered ``predictor.py`` modules can reach the column-alignment helper without +importing ``pytorch_lightning`` at module scope. ``utils.py`` re-exports the +symbol, so the historical import path keeps working. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np + + +def _realign_omic_matrix( + values: np.ndarray, + model_features: Sequence[str] | np.ndarray, + meta_feature_names: Sequence[str] | np.ndarray, +) -> np.ndarray: + """Align prediction-time omics columns to the feature order stored on the trained model. + + :param values: Omic feature matrix in incoming column order. + :param model_features: Feature names used by the trained model. + :param meta_feature_names: Feature names available in the incoming data. + + :returns: Matrix with columns reordered to match *model_features*. + """ + if values.shape[1] == len(model_features): + return values + realigned = np.zeros((values.shape[0], len(model_features))) + lookup_table = {feature: i for i, feature in enumerate(meta_feature_names)} + for column, feature in enumerate(model_features): + if feature in lookup_table: + realigned[:, column] = values[:, lookup_table[feature]] + return realigned diff --git a/drevalpy/components/predictors/literature/molir/predictor.py b/drevalpy/components/predictors/literature/molir/predictor.py new file mode 100644 index 000000000..7fda6448a --- /dev/null +++ b/drevalpy/components/predictors/literature/molir/predictor.py @@ -0,0 +1,259 @@ +"""MOLIR literature predictor consuming ModelInputBatch directly.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.literature._metadata import MOLIR_REFERENCE +from drevalpy.components.predictors.literature._single_drug_omics import ( + OmicFeatureNames, + aligned_pair_matrices, + feature_names_from_payload, + feature_names_payload, + iter_drug_subsets, + omic_feature_names, + omic_matrices, + validation_split, +) +from drevalpy.components.predictors.single_drug_routing import ( + require_known_training_keys, + routing_keys, +) +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.enums.model_scope import ModelScope +from drevalpy.utils.torch_io import load_state_dict as _load_torch_state_dict +from drevalpy.utils.torch_io import ( + load_trusted_mapping, + save_state_dict, + save_trusted_mapping, +) + +# MOLIModel lives in .utils, which imports pytorch_lightning at module scope. This +# module is imported eagerly by register_builtin_components(), so the import is +# deferred to the methods that construct a model - keeping the training stack off +# the critical path of `import drevalpy`. Guarded by tests/test_import_cost_policy.py. +if TYPE_CHECKING: + from drevalpy.components.predictors.literature.molir.utils import MOLIModel + + +@register( + "molir", + description="MOLIR single-drug multi-omics model.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + reference=MOLIR_REFERENCE, +) +class MOLIRPredictor(BlockPredictor): + """MOLIR predictor: per-drug multi-omics late integration model.""" + + scope: ClassVar[ModelScope] = ModelScope.SINGLE_DRUG + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ( + "gene_expression", + "mutations", + "copy_number_variation_gistic", + ) + required_drug_blocks: ClassVar[tuple[str, ...]] = ("identity",) + required_cell_line_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("mutations", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("copy_number_variation_gistic", FeatureFormat.NUMERIC_MATRIX), + ) + required_drug_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("identity", FeatureFormat.NUMERIC_MATRIX),) + validate_drug_graphs: ClassVar[bool] = False + supports_early_stopping: ClassVar[bool] = True + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize predictor with optional hyperparameter overrides. + + :param hyperparameters: Optional hyperparameter overrides. + """ + super().__init__(hyperparameters) + self._models: dict[str, MOLIModel] = {} + self._feature_names: dict[str, OmicFeatureNames] = {} + + def _fit(self, batch: ModelInputBatch) -> None: + """Train per-drug MOLIR models. + + :param batch: Training batch with all required cell-line omics blocks. + """ + require_known_training_keys(routing_keys(batch)) + self._models = {} + self._feature_names = {} + + for drug_id, sub in iter_drug_subsets(batch): + self._fit_single_drug(drug_id, sub) + + def _fit_single_drug(self, drug_id: str, batch: ModelInputBatch) -> None: + """Fit a single-drug MOLIR model. + + :param drug_id: Identifier of the drug to train on. + :param batch: Subset batch for this drug. + """ + self._feature_names[drug_id] = omic_feature_names(batch) + if batch.n_pairs == 0: + return + + matrices = omic_matrices(batch) + model = self._build_model(matrices.widths()) + + if batch.n_pairs >= self._hyperparameters["mini_batch"]: + model.fit( + train=matrices.split(batch.cell_line_pair_idx, np.asarray(batch.response, dtype=np.float32)), + val=validation_split(matrices, batch), + model_checkpoint_dir=str(batch.training_context.checkpoint_dir), + ) + + self._models[drug_id] = model + + def _build_model(self, widths: tuple[int, int, int]) -> MOLIModel: + """Construct an untrained MOLIR model sized to the three omic views. + + :param widths: Feature widths of the expression, mutation and CNV views. + :returns: The initialized model. + """ + from drevalpy.components.predictors.literature.molir.utils import MOLIModel + + return MOLIModel( + hpams=dict(self._hyperparameters), + input_dim_expr=widths[0], + input_dim_mut=widths[1], + input_dim_cnv=widths[2], + ) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict drug responses for pairs, routing to per-drug models. + + :param batch: Featurized pairs to score. + :returns: One predicted response per pair. + """ + keys = routing_keys(batch) + predictions = np.full(batch.n_pairs, np.nan, dtype=np.float64) + for drug_id in np.unique(keys): + if drug_id == "": + continue + model = self._models.get(str(drug_id)) + if model is None: + continue + mask = keys == drug_id + sub = batch.subset_pairs(mask) + preds = self._predict_single_drug(str(drug_id), model, sub) + predictions[mask] = np.asarray(preds, dtype=np.float64).ravel() + return predictions + + def _predict_single_drug(self, drug_id: str, model: MOLIModel, batch: ModelInputBatch) -> np.ndarray: + """Predict for a single drug model. + + :param drug_id: Drug identifier for feature alignment. + :param model: Trained MOLI model instance. + :param batch: Subset batch for this drug. + :returns: Predicted responses. + """ + feature_names = self._feature_names.get(drug_id) + if feature_names is None: + return np.full(batch.n_pairs, np.nan) + + return np.atleast_1d(model.predict(*aligned_pair_matrices(batch, feature_names))) + + def is_fitted(self) -> bool: + """Report whether trained models exist. + + :returns: True when at least one per-drug model has been fitted. + """ + return bool(self._models) + + def get_state(self) -> dict[str, object]: + """Serialize fitted state for all per-drug models. + + :returns: Mapping with algorithm blobs and hyperparameters. + """ + if not self._models: + return {} + algorithms: dict[str, bytes] = {} + for drug_id, model in self._models.items(): + payload: dict[str, Any] = { + "hyperparameters": dict(self._hyperparameters), + "model_state": save_state_dict(model.state_dict()), + "input_dims": { + "expr": model.expression_encoder.encode[0].in_features, + "mut": model.mutation_encoder.encode[0].in_features, + "cnv": model.cna_encoder.encode[0].in_features, + }, + **feature_names_payload(self._feature_names.get(drug_id)), + } + algorithms[drug_id] = save_trusted_mapping(payload) + return { + "algorithms": algorithms, + "predictor_hyperparameters": dict(self._hyperparameters), + } + + def set_state(self, state: dict[str, object]) -> None: + """Restore fitted state from serialized algorithm blobs. + + :param state: State mapping from ``get_state``. + :raises PredictorStateError: If state is malformed. + """ + algorithms_blob = state.get("algorithms") + if not isinstance(algorithms_blob, dict): + msg = "MOLIRPredictor state requires an 'algorithms' mapping" + raise PredictorStateError(msg) + hyperparameters = state.get("predictor_hyperparameters") + if isinstance(hyperparameters, dict): + self._hyperparameters = dict(hyperparameters) + + self._models = {} + self._feature_names = {} + for drug_id, blob in algorithms_blob.items(): + if not isinstance(blob, (bytes, bytearray)): + msg = f"MOLIRPredictor algorithm payload for {drug_id!r} must be bytes" + raise PredictorStateError(msg) + payload = load_trusted_mapping(bytes(blob)) + self._feature_names[str(drug_id)] = feature_names_from_payload(payload) + self._models[str(drug_id)] = self._restore_model(payload) + + def _restore_model(self, payload: dict[str, Any]) -> MOLIModel: + """Rebuild one per-drug model from its deserialized payload. + + :param payload: Deserialized per-drug payload. + :returns: The restored model. + """ + from drevalpy.components.predictors.literature.molir.utils import MOLIModel + + input_dims = payload.get("input_dims", {}) + model = MOLIModel( + hpams=payload.get("hyperparameters", dict(self._hyperparameters)), + input_dim_expr=input_dims["expr"], + input_dim_mut=input_dims["mut"], + input_dim_cnv=input_dims["cnv"], + ) + model_state_bytes = payload.get("model_state") + if isinstance(model_state_bytes, (bytes, bytearray)): + model.load_state_dict(_load_torch_state_dict(bytes(model_state_bytes))) + return model + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Return default hyperparameters. + + :returns: Default hyperparameter mapping. + """ + return { + "mini_batch": 32, + "h_dim1": 64, + "h_dim2": 64, + "h_dim3": 64, + "learning_rate": 0.01, + "dropout_rate": 0.5, + "weight_decay": 0.0001, + "gamma": 0.5, + "epochs": 30, + "margin": 1.5, + } diff --git a/drevalpy/models/MOLIR/utils.py b/drevalpy/components/predictors/literature/molir/utils.py similarity index 52% rename from drevalpy/models/MOLIR/utils.py rename to drevalpy/components/predictors/literature/molir/utils.py index c7e9e9576..d33b9fb9a 100644 --- a/drevalpy/models/MOLIR/utils.py +++ b/drevalpy/components/predictors/literature/molir/utils.py @@ -1,69 +1,22 @@ -""" -Utility functions for the MOLIR model. +"""Utility functions for the MOLIR model. Original authors of MOLI: Sharifi-Noghabi et al. (2019, 10.1093/bioinformatics/btz318) Code adapted from: Hauptmann et al. (2023, 10.1186/s12859-023-05166-7), https://github.com/kramerlab/Multi-Omics_analysis """ -import os -import secrets - import numpy as np import pytorch_lightning as pl import torch -from pytorch_lightning.callbacks import EarlyStopping, TQDMProgressBar from torch import nn -from torch.utils.data import DataLoader, Dataset - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset - -from ..drp_model import DRPModel -from ..lightning_metrics_mixin import RegressionMetricsMixin - - -class RegressionDataset(Dataset): - """Dataset for regression tasks for the data loader.""" - - def __init__( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - ) -> None: - """ - Initializes the dataset by setting the output and the cell line input. +from upath import UPath as Path - :param output: drug response dataset - :param cell_line_input: omics features of the cell lines - """ - self.output = output - self.cell_line_input = cell_line_input - - def __getitem__(self, idx: int) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.float32]: - """ - Overwrites the getitem method. - - :param idx: index of the sample - :returns: gene expression, mutations, copy number variation, and response of the sample as numpy arrays - """ - response: np.float32 = np.float32(self.output.response[idx]) - - cell_line_id = str(self.output.cell_line_ids[idx]) - gene_expression: np.ndarray = self.cell_line_input.features[cell_line_id]["gene_expression"].astype(np.float32) - mutations: np.ndarray = self.cell_line_input.features[cell_line_id]["mutations"].astype(np.float32) - copy_number: np.ndarray = self.cell_line_input.features[cell_line_id]["copy_number_variation_gistic"].astype( - np.float32 - ) - - return gene_expression, mutations, copy_number, response - - def __len__(self) -> int: - """ - Overwrites the len method. - - :returns: number of samples in the dataset - """ - return len(self.output.response) +from drevalpy.components.predictors.literature._lightning_training import LightningRun, run_lightning_fit +from drevalpy.components.predictors.literature._omics_loaders import OmicsSplit, make_omics_loaders +from drevalpy.components.predictors.literature.molir._omics import ( + _realign_omic_matrix as _realign_omic_matrix, # re-exported for the historical import path +) +from drevalpy.utils.torch_io import load_state_dict def generate_triplets_indices( @@ -72,8 +25,7 @@ def generate_triplets_indices( negative_range: float, random_seed: int | None = None, ) -> tuple[np.ndarray, np.ndarray]: - """ - Generates triplets for the MOLIR model. + """Generates triplets for the MOLIR model. The positive and negative range are determined by the standard deviation of the response values. A sample is considered positive if its response value is within the positive range of the label. The positive range is ±10% @@ -84,7 +36,8 @@ def generate_triplets_indices( :param positive_range: positive range for the triplet loss :param negative_range: negative range for the triplet loss :param random_seed: random seed for reproducibility - :returns: positive and negative sample indices for each sample + + :returns: returns: positive and negative sample indices for each sample """ rng = np.random.default_rng(random_seed) positive_sample_indices = [] @@ -101,15 +54,16 @@ def generate_triplets_indices( def _get_positive_class_indices(label: np.float32, idx_label: int, y: np.ndarray, positive_range: float) -> np.ndarray: - """ - Find the samples that are within the positive range of the label except the label itself. + """Find the samples that are within the positive range of the label except the label itself. If there is no similar sample within the positive range, the method returns the closest sample to the label. + :param label: response of interest :param idx_label: index of the response of interest :param y: all responses :param positive_range: 0.1 * the standard deviation of all training responses - :returns: indices of the samples that can be considered positive examples (=similar to the response of interest) + + :returns: Indices of samples considered positive examples for the response of interest. """ indices_similar_samples = np.where(np.logical_and(label - positive_range <= y, y <= label + positive_range))[0] indices_similar_samples = np.delete(indices_similar_samples, np.where(indices_similar_samples == idx_label)) @@ -123,14 +77,15 @@ def _get_positive_class_indices(label: np.float32, idx_label: int, y: np.ndarray def _get_negative_class_indices(label: np.float32, y: np.ndarray, negative_range: float) -> np.ndarray: - """ - Finds dissimilar samples to the label. + """Finds dissimilar samples to the label. If there is no dissimilar sample within the negative range, the method returns the sample that is the furthest away. + :param label: reponse of interest :param y: all responses :param negative_range: 1 * the standard deviation of all training responses - :returns: indices of the samples that can be considered negative examples (=dissimilar to the response of interest) + + :returns: Indices of samples considered negative examples for the response of interest. """ dissimilar_samples = np.where(np.logical_or(label - negative_range >= y, y >= label + negative_range))[0] if len(dissimilar_samples) == 0: @@ -139,129 +94,15 @@ def _get_negative_class_indices(label: np.float32, y: np.ndarray, negative_range return dissimilar_samples -def make_ranges(output: DrugResponseDataset) -> tuple[float, float]: - """ - Compute the positive and negative range for the triplet loss. - - :param output: drug response dataset - :returns: positive and negative range for the triplet loss - """ - positive_range = float(np.std(output.response) * 0.1) - negative_range = float(np.std(output.response)) - return positive_range, negative_range - - -def create_dataset_and_loaders( - batch_size: int, - output_train: DrugResponseDataset, - cell_line_input: FeatureDataset, - output_earlystopping: DrugResponseDataset | None = None, -) -> tuple[DataLoader, DataLoader | None]: - """ - Creates the RegressionDataset (torch Dataset) and the DataLoader for the training and validation data. - - :param batch_size: specified batch size - :param output_train: response values for the training data - :param cell_line_input: omic input features of the cell lines - :param output_earlystopping: early stopping dataset - :returns: training and validation data loaders - """ - train_dataset = RegressionDataset(output_train, cell_line_input) - train_loader = DataLoader( - train_dataset, - batch_size=batch_size, - shuffle=False, - num_workers=1 if os.name == "nt" else 4, # multiprocessing on Windows is not supported - persistent_workers=True, - drop_last=True, # avoids batch norm errors if last batch < batch_size - ) - - val_loader = None - if output_earlystopping is not None: - val_dataset = RegressionDataset( - output=output_earlystopping, - cell_line_input=cell_line_input, - ) - val_loader = DataLoader( - val_dataset, - batch_size=batch_size, - shuffle=False, - num_workers=1, - persistent_workers=True, - ) - return train_loader, val_loader - - -def get_dimensions_of_omics_data(cell_line_input: FeatureDataset) -> tuple[int, int, int]: - """ - Determines the dimensions of the omics data for the creation of the input layers. - - :param cell_line_input: omic input features of the cell lines - :returns: dimensions of the gene expression, mutations, and copy number variation data - """ - first_item = next(iter(cell_line_input.features.values())) - dim_gex = first_item["gene_expression"].shape[0] - dim_mut = first_item["mutations"].shape[0] - dim_cnv = first_item["copy_number_variation_gistic"].shape[0] - return dim_gex, dim_mut, dim_cnv - - -def filter_and_sort_omics( - model: DRPModel, # MOLIR or SuperFELTR - gene_expression: np.ndarray, - mutations: np.ndarray, - cnvs: np.ndarray, - cell_line_input: FeatureDataset, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """ - Filters out features that were not present during training and imputes missing features with zeros. - - This is necessary because the feature order might have changed or more features are available (cross-study setting). - - :param model: either MOLIR or SuperFELTR self - :param gene_expression: new gene expression data from which to predict - :param mutations: new mutation data from which to predict - :param cnvs: new copy number variation data from which to predict - :param cell_line_input: needed for meta information (feature names) - :return: filtered and sorted gene expression, mutations, and copy number variation data - """ - for key, features in { - "gene_expression": model.gene_expression_features, # type: ignore - "mutations": model.mutations_features, # type: ignore - "copy_number_variation_gistic": model.copy_number_variation_features, # type: ignore - }.items(): - if key == "gene_expression": - values = gene_expression - elif key == "mutations": - values = mutations - else: - values = cnvs - if values.shape[1] != len(features): - new_value = np.zeros((values.shape[0], len(features))) - lookup_table = {feature: i for i, feature in enumerate(cell_line_input.meta_info[key])} - for i, feature in enumerate(features): - if feature in lookup_table: - new_value[:, i] = values[:, lookup_table[feature]] - if key == "gene_expression": - gene_expression = new_value - elif key == "mutations": - mutations = new_value - else: - cnvs = new_value - return gene_expression, mutations, cnvs - - class MOLIEncoder(nn.Module): - """ - Encoders of the MOLIR model, which is identical to the encoders of the original MOLI model. + """Encoders of the MOLIR model, which is identical to the encoders of the original MOLI model. The MOLIR model has three encoders for the gene expression, mutations, and copy number variation data which are trained together. """ def __init__(self, input_size: int, output_size: int, dropout_rate: float) -> None: - """ - Initializes the encoder for the MOLIR model. + """Initializes the encoder for the MOLIR model. :param input_size: input size determined by feature selection. :param output_size: output size of the encoder, set as hyperparameter. @@ -276,26 +117,24 @@ def __init__(self, input_size: int, output_size: int, dropout_rate: float) -> No ) def forward(self, x: torch.Tensor) -> torch.Tensor: - """ - Forward pass of the encoder. + """Forward pass of the encoder. :param x: omic input features - :returns: encoded omic features + + :returns: returns: encoded omic features """ return self.encode(x) class MOLIRegressor(nn.Module): - """ - Regressor of the MOLIR model. + """Regressor of the MOLIR model. It is identical to the regressor of the original MOLI model, except for the omission of the final sigmoid activation function. After the three encoders, the encoded features are concatenated and fed into the regressor. """ def __init__(self, input_size: int, dropout_rate: float) -> None: - """ - Initializes the regressor for the MOLIR model. + """Initializes the regressor for the MOLIR model. :param input_size: determined by the output sizes of the encoders. :param dropout_rate: set as hyperparameter. @@ -304,18 +143,17 @@ def __init__(self, input_size: int, dropout_rate: float) -> None: self.regressor = nn.Sequential(nn.Linear(input_size, 1), nn.Dropout(dropout_rate)) def forward(self, x: torch.Tensor) -> torch.Tensor: - """ - Forward pass of the regressor. + """Forward pass of the regressor. :param x: concatenated encoded features - :returns: predicted drug response + + :returns: returns: predicted drug response """ return self.regressor(x) -class MOLIModel(RegressionMetricsMixin, pl.LightningModule): - """ - PyTorch Lightning module for the MOLIR model. +class MOLIModel(pl.LightningModule): + """PyTorch Lightning module for the MOLIR model. The architecture of the MOLIR model is identical to the MOLI model, except for the omission of the final sigmoid layer and the usage of a regression MSE loss instead of a binary cross-entropy loss. Additionally, early stopping is @@ -325,14 +163,13 @@ class MOLIModel(RegressionMetricsMixin, pl.LightningModule): def __init__( self, hpams: dict[str, int | float], input_dim_expr: int, input_dim_mut: int, input_dim_cnv: int ) -> None: - """ - Initializes the MOLIR model. + """Initializes the MOLIR model. The MOLIR model uses a combined loss function of a triplet margin loss for the concatenated representation and an MSE loss for the regression loss. - :param hpams: includes mini_batch, layer dimensions (h_dim1, h_dim2, h_dim3), learning_rate, dropout_rate, - weight decay, gamma, epochs, and margin. + :param hpams: Hyperparameters such as ``mini_batch``, layer sizes, learning rate, and + margin. :param input_dim_expr: determined by the feature selection of the gene expression data. :param input_dim_mut: determined by dataset size :param input_dim_cnv: determined by dataset size @@ -363,84 +200,48 @@ def __init__( self.cna_encoder = MOLIEncoder(input_dim_cnv, self.h_dim3, self.dropout_rate) self.regressor = MOLIRegressor(self.h_dim1 + self.h_dim2 + self.h_dim3, self.dropout_rate) - # Initialize metrics storage for epoch-end R^2 and PCC computation - self._init_metrics_storage() - def fit( self, - output_train: DrugResponseDataset, - cell_line_input: FeatureDataset, - output_earlystopping: DrugResponseDataset | None = None, + train: OmicsSplit, + val: OmicsSplit | None = None, patience: int = 5, - model_checkpoint_dir: str = "checkpoints", + model_checkpoint_dir: str | Path = "checkpoints", wandb_project: str | None = None, ) -> None: - """ - Trains the MOLIR model. + """Trains the MOLIR model. First, the ranges for the triplet loss are determined using the standard deviation of the training responses. Then, the training and validation data loaders are created. The model is trained using the Lightning Trainer - with an early stopping callback and patience of 5. + with an early stopping callback. - :param output_train: training dataset containing the response output - :param cell_line_input: feature dataset containing the omics data of the cell lines - :param output_earlystopping: early stopping dataset + :param train: training split of the three omic views + :param val: validation split for early stopping, or None to monitor the training loss :param patience: for early stopping :param model_checkpoint_dir: directory to save the model checkpoints - :param wandb_project: optional wandb project name for logging. If provided, uses WandbLogger - for PyTorch Lightning training. - """ - self.positive_range, self.negative_range = make_ranges(output_train) - - train_loader, val_loader = create_dataset_and_loaders( - batch_size=self.mini_batch, - output_train=output_train, - cell_line_input=cell_line_input, - output_earlystopping=output_earlystopping, - ) - - # Train the model - monitor = "train_loss" if (val_loader is None) else "val_loss" - - early_stop_callback = EarlyStopping(monitor=monitor, mode="min", patience=patience) - name = "version-" + "".join( - [secrets.choice("0123456789abcdef") for _ in range(20)] - ) # preventing conflicts of filenames - self.checkpoint_callback = pl.callbacks.ModelCheckpoint( - dirpath=os.path.join(model_checkpoint_dir, name), - monitor=monitor, - mode="min", - save_top_k=1, - save_weights_only=True, - ) - - # Set up wandb logger if project is provided - loggers = [] - if wandb_project is not None: - from pytorch_lightning.loggers import WandbLogger - - logger = WandbLogger(project=wandb_project, log_model=False) - loggers.append(logger) - - # Initialize the Lightning trainer - trainer = pl.Trainer( - max_epochs=self.epochs, - logger=loggers if loggers else True, # Use default logger if no wandb - callbacks=[ - early_stop_callback, - self.checkpoint_callback, - TQDMProgressBar(refresh_rate=0), - ], - devices=1, - enable_model_summary=False, + :param wandb_project: Optional Weights & Biases project name for Lightning logging. + """ + std = float(np.std(train.response)) + self.positive_range = std * 0.1 + self.negative_range = std + + train_loader, val_loader = make_omics_loaders(train, val, self.mini_batch) + + self.checkpoint_callback = run_lightning_fit( + self, + train_loader, + val_loader, + LightningRun( + max_epochs=self.epochs, + patience=patience, + checkpoint_dir=model_checkpoint_dir, + wandb_project=wandb_project, + save_weights_only=True, + devices=1, + enable_model_summary=False, + ), ) - if val_loader is None: - trainer.fit(self, train_loader) - else: - trainer.fit(self, train_loader, val_loader) - # load best model if self.checkpoint_callback.best_model_path is not None: - checkpoint = torch.load(self.checkpoint_callback.best_model_path, weights_only=True) # noqa: S614 + checkpoint = load_state_dict(self.checkpoint_callback.best_model_path) self.load_state_dict(checkpoint["state_dict"]) def predict( @@ -449,8 +250,7 @@ def predict( mutations: np.ndarray, copy_number: np.ndarray, ) -> np.ndarray: - """ - Perform prediction on given input data. + """Perform prediction on given input data. If there was enough training data to train the model, the model from the best epoch was saved in the checkpoint callback and is loaded now. If there was not enough training data, the model is only randomly initialized. @@ -458,7 +258,8 @@ def predict( :param gene_expression: gene expression data :param mutations: mutation data :param copy_number: copy number variation data - :returns: predicted drug response + + :returns: returns: predicted drug response """ # convert to torch tensors gene_expression_tensor = torch.from_numpy(gene_expression).float().to(self.device) @@ -473,13 +274,13 @@ def predict( def _encode_and_concatenate( self, gene_expression: torch.Tensor, mutations: torch.Tensor, copy_number: torch.Tensor ) -> torch.Tensor: - """ - Encodes the input modalities, concatenates, and normalizes the resulting embeddings. + """Encodes the input modalities, concatenates, and normalizes the resulting embeddings. :param gene_expression: gene expression data :param mutations: mutation data :param copy_number: copy number variation data - :returns: concatenated, normalized embeddings + + :returns: returns: concatenated, normalized embeddings """ z_ex = self.expression_encoder(gene_expression) z_mu = self.mutation_encoder(mutations) @@ -490,26 +291,26 @@ def _encode_and_concatenate( return z def forward(self, x_gene: torch.Tensor, x_mutation: torch.Tensor, x_cna: torch.Tensor) -> torch.Tensor: - """ - Forward pass of the MOLIR model. + """Forward pass of the MOLIR model. :param x_gene: gene expression input :param x_mutation: mutation input :param x_cna: copy number variation input - :returns: predicted drug response + + :returns: returns: predicted drug response """ z = self._encode_and_concatenate(x_gene, x_mutation, x_cna) preds = self.regressor(z) return preds def _compute_loss(self, z: torch.Tensor, preds: torch.Tensor, y: torch.Tensor) -> torch.Tensor: - """ - Computes the combined triplet loss and regression loss. + """Computes the combined triplet loss and regression loss. :param z: concatenated, normalized embeddings on which the triplet loss is calculated :param preds: predicted drug response on which the regression loss is calculated :param y: true drug response - :returns: combined loss + + :returns: returns: combined loss """ positive_indices, negative_indices = generate_triplets_indices( y.cpu().detach().numpy(), self.positive_range, self.negative_range @@ -520,14 +321,15 @@ def _compute_loss(self, z: torch.Tensor, preds: torch.Tensor, y: torch.Tensor) - return triplet_loss + regression_loss def training_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Tensor: - """ - Training step of the MOLIR model. + """Training step of the MOLIR model. :param batch: batch of gene expression, mutations, copy number variation, and response :param batch_idx: index of the batch - :returns: combined loss + + :returns: returns: combined loss """ gene_expression, mutations, copy_number, response = batch + response = response.squeeze(-1) # Encode and concatenate z = self._encode_and_concatenate(gene_expression, mutations, copy_number) @@ -539,20 +341,18 @@ def training_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Tens loss = self._compute_loss(z, preds, response) self.log("train_loss", loss, on_step=False, on_epoch=True, prog_bar=True) - # Store predictions and targets for epoch-end metrics via mixin - self._store_predictions(preds, response, is_training=True) - return loss def validation_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Tensor: - """ - Validation step of the MOLIR model. + """Validation step of the MOLIR model. :param batch: batch of gene expression, mutations, copy number variation, and response :param batch_idx: index of the batch - :returns: combined loss + + :returns: returns: combined loss """ gene_expression, mutations, copy_number, response = batch + response = response.squeeze(-1) # Encode and concatenate z = self._encode_and_concatenate(gene_expression, mutations, copy_number) @@ -564,16 +364,12 @@ def validation_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Te val_loss = self._compute_loss(z, preds, response) self.log("val_loss", val_loss, on_step=False, on_epoch=True, prog_bar=True) - # Store predictions and targets for epoch-end metrics via mixin - self._store_predictions(preds, response, is_training=False) - return val_loss def configure_optimizers(self) -> torch.optim.Optimizer: - """ - Overwrites the configure_optimizers method from PyTorch Lightning. + """Overwrites the configure_optimizers method from PyTorch Lightning. - :returns: optimizers for the MOLIR expression, mutation, copy number variation encoders, and regressor + :returns: returns: optimizers for the MOLIR expression, mutation, copy number variation encoders, and regressor """ optimizer = torch.optim.Adagrad( [ diff --git a/drevalpy/components/predictors/literature/pharmaformer/__init__.py b/drevalpy/components/predictors/literature/pharmaformer/__init__.py new file mode 100644 index 000000000..98ea7c571 --- /dev/null +++ b/drevalpy/components/predictors/literature/pharmaformer/__init__.py @@ -0,0 +1 @@ +"""PharmaFormer literature algorithm package.""" diff --git a/drevalpy/models/PharmaFormer/model_utils.py b/drevalpy/components/predictors/literature/pharmaformer/model_utils.py similarity index 89% rename from drevalpy/models/PharmaFormer/model_utils.py rename to drevalpy/components/predictors/literature/pharmaformer/model_utils.py index a8e632fe6..db88a2fef 100644 --- a/drevalpy/models/PharmaFormer/model_utils.py +++ b/drevalpy/components/predictors/literature/pharmaformer/model_utils.py @@ -9,8 +9,7 @@ class FeatureExtractor(nn.Module): """Feature extractor for gene expression and drug SMILES.""" def __init__(self, gene_input_size: int, gene_hidden_size: int, drug_hidden_size: int): - """ - Initialize the feature extractor. + """Initialize the feature extractor. :param gene_input_size: Input size for gene expression features :param gene_hidden_size: Hidden size for gene expression MLP @@ -22,12 +21,12 @@ def __init__(self, gene_input_size: int, gene_hidden_size: int, drug_hidden_size self.smiles_fc = nn.Linear(128, drug_hidden_size) def forward(self, gene_expr: torch.Tensor, smiles: torch.Tensor) -> torch.Tensor: - """ - Forward pass of the feature extractor. + """Forward pass of the feature extractor. :param gene_expr: Gene expression features [batch_size, gene_input_size] :param smiles: BPE-encoded SMILES features [batch_size, 128] - :return: Combined features [batch_size, gene_hidden_size + drug_hidden_size] + + :returns: return: Combined features [batch_size, gene_hidden_size + drug_hidden_size] """ gene_out = functional.relu(self.gene_fc1(gene_expr)) gene_out = functional.relu(self.gene_fc2(gene_out)) @@ -48,8 +47,7 @@ def __init__( dropout: float = 0.1, num_layers: int = 3, ): - """ - Initialize the transformer model. + """Initialize the transformer model. :param feature_dim: Dimension of each feature in the sequence :param nhead: Number of attention heads @@ -75,11 +73,11 @@ def __init__( ) def forward(self, x: torch.Tensor) -> torch.Tensor: - """ - Forward pass of the transformer model. + """Forward pass of the transformer model. :param x: Input tensor [batch_size, seq_len, feature_dim] - :return: Output predictions [batch_size, 1] + + :returns: return: Output predictions [batch_size, 1] """ x = self.transformer_encoder(x) x = torch.flatten(x, 1) @@ -100,8 +98,7 @@ def __init__( dim_feedforward: int = 2048, dropout: float = 0.1, ): - """ - Initialize the combined model. + """Initialize the combined model. :param gene_input_size: Input size for gene expression features :param gene_hidden_size: Hidden size for gene expression MLP @@ -126,12 +123,12 @@ def __init__( ) def forward(self, gene_expr: torch.Tensor, smiles: torch.Tensor) -> torch.Tensor: - """ - Forward pass of the combined model. + """Forward pass of the combined model. :param gene_expr: Gene expression features [batch_size, gene_input_size] :param smiles: BPE-encoded SMILES features [batch_size, 128] - :return: Output predictions [batch_size, 1] + + :returns: return: Output predictions [batch_size, 1] """ features = self.feature_extractor(gene_expr, smiles) batch_size = features.size(0) diff --git a/drevalpy/components/predictors/literature/pharmaformer/predictor.py b/drevalpy/components/predictors/literature/pharmaformer/predictor.py new file mode 100644 index 000000000..fe42590d1 --- /dev/null +++ b/drevalpy/components/predictors/literature/pharmaformer/predictor.py @@ -0,0 +1,320 @@ +"""PharmaFormer block literature predictor. + +``torch`` and ``.model_utils`` are imported inside the functions that need them. +``drevalpy.registry`` imports this module to register the ``pharmaFormer`` +predictor on ``import drevalpy``, so a module-scope ``import torch`` put ~0.35s on +the startup path of every CLI invocation. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.literature._early_stopping import ( + EarlyStoppingRun, + train_with_early_stopping, +) +from drevalpy.components.predictors.literature._metadata import PHARMAFORMER_REFERENCE +from drevalpy.components.predictors.literature._pair_predict import ( + PairEvalSpec, + predict_pairs, + require_drug_pair_idx, +) +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.tensor_data import make_pair_loader +from drevalpy.utils.torch_io import ( + load_state_dict, + load_trusted_mapping, + save_state_dict, + save_trusted_mapping, +) + +if TYPE_CHECKING: + import torch + from torch import nn, optim + from torch.utils.data import DataLoader + + from .model_utils import CombinedModel + + +def _build_combined_model(gene_input_size: int, hyperparameters: dict[str, Any], device: torch.device) -> CombinedModel: + from .model_utils import CombinedModel + + return CombinedModel( + gene_input_size=gene_input_size, + gene_hidden_size=hyperparameters["gene_hidden_size"], + drug_hidden_size=hyperparameters["drug_hidden_size"], + feature_dim=hyperparameters["feature_dim"], + nhead=hyperparameters["nhead"], + num_layers=hyperparameters.get("num_layers", 3), + dim_feedforward=hyperparameters.get("dim_feedforward", 2048), + dropout=hyperparameters.get("dropout", 0.1), + ).to(device) + + +def _run_epoch( + model: CombinedModel, + loader: DataLoader, + loss_func: nn.Module, + optimizer: optim.Optimizer | None, + device: torch.device, +) -> float: + """Run one train or validation epoch. + + :param model: Combined PharmaFormer model. + :param loader: Training or validation data loader. + :param loss_func: Loss function. + :param optimizer: Optimizer for training; ``None`` for eval-only. + :param device: Torch device. + :returns: Mean epoch loss. + """ + import torch + + is_training = optimizer is not None + if is_training: + model.train() + else: + model.eval() + + epoch_loss = 0.0 + batch_count = 0 + + context = torch.enable_grad() if is_training else torch.no_grad() + with context: + for gene_inputs, smiles_inputs, batch_targets in loader: + gene_inputs = gene_inputs.to(device) + smiles_inputs = smiles_inputs.to(device) + batch_targets = batch_targets.to(device) + + outputs = model(gene_inputs, smiles_inputs) + loss = loss_func(outputs.squeeze(), batch_targets) + + if is_training and optimizer is not None: + optimizer.zero_grad() + loss.backward() + optimizer.step() + epoch_loss += loss.detach().item() + else: + epoch_loss += loss.item() + + batch_count += 1 + + return epoch_loss / max(batch_count, 1) + + +@register( + "pharmaFormer", + description="PharmaFormer landmark genes + BPE PharmaFormer model.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + reference=PHARMAFORMER_REFERENCE, +) +class PharmaFormerPredictor(BlockPredictor): + """Registered PharmaFormer predictor consuming ModelInputBatch directly.""" + + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ("gene_expression",) + required_drug_blocks: ClassVar[tuple[str, ...]] = ("bpe_smiles",) + required_cell_line_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX), + ) + required_drug_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("bpe_smiles", FeatureFormat.NUMERIC_MATRIX), + ) + supports_early_stopping: ClassVar[bool] = True + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize the PharmaFormer predictor. + + :param hyperparameters: Optional hyperparameter overrides. + """ + import torch + + super().__init__(hyperparameters) + self._device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self._model: CombinedModel | None = None + self._gene_input_size: int | None = None + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Return default hyperparameters for PharmaFormer. + + :returns: Default hyperparameter mapping. + """ + return { + "gene_hidden_size": 2048, + "drug_hidden_size": 128, + "feature_dim": 64, + "nhead": 4, + "num_layers": 2, + "dim_feedforward": 1024, + "dropout": 0.1, + "batch_size": 64, + "lr": 0.00001, + "epochs": 100, + "patience": 10, + } + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return the tunable hyperparameter space. + + :returns: Ray Tune-style hyperparameter specs. + """ + return {} + + def _entity_blocks(self, batch: ModelInputBatch) -> tuple[np.ndarray, np.ndarray]: + """Return the entity-level gene expression and BPE SMILES matrices. + + :param batch: Batch to read the blocks from. + :returns: Tuple of ``(gene_expression, bpe_smiles)`` matrices. + """ + return ( + batch.cell_line_blocks["gene_expression"].values, + batch.drug_blocks["bpe_smiles"].values, + ) + + def _build_loaders(self, batch: ModelInputBatch) -> tuple[DataLoader, DataLoader]: + """Build the training and early-stopping loaders. + + :param batch: Training batch with an early-stopping response. + :returns: Tuple of ``(train_loader, val_loader)``. + :raises ValueError: If early stopping data is not provided. + """ + if batch.early_stopping_response is None: + msg = "PharmaFormer model requires early stopping data." + raise ValueError(msg) + + gene_entity, drug_entity = self._entity_blocks(batch) + batch_size = self._hyperparameters["batch_size"] + + train_loader = make_pair_loader( + (gene_entity, batch.cell_line_pair_idx), + (drug_entity, require_drug_pair_idx(batch.drug_pair_idx)), + response=np.asarray(batch.response, dtype=np.float32), + batch_size=batch_size, + shuffle=True, + ) + + es_response = batch.early_stopping_response + es_cell_pair_idx, es_drug_pair_idx = batch._pair_indices_for(es_response) + val_loader = make_pair_loader( + (gene_entity, es_cell_pair_idx), + (drug_entity, require_drug_pair_idx(es_drug_pair_idx)), + response=np.asarray(es_response.response, dtype=np.float32), + batch_size=batch_size, + shuffle=False, + ) + return train_loader, val_loader + + def _fit(self, batch: ModelInputBatch) -> None: + """Train the PharmaFormer model with early stopping. + + :param batch: Training batch with gene_expression and bpe_smiles blocks. + """ + import torch.nn as nn + import torch.optim as optim + + train_loader, val_loader = self._build_loaders(batch) + + gene_entity, _ = self._entity_blocks(batch) + self._gene_input_size = gene_entity.shape[1] + model = _build_combined_model(self._gene_input_size, self._hyperparameters, self._device) + self._model = model + + loss_func = nn.MSELoss() + optimizer = optim.Adam(model.parameters(), lr=self._hyperparameters["lr"]) + + train_with_early_stopping( + model, + EarlyStoppingRun( + epochs=self._hyperparameters["epochs"], + patience=self._hyperparameters.get("patience", 10), + checkpoint_dir=batch.training_context.checkpoint_dir, + model_name="PharmaFormer", + verbose=True, + ), + train_epoch=lambda: _run_epoch(model, train_loader, loss_func, optimizer, self._device), + val_epoch=lambda: _run_epoch(model, val_loader, loss_func, None, self._device), + device=self._device, + ) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Run PharmaFormer inference on the given batch. + + :param batch: Featurized pairs to score. + :returns: One predicted response per pair. + :raises ValueError: If the model has not been trained yet. + """ + if self._model is None: + msg = "PharmaFormer model not initialized." + raise ValueError(msg) + + gene_entity, drug_entity = self._entity_blocks(batch) + return predict_pairs( + self._model, + batch, + PairEvalSpec( + cell_line_blocks=(gene_entity,), + drug_blocks=(drug_entity,), + batch_size=self._hyperparameters.get("batch_size", 64), + device=self._device, + ), + ) + + def is_fitted(self) -> bool: + """Return whether the model has been trained. + + :returns: True when the model is initialized. + """ + return self._model is not None + + def get_state(self) -> dict[str, object]: + """Serialize fitted predictor state. + + :returns: Mapping with binary payload blob when fitted, else empty. + """ + if self._model is None: + return {} + payload: dict[str, Any] = { + "predictor_hyperparameters": dict(self._hyperparameters), + "gene_input_size": self._gene_input_size, + "model_state": save_state_dict(self._model.state_dict()), + } + return {"payload": save_trusted_mapping(payload)} + + def set_state(self, state: dict[str, object]) -> None: + """Restore predictor from get_state output. + + :param state: Serialized state containing a payload byte blob. + :raises PredictorStateError: If payload is missing or invalid. + """ + blob = state.get("payload") + if not isinstance(blob, (bytes, bytearray)): + msg = "PharmaFormerPredictor state requires a payload byte blob" + raise PredictorStateError(msg) + try: + payload = load_trusted_mapping(bytes(blob)) + except Exception as exc: + msg = "PharmaFormerPredictor payload could not be deserialized" + raise PredictorStateError(msg) from exc + hyperparameters = payload.get("predictor_hyperparameters") + if not isinstance(hyperparameters, dict): + msg = "PharmaFormerPredictor payload is missing predictor_hyperparameters" + raise PredictorStateError(msg) + self._hyperparameters = dict(hyperparameters) + gene_input_size = payload.get("gene_input_size") + model_state = payload.get("model_state") + if isinstance(gene_input_size, int) and isinstance(model_state, (bytes, bytearray)): + self._gene_input_size = gene_input_size + self._model = _build_combined_model(gene_input_size, self._hyperparameters, self._device) + self._model.load_state_dict(load_state_dict(bytes(model_state))) + self._model.to(self._device) diff --git a/drevalpy/components/predictors/literature/precily/__init__.py b/drevalpy/components/predictors/literature/precily/__init__.py new file mode 100644 index 000000000..f12e4ef2b --- /dev/null +++ b/drevalpy/components/predictors/literature/precily/__init__.py @@ -0,0 +1 @@ +"""Precily literature algorithm package.""" diff --git a/drevalpy/models/Precily/model_utils.py b/drevalpy/components/predictors/literature/precily/model_utils.py similarity index 76% rename from drevalpy/models/Precily/model_utils.py rename to drevalpy/components/predictors/literature/precily/model_utils.py index 4aa755951..9730cfb9e 100644 --- a/drevalpy/models/Precily/model_utils.py +++ b/drevalpy/components/predictors/literature/precily/model_utils.py @@ -1,13 +1,13 @@ -r"""Neural network components for the Precily model. +"""Neural network components for the Precily model. Exact port of the Keras architecture from Chawla et al. (Nat Commun 2022), - Input(input_dim) - -> Dense(1429) -> ReLU - -> Dense(512) -> ReLU -> Dropout(p) - -> Dense(140) -> ReLU -> Dropout(p) - -> Dense(200) -> ReLU -> Dropout(p) - -> Dense(1) +Input(input_dim) +-> Dense(1429) -> ReLU +-> Dense(512) -> ReLU -> Dropout(p) +-> Dense(140) -> ReLU -> Dropout(p) +-> Dense(200) -> ReLU -> Dropout(p) +-> Dense(1) input_dim = n_pathways (GSVA) + n_drug_features (Morgan/SMILESVec). With Morgan fingerprints the drug dimension differs and input_dim @@ -24,8 +24,7 @@ class PrecilyNetwork(nn.Module): """Feed-forward regressor predicting LN(IC50) from pathway + drug features.""" def __init__(self, input_dim: int, dropout: float = 0.1): - """ - Initialize the Precily network. + """Initialize the Precily network. :param input_dim: total feature dimension (pathways + drug features) :param dropout: dropout probability between hidden layers @@ -47,10 +46,10 @@ def __init__(self, input_dim: int, dropout: float = 0.1): ) def forward(self, x: torch.Tensor) -> torch.Tensor: - """ - Perform forward pass. + """Perform forward pass. :param x: [batch, input_dim] feature tensor - :return: [batch] predicted LN(IC50) + + :returns: [batch] predicted LN(IC50) """ return self.net(x).squeeze(-1) diff --git a/drevalpy/components/predictors/literature/precily/predictor.py b/drevalpy/components/predictors/literature/precily/predictor.py new file mode 100644 index 000000000..c5e8f1b5f --- /dev/null +++ b/drevalpy/components/predictors/literature/precily/predictor.py @@ -0,0 +1,243 @@ +"""Precily block literature predictor. + +``torch`` and ``.model_utils`` are imported inside the methods that need them. +``drevalpy.registry`` imports this module to register the ``precily`` predictor on +``import drevalpy``, so a module-scope ``import torch`` put ~0.35s on the startup +path of every CLI invocation. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.literature._metadata import PRECILY_REFERENCE +from drevalpy.components.predictors.literature._pair_predict import ( + PairEvalSpec, + concatenated_forward, + predict_pairs, + require_drug_pair_idx, +) +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.tensor_data import make_pair_loader +from drevalpy.utils.torch_io import ( + load_state_dict, + load_trusted_mapping, + save_state_dict, + save_trusted_mapping, +) + +if TYPE_CHECKING: + from torch import nn, optim + from torch.utils.data import DataLoader + + from .model_utils import PrecilyNetwork + + +@register( + "precily", + description="Precily pathway + SMILESVec model.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + reference=PRECILY_REFERENCE, +) +class PrecilyPredictor(BlockPredictor): + """Registered Precily predictor consuming ModelInputBatch directly.""" + + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ("pathways",) + required_drug_blocks: ClassVar[tuple[str, ...]] = ("smilesvec",) + supports_early_stopping: ClassVar[bool] = False + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize the Precily predictor. + + :param hyperparameters: Optional hyperparameter overrides. + """ + import torch + + super().__init__(hyperparameters) + self._device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self._model: PrecilyNetwork | None = None + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Return default hyperparameters for Precily. + + :returns: Default hyperparameter mapping. + """ + return { + "learning_rate": 1.0e-3, + "dropout": 0.1, + "epochs": 50, + "batch_size": 128, + "seed": 42, + } + + def _entity_blocks(self, batch: ModelInputBatch) -> tuple[np.ndarray, np.ndarray]: + """Return the entity-level pathway and SMILESVec matrices. + + :param batch: Batch to read the blocks from. + :returns: Tuple of ``(pathways, smilesvec)`` matrices. + """ + return ( + batch.cell_line_blocks["pathways"].values, + batch.drug_blocks["smilesvec"].values, + ) + + def _build_model(self, input_dim: int, dropout: float) -> PrecilyNetwork: + """Construct the Precily network on the target device. + + :param input_dim: Concatenated pathway plus drug feature width. + :param dropout: Dropout probability between hidden layers. + :returns: Initialized network. + """ + from .model_utils import PrecilyNetwork + + return PrecilyNetwork(input_dim=input_dim, dropout=dropout).to(self._device) + + def _fit(self, batch: ModelInputBatch) -> None: + """Train the Precily model on the batch. + + :param batch: Training batch with pathways and smilesvec blocks. + """ + import torch.nn as nn + import torch.optim as optim + + pathway_entity, drug_entity = self._entity_blocks(batch) + model = self._build_model( + pathway_entity.shape[1] + drug_entity.shape[1], + self._hyperparameters.get("dropout", 0.1), + ) + self._model = model + + train_loader = make_pair_loader( + (pathway_entity, batch.cell_line_pair_idx), + (drug_entity, require_drug_pair_idx(batch.drug_pair_idx)), + response=np.asarray(batch.response, dtype=np.float32), + batch_size=self._hyperparameters["batch_size"], + shuffle=True, + ) + + loss_func = nn.MSELoss() + optimizer = optim.Adam(model.parameters(), lr=self._hyperparameters["learning_rate"]) + epochs = self._hyperparameters["epochs"] + for epoch in range(epochs): + epoch_loss = self._run_epoch(model, train_loader, loss_func, optimizer) + print(f"Precily: Epoch [{epoch + 1}/{epochs}] Training Loss: {epoch_loss:.4f}") + + def _run_epoch( + self, + model: PrecilyNetwork, + loader: DataLoader, + loss_func: nn.Module, + optimizer: optim.Optimizer, + ) -> float: + """Run one training epoch over *loader*. + + :param model: Network being trained. + :param loader: Training loader yielding pathway, drug and target tensors. + :param loss_func: Loss function. + :param optimizer: Optimizer to step. + :returns: Mean epoch loss. + """ + import torch + + model.train() + epoch_loss = 0.0 + batch_count = 0 + for pathway_inputs, drug_inputs, targets in loader: + x = torch.cat([pathway_inputs.to(self._device), drug_inputs.to(self._device)], dim=1) + loss = loss_func(model(x), targets.to(self._device)) + + optimizer.zero_grad() + loss.backward() + optimizer.step() + + epoch_loss += loss.detach().item() + batch_count += 1 + return epoch_loss / max(batch_count, 1) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Run Precily inference on the given batch. + + :param batch: Featurized pairs to score. + :returns: One predicted response per pair. + :raises ValueError: If the model has not been trained yet. + """ + if self._model is None: + msg = "Precily model not initialized." + raise ValueError(msg) + + pathway_entity, drug_entity = self._entity_blocks(batch) + return predict_pairs( + self._model, + batch, + PairEvalSpec( + cell_line_blocks=(pathway_entity,), + drug_blocks=(drug_entity,), + batch_size=self._hyperparameters.get("batch_size", 128), + device=self._device, + ), + forward=concatenated_forward(self._model), + ) + + def is_fitted(self) -> bool: + """Return whether the model has been trained. + + :returns: True when the model is initialized. + """ + return self._model is not None + + def get_state(self) -> dict[str, object]: + """Serialize fitted predictor state. + + :returns: Mapping with binary payload blob when fitted, else empty. + :raises TypeError: If the network architecture is unexpected. + """ + import torch.nn as nn + + if self._model is None: + return {} + first_layer = self._model.net[0] + if not isinstance(first_layer, nn.Linear): + msg = "PrecilyNetwork must start with a Linear layer" + raise TypeError(msg) + payload: dict[str, Any] = { + "predictor_hyperparameters": dict(self._hyperparameters), + "input_dim": int(first_layer.in_features), + "model_state": save_state_dict(self._model.state_dict()), + } + return {"payload": save_trusted_mapping(payload)} + + def set_state(self, state: dict[str, object]) -> None: + """Restore predictor from get_state output. + + :param state: Serialized state containing a payload byte blob. + :raises PredictorStateError: If payload is missing or invalid. + """ + blob = state.get("payload") + if not isinstance(blob, (bytes, bytearray)): + msg = "PrecilyPredictor state requires a payload byte blob" + raise PredictorStateError(msg) + try: + payload = load_trusted_mapping(bytes(blob)) + except Exception as exc: + msg = "PrecilyPredictor payload could not be deserialized" + raise PredictorStateError(msg) from exc + hyperparameters = payload.get("predictor_hyperparameters") + if not isinstance(hyperparameters, dict): + msg = "PrecilyPredictor payload is missing predictor_hyperparameters" + raise PredictorStateError(msg) + self._hyperparameters = dict(hyperparameters) + input_dim = payload.get("input_dim") + model_state = payload.get("model_state") + if isinstance(input_dim, int) and isinstance(model_state, (bytes, bytearray)): + self._model = self._build_model(input_dim, float(hyperparameters.get("dropout", 0.1))) + self._model.load_state_dict(load_state_dict(bytes(model_state))) diff --git a/drevalpy/components/predictors/literature/sparsego/__init__.py b/drevalpy/components/predictors/literature/sparsego/__init__.py new file mode 100644 index 000000000..14ce99dd9 --- /dev/null +++ b/drevalpy/components/predictors/literature/sparsego/__init__.py @@ -0,0 +1 @@ +"""SparseGO literature algorithm exports.""" diff --git a/drevalpy/components/predictors/literature/sparsego/algorithm.py b/drevalpy/components/predictors/literature/sparsego/algorithm.py new file mode 100644 index 000000000..1b5e289ce --- /dev/null +++ b/drevalpy/components/predictors/literature/sparsego/algorithm.py @@ -0,0 +1,422 @@ +"""SparseGO model for drug response prediction. + +A sparse visible neural network (VNN) structured according to the Gene Ontology (GO) +hierarchy, combined with an ANN for drug fingerprints. + +Original authors: Sada Del Real & Rubio (2023, 10.1016/j.ebiom.2023.104767) +Code adapted from https://github.com/KatynaSada/SparseGO_lightning +""" + +import warnings +from typing import cast + +import numpy as np +import torch +import torch.nn as nn +from scipy import sparse + +from .utils import create_index + + +def _validate_sparse_linear_dimensions( + in_features: int, out_features: int, sparsity: float, connectivity: torch.Tensor | None +) -> None: + if not (in_features < 2**31 and out_features < 2**31 and sparsity < 1.0): + raise ValueError("in_features and out_features must be < 2^31, sparsity must be < 1.0") + if connectivity is None: + return + if connectivity.shape[0] != 2 or connectivity.shape[1] <= 0: + raise ValueError("Input shape for connectivity should be (2, nnz)") + if connectivity.shape[1] > in_features * out_features: + raise ValueError("Nnz can't be bigger than the weight matrix") + + +def _sparse_connectivity_indices( + in_features: int, + out_features: int, + sparsity: float, + connectivity: torch.Tensor | None, + device: torch.device, +) -> tuple[torch.Tensor, int, float]: + """Return COO indices, nnz count, and effective sparsity. + + :param in_features: Input feature dimension. + :param out_features: Output feature dimension. + :param sparsity: Target sparsity when *connectivity* is not provided. + :param connectivity: Optional fixed sparse connectivity tensor. + :param device: Torch device for generated indices. + + :returns: Tuple of connectivity indices, non-zero count, and effective sparsity. + """ + if connectivity is None: + nnz = round((1.0 - sparsity) * in_features * out_features) + if in_features * out_features <= 10**8: + idx = np.random.choice(in_features * out_features, nnz, replace=False) + indices = torch.as_tensor(idx, device=device) + row_ind = indices.floor_divide(in_features) + col_ind = indices.fmod(in_features) + else: + warnings.warn( + "Matrix too large to sample non-zero indices without replacement, sparsity will be approximate", + RuntimeWarning, + stacklevel=3, + ) + row_ind = torch.randint(0, out_features, (nnz,), device=device) + col_ind = torch.randint(0, in_features, (nnz,), device=device) + stacked = torch.stack((row_ind, col_ind)) + return stacked, nnz, sparsity + + nnz = connectivity.shape[1] + effective_sparsity = nnz / (out_features * in_features) + return connectivity.to(device=device), nnz, effective_sparsity + + +class SparseLinearNew(nn.Module): + """Sparse linear layer with user-defined connectivity. + + Applies a linear transformation y = xA^T + b where A is a sparse weight + matrix. Only the connections specified in the connectivity tensor are learned; + all other weights are permanently zero. + + :param in_features: Size of each input sample. + :param out_features: Size of each output sample. + :param bias: If True, adds a learnable bias. Default: True. + :param sparsity: Sparsity of weight matrix if connectivity is None. Default: 0.9. + :param connectivity: LongTensor of shape ``(2, nnz)`` with non-zero weight indices for + GO-structured layers. + """ + + def __init__( + self, + in_features: int, + out_features: int, + bias: bool = True, + sparsity: float = 0.9, + connectivity: torch.Tensor | None = None, + ): + """Initialize SparseLinearNew layer. + + :param in_features: Size of each input sample. + :param out_features: Size of each output sample. + :param bias: If True, adds a learnable bias. Default: True. + :param sparsity: Sparsity of weight matrix if connectivity is None. Default: 0.9. + :param connectivity: LongTensor of shape (2, nnz) specifying non-zero weight positions. + """ + _validate_sparse_linear_dimensions(in_features, out_features, sparsity, connectivity) + + super().__init__() + self.in_features = in_features + self.out_features = out_features + self.connectivity = connectivity + + coalesce_device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu") + indices, nnz, effective_sparsity = _sparse_connectivity_indices( + in_features, out_features, sparsity, connectivity, coalesce_device + ) + self.sparsity = effective_sparsity + + values = torch.empty(nnz, device=coalesce_device) + sparse = torch.sparse_coo_tensor(indices, values, (out_features, in_features)).coalesce() + indices, values = sparse.indices(), sparse.values() + + self.register_buffer("indices", indices.cpu()) + self.weights = nn.Parameter(values.cpu()) + + if bias: + self.bias = nn.Parameter(torch.Tensor(out_features)) + else: + self.register_parameter("bias", None) + + self.reset_parameters() + + def reset_parameters(self) -> None: + """Initialize weights and bias with uniform distribution.""" + bound = 1 / self.in_features**0.5 + nn.init.uniform_(self.weights, -bound, bound) + if self.bias is not None: + nn.init.uniform_(self.bias, -bound, bound) + + def forward(self, inputs: torch.Tensor) -> torch.Tensor: + """Forward pass through sparse linear layer. + + :param inputs: Input tensor of shape (batch_size, in_features). + + :returns: Output tensor of shape (batch_size, out_features). + """ + output_shape = list(inputs.shape) + output_shape[-1] = self.out_features + + if len(output_shape) == 1: + inputs = inputs.view(1, -1) + inputs = inputs.flatten(end_dim=-2) + + indices = cast(torch.Tensor, self.indices) + sparse_matrix = torch.sparse_coo_tensor( + indices, + self.weights, + [self.out_features, self.in_features], + ) + output = torch.sparse.mm(sparse_matrix, inputs.t()).t() + + if self.bias is not None: + output += self.bias + + return output.view(output_shape) + + +class SparseGONetwork(nn.Module): + """Sparse Visible Neural Network structured according to the Gene Ontology hierarchy. + + Two-branch architecture: + + - VNN branch: sparse layers following GO parent-child relationships, takes + gene expression or mutation data as input. + - ANN branch: fully connected layers processing Morgan drug fingerprints. + + Both branches are concatenated and fed into a final regression head that + predicts the drug response. + + Adapted from sparseGO_nn in https://github.com/KatynaSada/SparseGO + + :param layer_connections: List of (parent, child) pair arrays per layer, output of pairs_in_layers(). + :param num_neurons_per_GO: Number of neurons per GO term (default 6). + :param num_neurons_per_final_GO: Number of neurons in the final GO layer. + :param num_neurons_drug: List of hidden layer sizes for the drug ANN branch. + :param num_neurons_final: Number of neurons in the final combined layer. + :param drug_dim: Dimensionality of the drug fingerprint vector. + :param gene2id_mapping: Mapping from gene names to ontology indices matching expression columns. + :param p_drop_final: Dropout rate for the final combined layers. + :param p_drop_genes: Dropout rate for the gene input layer. + :param p_drop_terms: Dropout rate for GO term layers. + :param p_drop_drugs: Dropout rate for drug ANN layers. + """ + + def __init__( + self, + layer_connections: list, + num_neurons_per_go: int, + num_neurons_per_final_go: int, + num_neurons_drug: list[int], + num_neurons_final: int, + drug_dim: int, + gene2id_mapping: dict, + p_drop_final: float = 0, + p_drop_genes: float = 0.1, + p_drop_terms: float = 0.1, + p_drop_drugs: float = 0.1, + ): + """Initialize SparseGONetwork. + + :param layer_connections: List of (parent, child) pair arrays per layer. + :param num_neurons_per_go: Number of neurons per GO term. + :param num_neurons_per_final_go: Number of neurons in the final GO layer. + :param num_neurons_drug: List of hidden layer sizes for the drug ANN branch. + :param num_neurons_final: Number of neurons in the final combined layer. + :param drug_dim: Dimensionality of the drug fingerprint vector. + :param gene2id_mapping: Dictionary mapping gene names to integer indices. + :param p_drop_final: Dropout rate for the final combined layers. + :param p_drop_genes: Dropout rate for the gene input layer. + :param p_drop_terms: Dropout rate for GO term layers. + :param p_drop_drugs: Dropout rate for drug ANN layers. + """ + super().__init__() + + self.num_neurons_per_GO = num_neurons_per_go + self.num_neurons_per_final_GO = num_neurons_per_final_go + self.num_neurons_drug = num_neurons_drug + self.drug_dim = drug_dim + self.layer_connections = layer_connections + + print("\nNumber of neurons per GO term: ", num_neurons_per_go) + print("Number of neurons of final GO term: ", num_neurons_per_final_go) + print("Number of drug neurons: ", num_neurons_drug) + print("Number of final neurons: ", num_neurons_final) + + # (1) Layer of genes with terms + input_id = self._genes_layer(layer_connections[0], p_drop_genes, gene2id_mapping) + + print("Number of term-term hierarchy levels:", len(layer_connections)) + + # (2...) Layers of terms with terms + for i in range(1, len(layer_connections)): + neurons = num_neurons_per_final_go if i == len(layer_connections) - 1 else num_neurons_per_go + input_id = self._terms_layer(input_id, layer_connections[i], str(i), neurons, p_drop_terms) + + # Drug ANN branch + self._construct_drug_branch(p_drop_drugs) + + # Final combined layers + final_input_size = num_neurons_per_final_go + num_neurons_drug[-1] + self.add_module("final_batchnorm_layer", nn.BatchNorm1d(final_input_size)) + self.add_module("drop_final", nn.Dropout(p_drop_final)) + self.add_module("final_linear_layer", nn.Linear(final_input_size, num_neurons_final)) + self.add_module("final_tanh", nn.Tanh()) + self.add_module("final_aux_batchnorm_layer", nn.BatchNorm1d(num_neurons_final)) + self.add_module("drop_aux_final", nn.Dropout(p_drop_final)) + self.add_module("final_aux_linear_layer", nn.Linear(num_neurons_final, 1)) + self.add_module("final_aux_tanh", nn.Tanh()) + self.add_module("final_linear_layer_output", nn.Linear(1, 1)) + + def _m(self, name: str) -> nn.Module: + """Get a registered submodule by name. + + :param name: Module name as registered via add_module. + + :returns: The submodule. + + :raises ValueError: if the module is not found. + """ + module = self._modules[name] + if module is None: + raise ValueError(f"Module '{name}' not found") + return module + + def _genes_layer(self, genes_terms_pairs: np.ndarray, p_drop_genes: float, gene2id: dict) -> dict: + """Build the first sparse layer connecting genes to GO terms. + + :param genes_terms_pairs: Array of (GO_term, gene) pairs. + :param p_drop_genes: Dropout rate. + :param gene2id: Dictionary mapping gene names to indices. + + :returns: Dictionary mapping GO term names to their indices in this layer. + """ + term2id = create_index(genes_terms_pairs[:, 0]) + + self.gene_dim = len(gene2id) + self.term_dim = len(term2id) + + rows = [term2id[term] for term in genes_terms_pairs[:, 0]] + columns = [gene2id[gene] for gene in genes_terms_pairs[:, 1]] + data = np.ones(len(rows)) + + genes_terms = sparse.coo_matrix((data, (rows, columns)), shape=(self.term_dim, self.gene_dim)) + + # Expand to k neurons per GO term by repeating each row k times + genes_terms_more_neurons = sparse.lil_matrix((self.term_dim * self.num_neurons_per_GO, self.gene_dim)) + genes_terms = genes_terms.tolil() + row = 0 + for i in range(genes_terms_more_neurons.shape[0]): + if (i != 0) and (i % self.num_neurons_per_GO) == 0: + row += 1 + genes_terms_more_neurons[i, :] = genes_terms[row, :] + + rows_t = torch.from_numpy(sparse.find(genes_terms_more_neurons)[0]).view(1, -1).long() + cols_t = torch.from_numpy(sparse.find(genes_terms_more_neurons)[1]).view(1, -1).long() + connections = torch.cat((rows_t, cols_t), dim=0) + + input_terms = len(gene2id) + output_terms = self.num_neurons_per_GO * len(term2id) + + self.genes_terms_sparse_linear_1 = SparseLinearNew(input_terms, output_terms, connectivity=connections) + self.genes_terms_batchnorm = nn.BatchNorm1d(input_terms) + self.genes_terms_tanh = nn.Tanh() + self.drop_0 = nn.Dropout(p_drop_genes) + + return term2id + + def _terms_layer( + self, + input_id: dict, + layer_pairs: np.ndarray, + number: str, + neurons_per_go: int, + p_drop_terms: float, + ) -> dict: + """Build one sparse layer connecting GO terms to GO terms. + + :param input_id: Dictionary mapping child GO term names to indices. + :param layer_pairs: Array of (parent_term, child_term) pairs for this layer. + :param number: Layer number as string, used for module naming. + :param neurons_per_go: Number of neurons for parent terms in this layer. + :param p_drop_terms: Dropout rate. + + :returns: Dictionary mapping parent GO term names to their indices. + """ + output_id = create_index(layer_pairs[:, 0]) + + rows = [output_id[term] for term in layer_pairs[:, 0]] + columns = [input_id[term] for term in layer_pairs[:, 1]] + data = np.ones(len(rows)) + + connections_matrix = sparse.coo_matrix((data, (rows, columns)), shape=(len(output_id), len(input_id))) + + # Kronecker product to expand to k neurons per term + ones = sparse.csr_matrix(np.ones([neurons_per_go, self.num_neurons_per_GO], dtype=int)) + connections_matrix_more_neurons = sparse.csr_matrix(sparse.kron(connections_matrix, ones)) + + rows_t = torch.from_numpy(sparse.find(connections_matrix_more_neurons)[0]).view(1, -1).long() + cols_t = torch.from_numpy(sparse.find(connections_matrix_more_neurons)[1]).view(1, -1).long() + connections = torch.cat((rows_t, cols_t), dim=0) + + input_terms = self.num_neurons_per_GO * len(input_id) + output_terms = neurons_per_go * len(output_id) + + self.add_module( + f"GO_terms_sparse_linear_{number}", + SparseLinearNew(input_terms, output_terms, connectivity=connections), + ) + self.add_module(f"drop_{number}", nn.Dropout(p_drop_terms)) + self.add_module(f"GO_terms_tanh_{number}", nn.Tanh()) + self.add_module(f"GO_terms_batchnorm_{number}", nn.BatchNorm1d(input_terms)) + + return output_id + + def _construct_drug_branch(self, p_drop_drugs: float) -> None: + """Build the fully connected ANN branch for drug fingerprints. + + :param p_drop_drugs: Dropout rate for drug layers. + """ + input_size = self.drug_dim + for i in range(len(self.num_neurons_drug)): + self.add_module(f"drug_linear_layer_{i + 1}", nn.Linear(input_size, self.num_neurons_drug[i])) + self.add_module(f"drug_drop_{i + 1}", nn.Dropout(p_drop_drugs)) + self.add_module(f"drug_tanh_{i + 1}", nn.Tanh()) + self.add_module(f"drug_batchnorm_layer_{i + 1}", nn.BatchNorm1d(input_size)) + input_size = self.num_neurons_drug[i] + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass through the full SparseGO network. + + :param x: Input tensor of shape (batch_size, gene_dim + drug_dim). + + :returns: Predicted drug response of shape (batch_size, 1). + """ + gene_input = x.narrow(1, 0, self.gene_dim) + drug_input = x.narrow(1, self.gene_dim, self.drug_dim) + + # VNN branch + gene_output = cast(nn.Module, self._modules["genes_terms_batchnorm"])(gene_input) + gene_output = cast(nn.Module, self._modules["drop_0"])(gene_output) + terms_output = cast(nn.Module, self._modules["genes_terms_tanh"])( + cast(nn.Module, self._modules["genes_terms_sparse_linear_1"])(gene_output) + ) + + for i in range(1, len(self.layer_connections)): + terms_output = cast(nn.Module, self._modules[f"GO_terms_batchnorm_{i}"])(terms_output) + terms_output = cast(nn.Module, self._modules[f"drop_{i}"])(terms_output) + terms_output = cast(nn.Module, self._modules[f"GO_terms_tanh_{i}"])( + cast(nn.Module, self._modules[f"GO_terms_sparse_linear_{i}"])(terms_output) + ) + + # ANN branch + drug_out = drug_input + for i in range(1, len(self.num_neurons_drug) + 1): + drug_out = cast(nn.Module, self._modules[f"drug_batchnorm_layer_{i}"])(drug_out) + drug_out = cast(nn.Module, self._modules[f"drug_drop_{i}"])(drug_out) + drug_out = cast(nn.Module, self._modules[f"drug_tanh_{i}"])( + cast(nn.Module, self._modules[f"drug_linear_layer_{i}"])(drug_out) + ) + + # Final + final_input = torch.cat((terms_output, drug_out), 1) + output = cast(nn.Module, self._modules["final_batchnorm_layer"])(final_input) + output = cast(nn.Module, self._modules["drop_final"])(output) + output = cast(nn.Module, self._modules["final_tanh"])( + cast(nn.Module, self._modules["final_linear_layer"])(output) + ) + output = cast(nn.Module, self._modules["final_aux_batchnorm_layer"])(output) + output = cast(nn.Module, self._modules["drop_aux_final"])(output) + output = cast(nn.Module, self._modules["final_aux_tanh"])( + cast(nn.Module, self._modules["final_aux_linear_layer"])(output) + ) + return cast(nn.Module, self._modules["final_linear_layer_output"])(output) diff --git a/drevalpy/components/predictors/literature/sparsego/predictor.py b/drevalpy/components/predictors/literature/sparsego/predictor.py new file mode 100644 index 000000000..22eea0109 --- /dev/null +++ b/drevalpy/components/predictors/literature/sparsego/predictor.py @@ -0,0 +1,354 @@ +"""SparseGO literature predictor -- direct BlockPredictor implementation. + +``torch``, the ``SparseGONetwork`` and the ontology helpers in ``.utils`` (which +pull in ``networkx``) are imported inside the methods that need them. +``drevalpy.registry`` imports this module to register the ``sparsego`` predictor on +``import drevalpy``. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.literature._metadata import SPARSEGO_REFERENCE +from drevalpy.components.predictors.literature._pair_predict import ( + PairEvalSpec, + concatenated_forward, + predict_pairs, + require_drug_pair_idx, +) +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.tensor_data import make_pair_loader +from drevalpy.utils.torch_io import load_state_dict, load_trusted_mapping, save_state_dict, save_trusted_mapping + +if TYPE_CHECKING: + from torch.utils.data import DataLoader + + from drevalpy.components.predictors.literature.sparsego.algorithm import SparseGONetwork + + +def _parse_ontology_metadata(metadata: dict[str, object]) -> tuple[list[np.ndarray], dict[str, int], list[str]]: + """Extract layer_connections, gene2id_mapping, and gene_order from block metadata. + + The metadata dictionary is produced by the SparseGO featurizer and contains: + - ``ontology_file``: Path to the GO ontology file. + - ``gene2ind_file``: Path to the gene-to-index mapping file. + - ``layer_connections``: Pre-computed list of per-layer connection arrays. + - ``gene2id_mapping_ont``: Pre-computed gene-to-id mapping dict. + - ``ontology_gene_order``: Ordered list of gene names matching expression columns. + + :param metadata: Metadata mapping from the active cell-line block. + :returns: Tuple of (layer_connections, gene2id_mapping, gene_order). + :raises ValueError: If required ontology info is missing from metadata. + """ + layer_connections = metadata.get("layer_connections") + gene2id_mapping = metadata.get("gene2id_mapping_ont") + gene_order = metadata.get("ontology_gene_order") + + if layer_connections is None or gene2id_mapping is None: + from drevalpy.components.predictors.literature.sparsego.utils import ( + load_mapping, + load_ontology, + pairs_in_layers, + sort_pairs, + ) + + ontology_file = metadata.get("ontology_file") + gene2ind_file = metadata.get("gene2ind_file") + if ontology_file is None or gene2ind_file is None: + raise ValueError( + "SparseGO block metadata must provide either pre-computed ontology structures " + "(layer_connections, gene2id_mapping_ont) or file paths (ontology_file, gene2ind_file)." + ) + gene2id_mapping = load_mapping(str(gene2ind_file)) + _, terms_pairs, genes_terms_pairs = load_ontology(str(ontology_file), gene2id_mapping) + sorted_pairs, level_list, level_number = sort_pairs(genes_terms_pairs, terms_pairs, _, gene2id_mapping) + layer_connections = pairs_in_layers(sorted_pairs, level_list, level_number) + gene_order = list(gene2id_mapping.keys()) + + return ( + list(layer_connections), # type: ignore[call-overload] + dict(gene2id_mapping), # type: ignore[call-overload] + list(gene_order) if gene_order else list(gene2id_mapping.keys()), # type: ignore[call-overload, attr-defined] + ) + + +def _resolve_active_view(batch: ModelInputBatch) -> str: + """Determine which cell-line block is the active ontology view. + + SparseGO supports either 'gene_expression' or 'mutations' (exactly one). + + :param batch: Input batch with cell-line blocks. + :returns: Name of the active cell-line view. + :raises ValueError: If zero or multiple valid views are present. + """ + candidates = {"gene_expression", "mutations"} + active = [name for name in candidates if name in batch.cell_line_blocks] + if len(active) != 1: + raise ValueError("SparseGOPredictor requires exactly one cell-line block from ['gene_expression', 'mutations']") + return active[0] + + +@register( + "sparsego", + description="SparseGO GO-structured visible neural network.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + reference=SPARSEGO_REFERENCE, +) +class SparseGOPredictor(BlockPredictor): + """SparseGO predictor consuming ModelInputBatch directly.""" + + required_cell_line_blocks: ClassVar[tuple[str, ...]] = () + required_cell_line_block_alternatives: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX, metadata=True), + BlockSpec("mutations", FeatureFormat.NUMERIC_MATRIX, metadata=True), + ) + required_drug_blocks: ClassVar[tuple[str, ...]] = ("fingerprints",) + required_drug_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("fingerprints", FeatureFormat.NUMERIC_MATRIX), + ) + validate_drug_graphs: ClassVar[bool] = False + supports_early_stopping: ClassVar[bool] = False + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize SparseGOPredictor. + + :param hyperparameters: Optional overrides for algorithm defaults. + """ + import torch + + super().__init__(hyperparameters) + self._device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self._model: SparseGONetwork | None = None + self._layer_connections: list | None = None + self._gene2id_mapping_ont: dict[str, int] | None = None + self._ontology_gene_order: list[str] | None = None + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Return default SparseGO hyperparameters. + + :returns: Default hyperparameter mapping. + """ + return { + "num_neurons_per_GO": 6, + "num_neurons_per_final_GO": 6, + "num_neurons_drug": [100, 50, 6], + "num_neurons_final": 12, + "drug_dim": 2048, + "learning_rate": 0.1, + "momentum": 0.9, + "decay_rate": 0.002, + "p_drop_genes": 0.15, + "p_drop_terms": 0.15, + "p_drop_drugs": 0.15, + "p_drop_final": 0.0, + "epochs": 400, + "batch_size": 20000, + } + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return the tunable hyperparameter space. + + :returns: Ray Tune-style hyperparameter specs. + """ + return {} + + def _build_network(self) -> None: + """Construct the SparseGONetwork from stored ontology metadata and hyperparameters. + + :raises ValueError: If ontology metadata is not available. + """ + from drevalpy.components.predictors.literature.sparsego.algorithm import SparseGONetwork + + if self._layer_connections is None or self._gene2id_mapping_ont is None: + raise ValueError("SparseGO ontology metadata must be provided before building the network.") + self._model = SparseGONetwork( + layer_connections=self._layer_connections, + num_neurons_per_go=self._hyperparameters.get("num_neurons_per_GO", 6), + num_neurons_per_final_go=self._hyperparameters.get("num_neurons_per_final_GO", 6), + num_neurons_drug=self._hyperparameters.get("num_neurons_drug", [200, 100, 50]), + num_neurons_final=self._hyperparameters.get("num_neurons_final", 12), + drug_dim=self._hyperparameters.get("drug_dim", 2048), + gene2id_mapping=self._gene2id_mapping_ont, + p_drop_final=self._hyperparameters.get("p_drop_final", 0.0), + p_drop_genes=self._hyperparameters.get("p_drop_genes", 0.1), + p_drop_terms=self._hyperparameters.get("p_drop_terms", 0.1), + p_drop_drugs=self._hyperparameters.get("p_drop_drugs", 0.1), + ).to(self._device) + + def _entity_matrices(self, batch: ModelInputBatch, active_view: str) -> tuple[np.ndarray, np.ndarray]: + """Return the entity-level cell-line and fingerprint matrices as float32. + + :param batch: Batch to read the blocks from. + :param active_view: Name of the cell-line block holding the ontology view. + :returns: Tuple of ``(cell_line_matrix, fingerprint_matrix)``. + """ + return ( + np.asarray(batch.cell_line_blocks[active_view].values, dtype=np.float32), + np.asarray(batch.drug_blocks["fingerprints"].values, dtype=np.float32), + ) + + def _build_train_loader(self, batch: ModelInputBatch, blocks: tuple[np.ndarray, np.ndarray]) -> DataLoader: + """Build the shuffled training loader over the entity matrices in *blocks*. + + :param batch: Training batch supplying the pair indices and response. + :param blocks: Tuple of ``(cell_line_matrix, fingerprint_matrix)``. + :returns: Training loader yielding cell-line, drug and response tensors. + """ + cell_entity, drug_entity = blocks + return make_pair_loader( + (cell_entity, batch.cell_line_pair_idx), + (drug_entity, require_drug_pair_idx(batch.drug_pair_idx)), + response=np.asarray(batch.response, dtype=np.float32).reshape(-1, 1), + batch_size=self._hyperparameters.get("batch_size", 10000), + shuffle=True, + ) + + def _fit(self, batch: ModelInputBatch) -> None: + """Train SparseGO on the batch. + + :param batch: Training batch with responses and cell-line/drug blocks. + :raises ValueError: If ontology metadata is missing or network build fails. + """ + active_view = _resolve_active_view(batch) + block = batch.cell_line_blocks[active_view] + if block.metadata is None: + raise ValueError("SparseGOPredictor requires ontology metadata on its active cell-line block") + + self._layer_connections, self._gene2id_mapping_ont, self._ontology_gene_order = _parse_ontology_metadata( + dict(block.metadata) + ) + + blocks = self._entity_matrices(batch, active_view) + self._hyperparameters["drug_dim"] = int(blocks[1].shape[1]) + self._build_network() + if self._model is None: + msg = "SparseGO network build failed" + raise ValueError(msg) + + self._run_training(self._model, self._build_train_loader(batch, blocks)) + + def _run_training(self, model: SparseGONetwork, loader: DataLoader) -> None: + """Run the SGD training loop with the paper's per-epoch learning-rate decay. + + :param model: The network to train. + :param loader: Training loader over cell-line, drug and response tensors. + """ + import torch + import torch.nn as nn + + lr = self._hyperparameters.get("learning_rate", 0.1) + decay_rate = self._hyperparameters.get("decay_rate", 0.002) + criterion = nn.MSELoss() + optimizer = torch.optim.SGD( + model.parameters(), + lr=lr, + momentum=self._hyperparameters.get("momentum", 0.9), + ) + + model.train() + for epoch in range(self._hyperparameters.get("epochs", 100)): + for param_group in optimizer.param_groups: + param_group["lr"] = lr * (1 / (1 + decay_rate * epoch)) + + for cell_feats, drug_feats, batch_labels in loader: + batch_features = torch.cat([cell_feats, drug_feats], dim=1).to(self._device) + optimizer.zero_grad() + loss = criterion(model(batch_features), batch_labels.to(self._device)) + loss.backward() + optimizer.step() + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict drug response for pairs in the batch. + + :param batch: Featurized pairs to score. + :returns: Predicted response values as a 1D numpy array. + :raises ValueError: If the model is not fitted. + """ + if self._model is None: + raise ValueError("SparseGOPredictor must be fitted before predict().") + + cell_entity, drug_entity = self._entity_matrices(batch, _resolve_active_view(batch)) + return predict_pairs( + self._model, + batch, + PairEvalSpec( + cell_line_blocks=(cell_entity,), + drug_blocks=(drug_entity,), + batch_size=self._hyperparameters.get("batch_size", 10000), + device=self._device, + ), + forward=concatenated_forward(self._model), + ) + + def is_fitted(self) -> bool: + """Return whether the predictor has been trained. + + :returns: True if the model is built and trained. + """ + return self._model is not None + + def get_state(self) -> dict[str, object]: + """Serialize fitted predictor state. + + :returns: Mapping with a binary payload blob when fitted, else empty. + """ + if self._model is None: + return {} + + payload: dict[str, Any] = { + "predictor_hyperparameters": dict(self._hyperparameters), + "preload": { + "layer_connections": self._layer_connections, + "gene2id_mapping_ont": self._gene2id_mapping_ont, + "ontology_gene_order": self._ontology_gene_order, + }, + "model_state": save_state_dict(self._model.state_dict()), + } + return {"payload": save_trusted_mapping(payload)} + + def set_state(self, state: dict[str, object]) -> None: + """Restore a predictor from get_state output. + + :param state: Serialized state containing a payload byte blob. + :raises PredictorStateError: If the payload is missing or invalid. + """ + blob = state.get("payload") + if not isinstance(blob, (bytes, bytearray)): + msg = "SparseGOPredictor state requires a payload byte blob" + raise PredictorStateError(msg) + try: + payload = load_trusted_mapping(bytes(blob)) + except Exception as exc: + msg = "SparseGOPredictor payload could not be deserialized" + raise PredictorStateError(msg) from exc + + hyperparameters = payload.get("predictor_hyperparameters") + if not isinstance(hyperparameters, dict): + msg = "SparseGOPredictor payload is missing predictor_hyperparameters" + raise PredictorStateError(msg) + self._hyperparameters = dict(hyperparameters) + + preload = payload.get("preload") + if isinstance(preload, dict): + self._layer_connections = preload.get("layer_connections") + self._gene2id_mapping_ont = preload.get("gene2id_mapping_ont") + self._ontology_gene_order = preload.get("ontology_gene_order") + + self._build_network() + + model_state = payload.get("model_state") + if isinstance(model_state, (bytes, bytearray)) and self._model is not None: + self._model.load_state_dict(load_state_dict(bytes(model_state))) diff --git a/drevalpy/models/SparseGO/utils.py b/drevalpy/components/predictors/literature/sparsego/utils.py similarity index 65% rename from drevalpy/models/SparseGO/utils.py rename to drevalpy/components/predictors/literature/sparsego/utils.py index 55346a0c9..539d5c409 100644 --- a/drevalpy/models/SparseGO/utils.py +++ b/drevalpy/components/predictors/literature/sparsego/utils.py @@ -10,16 +10,18 @@ import networkx.algorithms.dag as nxadag import numpy as np import pandas as pd +from upath import UPath as Path -def load_mapping(mapping_file: str) -> dict: +def load_mapping(mapping_file: str | Path) -> dict: """Open a txt file with two columns and return a dictionary. The second column becomes the key and the first column becomes the value. Used to read gene2ind.txt and drug2ind.txt files. :param mapping_file: Path to the mapping txt file. - :return: Dictionary mapping names (e.g. gene symbols) to integer indices. + + :returns: Dictionary mapping names (e.g. gene symbols) to integer indices. """ mapping = {} with open(mapping_file) as file_handle: @@ -29,89 +31,109 @@ def load_mapping(mapping_file: str) -> dict: return mapping -def load_ontology(ontology_file: str, gene2id_mapping: dict) -> tuple: - """Create the directed graph of GO terms and store connected elements in arrays. - - The ontology file is tab-delimited with three columns: - - Column 1: parent term or gene - - Column 2: child term or gene - - Column 3: 'default' for term-term connections, 'gene' for term-gene annotations - - :param ontology_file: Path to the ontology file (e.g. sparseGO_ont.txt). - :param gene2id_mapping: Dictionary mapping gene names to integer IDs. - :return: Tuple of (directed graph, term-term pairs array, gene-term pairs array). - """ - dG = nx.DiGraph() +def _read_ontology_edges( + ontology_file: str | Path, gene2id_mapping: dict +) -> tuple[nx.DiGraph, list[list[str]], list[list[str]], set[str], dict[str, set[int]]]: + directed_graph = nx.DiGraph() terms_pairs: list[list[str]] = [] genes_terms_pairs: list[list[str]] = [] gene_set: set[str] = set() term_direct_gene_map: dict[str, set[int]] = {} - term_size_map: dict[str, int] = {} with open(ontology_file) as file_handle: for line in file_handle: line_parts = line.rstrip().split() if line_parts[2] == "default": - dG.add_edge(line_parts[0], line_parts[1]) + directed_graph.add_edge(line_parts[0], line_parts[1]) terms_pairs.append([line_parts[0], line_parts[1]]) - else: - if line_parts[1] not in gene2id_mapping: - continue - genes_terms_pairs.append([line_parts[0], line_parts[1]]) - if line_parts[0] not in term_direct_gene_map: - term_direct_gene_map[line_parts[0]] = set() - term_direct_gene_map[line_parts[0]].add(gene2id_mapping[line_parts[1]]) - gene_set.add(line_parts[1]) + continue - terms_pairs_arr: np.ndarray = np.array(terms_pairs) - genes_terms_pairs_arr: np.ndarray = np.array(genes_terms_pairs) + if line_parts[1] not in gene2id_mapping: + continue + genes_terms_pairs.append([line_parts[0], line_parts[1]]) + term_direct_gene_map.setdefault(line_parts[0], set()).add(gene2id_mapping[line_parts[1]]) + gene_set.add(line_parts[1]) - print(f"There are {len(gene_set)} genes") + return directed_graph, terms_pairs, genes_terms_pairs, gene_set, term_direct_gene_map - for term in dG.nodes(): - term_gene_set: set[int] = set() - if term in term_direct_gene_map: - term_gene_set = term_direct_gene_map[term] - deslist = nxadag.descendants(dG, term) - for child in deslist: + +def _annotate_term_gene_counts(directed_graph: nx.DiGraph, term_direct_gene_map: dict[str, set[int]]) -> dict[str, int]: + term_size_map: dict[str, int] = {} + for term in directed_graph.nodes(): + term_gene_set: set[int] = set(term_direct_gene_map.get(term, set())) + for child in nxadag.descendants(directed_graph, term): if child in term_direct_gene_map: - term_gene_set = term_gene_set | term_direct_gene_map[child] + term_gene_set |= term_direct_gene_map[child] if len(term_gene_set) == 0: print(f"There is empty terms, please delete term: {term}") sys.exit(1) - else: - term_size_map[term] = len(term_gene_set) + term_size_map[term] = len(term_gene_set) + return term_size_map + - leaves = [n for n in dG.nodes if dG.in_degree(n) == 0] - uG = dG.to_undirected() - connected_subG_list = list(nxacc.connected_components(uG)) +def _validate_ontology_topology(directed_graph: nx.DiGraph) -> None: + leaves = [n for n in directed_graph.nodes if directed_graph.in_degree(n) == 0] + undirected_graph = directed_graph.to_undirected() + connected_subgraphs = list(nxacc.connected_components(undirected_graph)) print(f"There are {len(leaves)} roots: {leaves[0]}") - print(f"There are {len(dG.nodes())} terms") - print(f"There are {len(connected_subG_list)} connected components") + print(f"There are {len(directed_graph.nodes())} terms") + print(f"There are {len(connected_subgraphs)} connected components") if len(leaves) > 1: print("There are more than 1 root of ontology. Please use only one root.") sys.exit(1) - if len(connected_subG_list) > 1: + if len(connected_subgraphs) > 1: print("There are more than connected components. Please connect them.") sys.exit(1) - return dG, terms_pairs_arr, genes_terms_pairs_arr + +def load_ontology(ontology_file: str | Path, gene2id_mapping: dict) -> tuple: + """Create the directed graph of GO terms and store connected elements in arrays. + + The ontology file is tab-delimited with three columns: + - Column 1: parent term or gene + - Column 2: child term or gene + - Column 3: 'default' for term-term connections, 'gene' for term-gene annotations + + :param ontology_file: Path to the ontology file (e.g. sparseGO_ont.txt). + :param gene2id_mapping: Dictionary mapping gene names to integer IDs. + + :returns: Tuple of (directed graph, term-term pairs array, gene-term pairs array). + """ + directed_graph, terms_pairs, genes_terms_pairs, gene_set, term_direct_gene_map = _read_ontology_edges( + ontology_file, gene2id_mapping + ) + + terms_pairs_arr: np.ndarray = np.array(terms_pairs) + genes_terms_pairs_arr: np.ndarray = np.array(genes_terms_pairs) + + print(f"There are {len(gene_set)} genes") + + _annotate_term_gene_counts(directed_graph, term_direct_gene_map) + _validate_ontology_topology(directed_graph) + + return directed_graph, terms_pairs_arr, genes_terms_pairs_arr -def sort_pairs(genes_terms_pairs: np.ndarray, terms_pairs: np.ndarray, dG: nx.DiGraph, gene2id_mapping: dict) -> tuple: +def sort_pairs( + genes_terms_pairs: np.ndarray, + terms_pairs: np.ndarray, + directed_graph: nx.DiGraph, + gene2id_mapping: dict, +) -> tuple: """Concatenate and sort all pairs so the parent term is always in the first column. :param genes_terms_pairs: Array of (term, gene) pairs. :param terms_pairs: Array of (parent_term, child_term) pairs. - :param dG: Directed graph of GO terms. + :param directed_graph: Directed graph of GO terms. :param gene2id_mapping: Dictionary mapping gene names to integer IDs. - :return: Tuple of (sorted_pairs, level_list, level_number). + + :returns: Tuple of (sorted_pairs, level_list, level_number). """ all_pairs = np.concatenate((genes_terms_pairs, terms_pairs)) - graph = dG.copy() + graph = directed_graph.copy() level_list = [] level_list.append(list(gene2id_mapping.keys())) # genes are level 0 @@ -149,7 +171,8 @@ def pairs_in_layers(sorted_pairs: np.ndarray, level_list: list, level_number: di :param sorted_pairs: Array of (parent, child) pairs sorted by level. :param level_list: List of term sets at each level. :param level_number: Dictionary mapping each term/gene to its level index. - :return: List of numpy arrays, one per layer, each containing (parent, child) pairs. + + :returns: List of numpy arrays, one per layer, each containing (parent, child) pairs. """ total_layers = len(level_list) - 1 layer_connections: list[list | np.ndarray] = [[] for _ in range(total_layers)] @@ -178,7 +201,8 @@ def create_index(array: np.ndarray) -> dict: """Create a dictionary mapping unique elements to sequential integer indices. :param array: Array of elements (may contain duplicates). - :return: Dictionary mapping each unique element to an integer index. + + :returns: Dictionary mapping each unique element to an integer index. """ unique_array = pd.unique(array) return {element: i for i, element in enumerate(unique_array)} diff --git a/drevalpy/models/SRMF/__init__.py b/drevalpy/components/predictors/literature/srmf/__init__.py similarity index 100% rename from drevalpy/models/SRMF/__init__.py rename to drevalpy/components/predictors/literature/srmf/__init__.py diff --git a/drevalpy/components/predictors/literature/srmf/predictor.py b/drevalpy/components/predictors/literature/srmf/predictor.py new file mode 100644 index 000000000..5efed5a7a --- /dev/null +++ b/drevalpy/components/predictors/literature/srmf/predictor.py @@ -0,0 +1,383 @@ +"""SRMF block predictor – Similarity Regularization Matrix Factorization. + +Original publication: Wang, L., Li, X., Zhang, L. et al. Improved anticancer drug response prediction in cell lines +using matrix factorization with similarity regularization. BMC Cancer 17, 513 (2017). +https://doi.org/10.1186/s12885-017-3500-5. +Matlab code adapted from https://github.com/linwang1982/SRMF. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.literature._metadata import SRMF_REFERENCE +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.utils.torch_io import load_trusted_mapping, save_trusted_mapping + +if TYPE_CHECKING: + import pandas as pd + + +@register( + "srmf", + description="SRMF matrix factorization model.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + reference=SRMF_REFERENCE, +) +class SRMFPredictor(BlockPredictor): + """Registered SRMF predictor using similarity-regularized matrix factorization.""" + + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ("gene_expression",) + required_drug_blocks: ClassVar[tuple[str, ...]] = ("fingerprints",) + supports_early_stopping: ClassVar[bool] = False + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize SRMF predictor. + + :param hyperparameters: Optional hyperparameter overrides. + """ + import pandas as pd + + super().__init__(hyperparameters) + self._best_u: pd.DataFrame = pd.DataFrame() + self._best_v: pd.DataFrame = pd.DataFrame() + self._training_mean: float = 0.0 + + @property + def _k(self) -> int: + return int(self._hyperparameters.get("K", 45)) + + @property + def _lambda_l(self) -> float: + return float(self._hyperparameters.get("lambda_l", 0.01)) + + @property + def _lambda_d(self) -> float: + return float(self._hyperparameters.get("lambda_d", 0.0)) + + @property + def _lambda_c(self) -> float: + return float(self._hyperparameters.get("lambda_c", 0.01)) + + @property + def _max_iter(self) -> int: + return int(self._hyperparameters.get("max_iter", 50)) + + @property + def _seed(self) -> int: + return int(self._hyperparameters.get("seed", 1)) + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Return default SRMF hyperparameters. + + :returns: Default hyperparameter mapping. + """ + return { + "K": 45, + "lambda_l": 0.01, + "lambda_d": 0.0, + "lambda_c": 0.01, + "max_iter": 50, + "seed": 1, + "n_features": 1036, + } + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Return tunable hyperparameter space. + + :returns: Ray Tune-style hyperparameter specs. + """ + return {} + + # ------------------------------------------------------------------ + # Fit + # ------------------------------------------------------------------ + + def _fit(self, batch: ModelInputBatch) -> None: + """Fit the SRMF model on training data. + + :param batch: Training batch with gene_expression and fingerprints blocks. + :raises ValueError: If drug features are missing. + """ + import pandas as pd + + cell_lines = batch.cell_line_entity_ids + drugs = batch.drug_entity_ids + if drugs is None: + msg = "SRMF requires drug features" + raise ValueError(msg) + + response_matrix = self._response_matrix(batch, cell_lines, drugs) + observed = ~np.isnan(response_matrix) + response_matrix_filled = np.where(observed, response_matrix, 0.0) + + # _cmf works in (drugs x cell_lines) orientation, hence the transpositions. + best_u, best_v = self._cmf( + w=observed.T, + int_mat=response_matrix_filled.T, + drug_mat=self._drug_similarity(batch.drug_blocks["fingerprints"].values), + cell_mat=np.corrcoef(batch.cell_line_blocks["gene_expression"].values, rowvar=True), + ) + + self._best_u = pd.DataFrame(best_u, index=[str(d) for d in drugs]) + self._best_v = pd.DataFrame(best_v, index=[str(c) for c in cell_lines]) + self._training_mean = float(np.nanmean(batch.response)) + + @staticmethod + def _drug_similarity(drug_features: np.ndarray) -> np.ndarray: + """Compute pairwise Jaccard similarity over binary fingerprints. + + :param drug_features: Binary fingerprint matrix, one row per drug. + :returns: Symmetric similarity matrix with ones on the diagonal. + """ + from scipy.spatial.distance import jaccard + + n_drugs = len(drug_features) + similarity = np.eye(n_drugs, dtype=np.float64) + for i in range(n_drugs): + for j in range(i + 1, n_drugs): + similarity[i, j] = similarity[j, i] = 1.0 - jaccard(drug_features[i], drug_features[j]) + return similarity + + @staticmethod + def _response_matrix( + batch: ModelInputBatch, + cell_lines: np.ndarray, + drugs: np.ndarray, + ) -> np.ndarray: + """Pivot the pair-level response into a cell-line x drug matrix. + + Duplicate pairs are averaged, matching the grouped mean the original pandas + implementation took. Unobserved cells stay ``NaN`` so the caller can derive the + weight mask from them. + + :param batch: Training batch with response data. + :param cell_lines: Cell-line entity ids, in row order. + :param drugs: Drug entity ids, in column order. + :returns: Response matrix with ``NaN`` for unobserved pairs. + :raises ValueError: If batch has no response data. + """ + if batch.response is None: + msg = "SRMF requires training response data" + raise ValueError(msg) + + cl_id_to_idx = {str(cid): i for i, cid in enumerate(cell_lines)} + dr_id_to_idx = {str(did): i for i, did in enumerate(drugs)} + sums: dict[tuple[int, int], float] = {} + counts: dict[tuple[int, int], int] = {} + for pair_idx in range(batch.n_pairs): + cl_idx = cl_id_to_idx.get(str(batch.cell_line_ids[pair_idx])) + dr_idx = dr_id_to_idx.get(str(batch.drug_ids[pair_idx])) + if cl_idx is None or dr_idx is None: + continue + key = (cl_idx, dr_idx) + sums[key] = sums.get(key, 0.0) + batch.response[pair_idx] + counts[key] = counts.get(key, 0) + 1 + + matrix = np.full((len(cell_lines), len(drugs)), np.nan, dtype=np.float64) + for (ci, di), total in sums.items(): + matrix[ci, di] = total / counts[(ci, di)] + return matrix + + # ------------------------------------------------------------------ + # Predict + # ------------------------------------------------------------------ + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict responses using learned latent factors. + + :param batch: Featurized pairs to score. + :returns: One predicted response per pair. + """ + drug_ids = batch.drug_ids + cell_line_ids = batch.cell_line_ids + + best_u = np.full((len(drug_ids), self._k), self._training_mean) + for idx, drug in enumerate(drug_ids): + key = str(drug) + if key in self._best_u.index: + best_u[idx, :] = self._best_u.loc[key].values + + best_v = np.full((len(cell_line_ids), self._k), self._training_mean) + for idx, cell in enumerate(cell_line_ids): + key = str(cell) + if key in self._best_v.index: + best_v[idx, :] = self._best_v.loc[key].values + + return np.einsum("ij,ji->i", best_u, best_v.T) + + # ------------------------------------------------------------------ + # State serialization + # ------------------------------------------------------------------ + + def is_fitted(self) -> bool: + """Return whether the predictor has been fit. + + :returns: True when latent factors have been learned. + """ + return not self._best_u.empty + + def get_state(self) -> dict[str, object]: + """Serialize fitted predictor state. + + :returns: Mapping with binary payload blob. + """ + if not self.is_fitted(): + return {} + payload: dict[str, Any] = { + "best_u": self._best_u.to_dict(orient="split"), + "best_v": self._best_v.to_dict(orient="split"), + "training_mean": self._training_mean, + "predictor_hyperparameters": dict(self._hyperparameters), + } + return {"payload": save_trusted_mapping(payload)} + + def set_state(self, state: dict[str, object]) -> None: + """Restore predictor from get_state output. + + :param state: Serialized state containing a payload byte blob. + :raises PredictorStateError: If payload is missing or invalid. + """ + blob = state.get("payload") + if not isinstance(blob, (bytes, bytearray)): + msg = f"{self.__class__.__name__} state requires a payload byte blob" + raise PredictorStateError(msg) + try: + payload = load_trusted_mapping(bytes(blob)) + except Exception as exc: + msg = f"{self.__class__.__name__} payload could not be deserialized" + raise PredictorStateError(msg) from exc + hyperparameters = payload.get("predictor_hyperparameters") + if not isinstance(hyperparameters, dict): + msg = f"{self.__class__.__name__} payload is missing predictor_hyperparameters" + raise PredictorStateError(msg) + import pandas as pd + + self._hyperparameters = dict(hyperparameters) + self._best_u = pd.DataFrame(**payload["best_u"]) + self._best_v = pd.DataFrame(**payload["best_v"]) + self._training_mean = float(payload.get("training_mean", 0.0)) + + # ------------------------------------------------------------------ + # SRMF algorithm internals + # ------------------------------------------------------------------ + + def _cmf( + self, + w: np.ndarray, + int_mat: np.ndarray, + drug_mat: np.ndarray, + cell_mat: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray]: + """Collective matrix factorization with similarity regularization. + + :param w: Binary weight matrix (drugs x cell_lines). + :param int_mat: Response interaction matrix (drugs x cell_lines). + :param drug_mat: Drug-drug similarity matrix. + :param cell_mat: Cell-line similarity matrix. + :returns: Best drug and cell-line latent factors. + """ + rng = np.random.default_rng(self._seed) + m, n = w.shape + u0 = np.sqrt(1 / self._k) * rng.standard_normal(size=(m, self._k)) + v0 = np.sqrt(1 / self._k) * rng.standard_normal(size=(n, self._k)) + + best_u, best_v = u0, v0 + + last_loss = self._compute_loss(u0, v0, w, int_mat, drug_mat, cell_mat) + best_loss = last_loss + wr = w * int_mat + + for _ in range(self._max_iter): + u = self._alg_update(u0, v0, w, wr, drug_mat, self._lambda_l, self._lambda_d) + v = self._alg_update(v0, u, w.T, wr.T, cell_mat, self._lambda_l, self._lambda_c) + curr_loss = self._compute_loss(u, v, w, int_mat, drug_mat, cell_mat) + + if curr_loss < best_loss: + best_u, best_v = u, v + best_loss = curr_loss + + delta_loss = (curr_loss - last_loss) / last_loss + if abs(delta_loss) < 1e-6: + break + + last_loss = curr_loss + u0, v0 = u, v + + return best_u, best_v + + def _compute_loss( + self, + u: np.ndarray, + v: np.ndarray, + w: np.ndarray, + int_mat: np.ndarray, + drug_mat: np.ndarray, + cell_mat: np.ndarray, + ) -> np.float64: + """Compute SRMF loss including similarity regularization. + + :param u: Drug latent factors. + :param v: Cell-line latent factors. + :param w: Binary weight matrix. + :param int_mat: Response interaction matrix. + :param drug_mat: Drug-drug similarity matrix. + :param cell_mat: Cell-line similarity matrix. + :returns: Total loss value. + """ + loss = np.sum((w * (int_mat - np.dot(u, v.T))) ** 2) + loss += self._lambda_l * (np.sum(u**2) + np.sum(v**2)) + loss += self._lambda_d * np.sum((drug_mat - np.dot(u, u.T)) ** 2) + loss += self._lambda_c * np.sum((cell_mat - np.dot(v, v.T)) ** 2) + return loss + + @staticmethod + def _alg_update( + u: np.ndarray, + v: np.ndarray, + w: np.ndarray, + r: np.ndarray, + s: np.ndarray, + lambda_l: float, + lambda_d: float, + ) -> np.ndarray: + """SRMF alternating update rule for latent factor matrix. + + :param u: Current latent factor matrix to update. + :param v: Other latent factor matrix (fixed). + :param w: Binary weight matrix. + :param r: Weighted response matrix. + :param s: Similarity matrix for regularization. + :param lambda_l: L2 regularization weight. + :param lambda_d: Similarity regularization weight. + :returns: Updated latent factor matrix. + """ + x = np.dot(r, v) + 2 * lambda_d * np.dot(s, u) + y = 2 * lambda_d * np.dot(u.T, u) + u0 = np.zeros_like(u) + d = np.dot(v.T, v) + m, _ = w.shape + + for i in range(m): + ii = np.where(w[i, :] > 0)[0] + if ii.size == 0: + b = y + lambda_l * np.eye(u.shape[1]) + elif ii.size == w.shape[1]: + b = d + y + lambda_l * np.eye(u.shape[1]) + else: + a = np.dot(v[ii, :].T, v[ii, :]) + b = a + y + lambda_l * np.eye(u.shape[1]) + + u0[i, :] = np.linalg.solve(b, x[i, :]) + return u0 diff --git a/drevalpy/models/SuperFELTR/__init__.py b/drevalpy/components/predictors/literature/superfeltr/__init__.py similarity index 100% rename from drevalpy/models/SuperFELTR/__init__.py rename to drevalpy/components/predictors/literature/superfeltr/__init__.py diff --git a/drevalpy/components/predictors/literature/superfeltr/predictor.py b/drevalpy/components/predictors/literature/superfeltr/predictor.py new file mode 100644 index 000000000..020e5220d --- /dev/null +++ b/drevalpy/components/predictors/literature/superfeltr/predictor.py @@ -0,0 +1,453 @@ +"""SuperFELTR literature predictor consuming ModelInputBatch directly.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.literature._metadata import SUPERFELTR_REFERENCE +from drevalpy.components.predictors.literature._single_drug_omics import ( + OmicFeatureNames, + aligned_pair_matrices, + feature_names_from_payload, + feature_names_payload, + iter_drug_subsets, + omic_feature_names, + omic_matrices, + validation_split, +) +from drevalpy.components.predictors.single_drug_routing import ( + require_known_training_keys, + routing_keys, +) +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.enums.model_scope import ModelScope +from drevalpy.utils.torch_io import load_state_dict as _load_torch_state_dict +from drevalpy.utils.torch_io import ( + load_trusted_mapping, + save_state_dict, + save_trusted_mapping, +) + +# .utils imports pytorch_lightning at module scope, and this module is imported +# eagerly by register_builtin_components(), so the encoder/regressor imports are +# deferred to the methods that construct them. Guarded by +# tests/test_import_cost_policy.py. +if TYPE_CHECKING: + from drevalpy.components.predictors.literature._omics_loaders import OmicsSplit + + from .utils import SuperFELTEncoder, SuperFELTRegressor + +#: Encoder omic types, in the order the regressor concatenates their outputs. Paired +#: with the ``out_dim_*`` hyperparameter naming each one's width. +_ENCODER_OMIC_TYPES = ("expression", "mutation", "copy_number_variation_gistic") +_ENCODER_DIM_KEYS = ("out_dim_expr_encoder", "out_dim_mutation_encoder", "out_dim_cnv_encoder") + + +class _DrugModel: + """Per-drug fitted model state for SuperFELTR.""" + + __slots__ = ("expr_encoder", "mut_encoder", "cnv_encoder", "regressor", "ranges", "best_checkpoint") + + def __init__( + self, + expr_encoder: SuperFELTEncoder | None = None, + mut_encoder: SuperFELTEncoder | None = None, + cnv_encoder: SuperFELTEncoder | None = None, + regressor: SuperFELTRegressor | None = None, + ranges: tuple[float, float] = (0.0, 1.0), + best_checkpoint: object = None, + ) -> None: + """Initialize per-drug model components. + + :param expr_encoder: Expression encoder module. + :param mut_encoder: Mutation encoder module. + :param cnv_encoder: CNV encoder module. + :param regressor: Final regressor module. + :param ranges: Response normalization range. + :param best_checkpoint: Best training checkpoint reference. + """ + self.expr_encoder = expr_encoder + self.mut_encoder = mut_encoder + self.cnv_encoder = cnv_encoder + self.regressor = regressor + self.ranges = ranges + self.best_checkpoint = best_checkpoint + + +@register( + "superfeltr", + description="SuperFELTR single-drug multi-omics model.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + reference=SUPERFELTR_REFERENCE, +) +class SuperFELTRPredictor(BlockPredictor): + """SuperFELTR predictor: per-drug multi-omics with independent encoders.""" + + scope: ClassVar[ModelScope] = ModelScope.SINGLE_DRUG + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ( + "gene_expression", + "mutations", + "copy_number_variation_gistic", + ) + required_drug_blocks: ClassVar[tuple[str, ...]] = ("identity",) + required_cell_line_block_specs: ClassVar[tuple[BlockSpec, ...]] = ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("mutations", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("copy_number_variation_gistic", FeatureFormat.NUMERIC_MATRIX), + ) + required_drug_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("identity", FeatureFormat.NUMERIC_MATRIX),) + validate_drug_graphs: ClassVar[bool] = False + supports_early_stopping: ClassVar[bool] = True + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize predictor with optional hyperparameter overrides. + + :param hyperparameters: Optional hyperparameter overrides. + """ + super().__init__(hyperparameters) + self._drug_models: dict[str, _DrugModel] = {} + self._feature_names: dict[str, OmicFeatureNames] = {} + + def _fit(self, batch: ModelInputBatch) -> None: + """Train per-drug SuperFELTR models. + + :param batch: Training batch with all required cell-line omics blocks. + """ + require_known_training_keys(routing_keys(batch)) + self._drug_models = {} + self._feature_names = {} + + for drug_id, sub in iter_drug_subsets(batch): + self._fit_single_drug(drug_id, sub) + + def _fit_single_drug(self, drug_id: str, batch: ModelInputBatch) -> None: + """Fit a single-drug SuperFELTR model including encoders and regressor. + + :param drug_id: Identifier of the drug to train on. + :param batch: Subset batch for this drug. + """ + self._feature_names[drug_id] = omic_feature_names(batch) + if batch.n_pairs == 0: + self._drug_models[drug_id] = _DrugModel() + return + + matrices = omic_matrices(batch) + response = np.asarray(batch.response, dtype=np.float32) + std = float(np.std(response)) + ranges = (std * 0.1, std) + + train = matrices.split(batch.cell_line_pair_idx, response) + val = validation_split(matrices, batch) + checkpoint_dir = str(batch.training_context.checkpoint_dir) + #: Below one mini-batch the training loader's ``drop_last`` empties it, so the + #: encoders and regressor stay randomly initialized rather than being fitted. + trainable = batch.n_pairs >= self._hyperparameters["mini_batch"] + + encoders = self._fit_encoders(matrices.widths(), ranges, train, val, checkpoint_dir, trainable) + regressor, best_checkpoint = self._fit_regressor(encoders, train, val, checkpoint_dir, trainable) + + self._drug_models[drug_id] = _DrugModel( + expr_encoder=encoders[0], + mut_encoder=encoders[1], + cnv_encoder=encoders[2], + regressor=regressor, + ranges=ranges, + best_checkpoint=best_checkpoint, + ) + + def _fit_encoders( + self, + widths: tuple[int, int, int], + ranges: tuple[float, float], + train: OmicsSplit, + val: OmicsSplit | None, + checkpoint_dir: str, + trainable: bool, + ) -> tuple[SuperFELTEncoder, SuperFELTEncoder, SuperFELTEncoder]: + """Train one independent encoder per omic view. + + Each encoder sees all three views in every batch and selects its own, so they + share the loaders even though they are fitted separately. + + :param widths: Input width of each omic view. + :param ranges: Positive and negative triplet-loss ranges. + :param train: Training split. + :param val: Validation split, or None. + :param checkpoint_dir: Directory the fits checkpoint into. + :param trainable: Whether there are enough pairs to fit at all. + :returns: The three encoders, in ``_ENCODER_OMIC_TYPES`` order. + """ + from .utils import SuperFELTEncoder, train_superfeltr_model + + encoders = [] + for omic_type, width in zip(_ENCODER_OMIC_TYPES, widths, strict=True): + encoder = SuperFELTEncoder( + input_size=width, + hpams=dict(self._hyperparameters), + omic_type=omic_type, + ranges=ranges, + ) + if trainable: + best_ckpt = train_superfeltr_model( + model=encoder, + hpams=dict(self._hyperparameters), + train=train, + val=val, + patience=5, + model_checkpoint_dir=checkpoint_dir, + ) + encoder = SuperFELTEncoder.load_from_checkpoint(best_ckpt.best_model_path) + encoders.append(encoder) + return encoders[0], encoders[1], encoders[2] + + def _fit_regressor( + self, + encoders: tuple[SuperFELTEncoder, SuperFELTEncoder, SuperFELTEncoder], + train: OmicsSplit, + val: OmicsSplit | None, + checkpoint_dir: str, + trainable: bool, + ) -> tuple[SuperFELTRegressor, object | None]: + """Train the regression head on top of the frozen encoders. + + :param encoders: The fitted encoders. + :param train: Training split. + :param val: Validation split, or None. + :param checkpoint_dir: Directory the fit checkpoints into. + :param trainable: Whether there are enough pairs to fit at all. + :returns: Tuple of the regressor and its best checkpoint, the latter ``None`` + when the fit was skipped. + """ + from .utils import SuperFELTRegressor, train_superfeltr_model + + input_size = self._regressor_input_size(self._hyperparameters) + regressor = SuperFELTRegressor( + input_size=input_size, + hpams=dict(self._hyperparameters), + encoders=encoders, + ) + if not trainable: + return regressor, None + + best_checkpoint = train_superfeltr_model( + model=regressor, + hpams=dict(self._hyperparameters), + train=train, + val=val, + patience=5, + model_checkpoint_dir=checkpoint_dir, + ) + if best_checkpoint is not None: + regressor = SuperFELTRegressor.load_from_checkpoint( + best_checkpoint.best_model_path, + input_size=input_size, + hpams=dict(self._hyperparameters), + encoders=encoders, + ) + return regressor, best_checkpoint + + @staticmethod + def _regressor_input_size(hpams: dict[str, Any]) -> int: + """Sum the three encoder output widths. + + :param hpams: Hyperparameters carrying the ``out_dim_*`` widths. + :returns: The regressor's input width. + """ + return sum(int(hpams[key]) for key in _ENCODER_DIM_KEYS) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict drug responses for pairs, routing to per-drug models. + + :param batch: Featurized pairs to score. + :returns: One predicted response per pair. + """ + keys = routing_keys(batch) + predictions = np.full(batch.n_pairs, np.nan, dtype=np.float64) + for drug_id in np.unique(keys): + if drug_id == "": + continue + dm = self._drug_models.get(str(drug_id)) + if dm is None or dm.regressor is None: + continue + mask = keys == drug_id + sub = batch.subset_pairs(mask) + preds = self._predict_single_drug(str(drug_id), dm, sub) + predictions[mask] = np.asarray(preds, dtype=np.float64).ravel() + return predictions + + def _predict_single_drug(self, drug_id: str, dm: _DrugModel, batch: ModelInputBatch) -> np.ndarray: + """Predict for a single drug model. + + :param drug_id: Drug identifier for feature alignment. + :param dm: Per-drug model container. + :param batch: Subset batch for this drug. + :returns: Predicted responses. + """ + feature_names = self._feature_names.get(drug_id) + if feature_names is None or dm.regressor is None: + return np.full(batch.n_pairs, np.nan) + + return np.atleast_1d(dm.regressor.predict(*aligned_pair_matrices(batch, feature_names))) + + def is_fitted(self) -> bool: + """Report whether trained models exist. + + :returns: True when at least one per-drug model has been fitted. + """ + return bool(self._drug_models) + + def get_state(self) -> dict[str, object]: + """Serialize fitted state for all per-drug models. + + :returns: Mapping with algorithm blobs and hyperparameters. + """ + if not self._drug_models: + return {} + algorithms: dict[str, bytes] = {} + for drug_id, dm in self._drug_models.items(): + fn = self._feature_names.get(drug_id) + algorithms[drug_id] = self._serialize_drug_model(dm, fn) + return { + "algorithms": algorithms, + "predictor_hyperparameters": dict(self._hyperparameters), + } + + def _serialize_drug_model(self, dm: _DrugModel, fn: OmicFeatureNames | None) -> bytes: + """Serialize a single per-drug model to bytes. + + :param dm: Per-drug model container. + :param fn: Associated feature name record. + :returns: Serialized payload bytes. + """ + payload: dict[str, Any] = { + "hyperparameters": dict(self._hyperparameters), + "ranges": dm.ranges, + "input_dims": self._compute_input_dims(fn), + **feature_names_payload(fn), + } + + for attr in ("expr_encoder", "mut_encoder", "cnv_encoder", "regressor"): + module = getattr(dm, attr, None) + if module is not None and hasattr(module, "state_dict"): + payload[f"{attr}_state"] = save_state_dict(module.state_dict()) + + return save_trusted_mapping(payload) + + @staticmethod + def _compute_input_dims(fn: OmicFeatureNames | None) -> dict[str, int | None]: + """Compute input dimension mapping from feature names. + + :param fn: Feature name record. + :returns: Mapping of omic type to dimension. + """ + names = fn.as_tuple() if fn is not None else (None, None, None) + keys = ("expression", "mutation", "cnv") + return {key: len(value) if value else None for key, value in zip(keys, names, strict=True)} + + def set_state(self, state: dict[str, object]) -> None: + """Restore fitted state from serialized algorithm blobs. + + :param state: State mapping from ``get_state``. + :raises PredictorStateError: If state is malformed. + """ + algorithms_blob = state.get("algorithms") + if not isinstance(algorithms_blob, dict): + msg = "SuperFELTRPredictor state requires an 'algorithms' mapping" + raise PredictorStateError(msg) + hyperparameters = state.get("predictor_hyperparameters") + if isinstance(hyperparameters, dict): + self._hyperparameters = dict(hyperparameters) + + self._drug_models = {} + self._feature_names = {} + for drug_id, blob in algorithms_blob.items(): + if not isinstance(blob, (bytes, bytearray)): + msg = f"SuperFELTRPredictor payload for {drug_id!r} must be bytes" + raise PredictorStateError(msg) + payload = load_trusted_mapping(bytes(blob)) + self._feature_names[str(drug_id)] = feature_names_from_payload(payload) + self._drug_models[str(drug_id)] = self._deserialize_drug_model(payload) + + def _deserialize_drug_model(self, payload: dict[str, Any]) -> _DrugModel: + """Reconstruct a _DrugModel from a deserialized payload. + + :param payload: Deserialized drug model payload. + :returns: Reconstructed per-drug model. + """ + hpams = payload.get("hyperparameters", dict(self._hyperparameters)) + ranges = payload.get("ranges", (0.0, 1.0)) + if isinstance(ranges, list): + ranges = tuple(ranges) + + input_dims = payload.get("input_dims", {}) + widths = tuple(input_dims.get(key) for key in ("expression", "mutation", "cnv")) + + dm = _DrugModel(ranges=ranges) + if all(isinstance(width, int) for width in widths): + self._rebuild_encoders_and_regressor(dm, hpams, ranges, widths, payload) + + return dm + + @classmethod + def _rebuild_encoders_and_regressor( + cls, + dm: _DrugModel, + hpams: dict[str, Any], + ranges: tuple[float, float], + widths: tuple[int, ...], + payload: dict[str, Any], + ) -> None: + """Rebuild encoder and regressor modules and load their state dicts. + + :param dm: Drug model to populate. + :param hpams: Hyperparameters for the modules. + :param ranges: Response normalization range. + :param widths: Input widths of the expression, mutation and CNV encoders. + :param payload: Deserialized payload with state blobs. + """ + from .utils import SuperFELTEncoder, SuperFELTRegressor + + encoders = tuple( + SuperFELTEncoder(input_size=width, hpams=hpams, omic_type=omic_type, ranges=ranges) + for omic_type, width in zip(_ENCODER_OMIC_TYPES, widths, strict=True) + ) + dm.expr_encoder, dm.mut_encoder, dm.cnv_encoder = encoders + dm.regressor = SuperFELTRegressor( + input_size=cls._regressor_input_size(hpams), + hpams=hpams, + encoders=encoders, + ) + + for attr in ("expr_encoder", "mut_encoder", "cnv_encoder", "regressor"): + module = getattr(dm, attr, None) + state_blob = payload.get(f"{attr}_state") + if module is not None and isinstance(state_blob, (bytes, bytearray)): + module.load_state_dict(_load_torch_state_dict(bytes(state_blob))) + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Return default hyperparameters. + + :returns: Default hyperparameter mapping. + """ + return { + "mini_batch": 55, + "dropout_rate": 0.5, + "weight_decay": 0.01, + "out_dim_expr_encoder": 256, + "out_dim_mutation_encoder": 32, + "out_dim_cnv_encoder": 64, + "epochs": 30, + "margin": 1.0, + "learning_rate": 0.01, + } diff --git a/drevalpy/models/SuperFELTR/utils.py b/drevalpy/components/predictors/literature/superfeltr/utils.py similarity index 69% rename from drevalpy/models/SuperFELTR/utils.py rename to drevalpy/components/predictors/literature/superfeltr/utils.py index de5073319..6b1f2402e 100644 --- a/drevalpy/models/SuperFELTR/utils.py +++ b/drevalpy/components/predictors/literature/superfeltr/utils.py @@ -1,22 +1,22 @@ """Utility functions for the SuperFELTR model.""" -import os -import secrets +from typing import cast import numpy as np import pytorch_lightning as pl import torch -from pytorch_lightning.callbacks import EarlyStopping, TQDMProgressBar from torch import nn +from upath import UPath as Path -from ...datasets.dataset import DrugResponseDataset, FeatureDataset -from ..lightning_metrics_mixin import RegressionMetricsMixin -from ..MOLIR.utils import create_dataset_and_loaders, generate_triplets_indices +from drevalpy.components.predictors.literature._lightning_training import LightningRun, run_lightning_fit +from drevalpy.components.predictors.literature._omics_loaders import OmicsSplit, make_omics_loaders +from drevalpy.components.predictors.literature.molir.utils import ( + generate_triplets_indices, +) class SuperFELTEncoder(pl.LightningModule): - """ - SuperFELT encoder definition for a single omic type, i.e., gene expression, mutation, or copy number variation. + """SuperFELT encoder definition for a single omic type, i.e., gene expression, mutation, or copy number variation. Very similar to MOLIEncoder, but with BatchNorm1d before ReLU. """ @@ -24,14 +24,15 @@ class SuperFELTEncoder(pl.LightningModule): def __init__( self, input_size: int, hpams: dict[str, int | float | dict], omic_type: str, ranges: tuple[float, float] ) -> None: - """ - Initializes the SuperFELTEncoder. + """Initializes the SuperFELTEncoder. Save_hyperparameters is turned on to facilitate loading the model from a checkpoint. + :param input_size: determined by the variance threshold feature selection :param hpams: hyperparameters for the model :param omic_type: gene expression, mutation, or copy number variation :param ranges: positive and negative ranges for the triplet loss + :raises ValueError: if the hyperparameters are not of the correct type """ super().__init__() @@ -60,17 +61,16 @@ def __init__( self.positive_range, self.negative_range = ranges def forward(self, x: torch.Tensor) -> torch.Tensor: - """ - Forward pass of the SuperFELTEncoder. + """Forward pass of the SuperFELTEncoder. :param x: input tensor + :returns: encoded tensor """ return self.encode(x) def configure_optimizers(self) -> torch.optim.Optimizer: - """ - Override the configure_optimizers method to use the Adam optimizer. + """Override the configure_optimizers method to use the Adam optimizer. :returns: Adam optimizer """ @@ -78,11 +78,12 @@ def configure_optimizers(self) -> torch.optim.Optimizer: return optimizer def _get_output_size(self, hpams: dict[str, int | float | dict]) -> int: - """ - Get the output size of the encoder based on the omic type from the hyperparameters. + """Get the output size of the encoder based on the omic type from the hyperparameters. :param hpams: hyperparameters for the model + :returns: output size of the encoder + :raises ValueError: if the output sizes are not of the correct type """ if ( @@ -101,13 +102,14 @@ def _get_output_size(self, hpams: dict[str, int | float | dict]) -> int: return output_size def _get_omic_data(self, data_expr: torch.Tensor, data_mut: torch.Tensor, data_cnv: torch.Tensor) -> torch.Tensor: - """ - Get the omic data based on the omic type. + """Get the omic data based on the omic type. :param data_expr: expression data :param data_mut: mutation data :param data_cnv: copy number variation data + :returns: the omic data + :raises ValueError: if the omic type is not recognized """ if self.omic_type == "expression": @@ -121,11 +123,11 @@ def _get_omic_data(self, data_expr: torch.Tensor, data_mut: torch.Tensor, data_c return data def _compute_loss(self, encoded: torch.Tensor, response: torch.Tensor) -> torch.Tensor: - """ - Computes the triplet loss. + """Computes the triplet loss. :param encoded: encoded data :param response: response data + :returns: triplet loss """ positive_indices, negative_indices = generate_triplets_indices( @@ -135,14 +137,15 @@ def _compute_loss(self, encoded: torch.Tensor, response: torch.Tensor) -> torch. return triplet_loss def training_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Tensor: - """ - Override the training_step method to compute the triplet loss. + """Override the training_step method to compute the triplet loss. :param batch: batch containing the omic data and response :param batch_idx: index of the batch + :returns: triplet loss """ data_expr, data_mut, data_cnv, response = batch + response = response.squeeze(-1) data = self._get_omic_data(data_expr, data_mut, data_cnv) encoded = self.encode(data) triplet_loss = self._compute_loss(encoded, response) @@ -150,14 +153,15 @@ def training_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Tens return triplet_loss def validation_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Tensor: - """ - Override the validation_step method to compute the triplet loss. + """Override the validation_step method to compute the triplet loss. :param batch: batch containing the omic data and response :param batch_idx: index of the batch + :returns: triplet loss """ data_expr, data_mut, data_cnv, response = batch + response = response.squeeze(-1) data = self._get_omic_data(data_expr, data_mut, data_cnv) encoded = self.encode(data) triplet_loss = self._compute_loss(encoded, response) @@ -165,9 +169,8 @@ def validation_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Te return triplet_loss -class SuperFELTRegressor(RegressionMetricsMixin, pl.LightningModule): - """ - SuperFELT regressor definition. +class SuperFELTRegressor(pl.LightningModule): + """SuperFELT regressor definition. Very similar to SuperFELT classifier, but with a regression loss and without the last sigmoid layer. """ @@ -178,14 +181,14 @@ def __init__( hpams: dict[str, int | float | dict], encoders: tuple[SuperFELTEncoder, SuperFELTEncoder, SuperFELTEncoder], ) -> None: - """ - Initializes the SuperFELTRegressor. + """Initializes the SuperFELTRegressor. The encoders are put in eval mode because they were fitted before. :param input_size: depends on the output of the encoders :param hpams: hyperparameters for the model :param encoders: the fitted encoders for the gene expression, mutation, and copy number variation data + :raises ValueError: if the hyperparameters are not of the correct type """ super().__init__() @@ -199,31 +202,47 @@ def __init__( self.regressor = nn.Sequential(nn.Linear(input_size, 1), nn.Dropout(hpams["dropout_rate"])) self.lr = float(hpams["learning_rate"]) self.weight_decay = float(hpams["weight_decay"]) - self.encoders = encoders - # put the encoders in eval mode + # Registered as a ModuleList so Lightning's device placement reaches the + # encoders too; a plain tuple left them behind on CPU while the regressor + # moved to the accelerator. + self.encoders = nn.ModuleList(encoders) + # The encoders were fitted beforehand, so freeze them and keep them in + # eval mode: their BatchNorm running stats and Dropout must stay fixed. for encoder in self.encoders: encoder.eval() + encoder.requires_grad_(False) self.regression_loss = nn.MSELoss() - # Initialize metrics storage for epoch-end R^2 and PCC computation - self._init_metrics_storage() + def train(self, mode: bool = True) -> "SuperFELTRegressor": + """Set training mode on the regressor while keeping the encoders in eval mode. - def forward(self, x: torch.Tensor) -> torch.Tensor: + Lightning calls ``train()`` when the fit loop starts, which would otherwise + reactivate the frozen encoders' BatchNorm updates and Dropout. + + :param mode: Whether to put the regressor into training mode. + :returns: self """ - Forward pass of the SuperFELTRegressor. + super().train(mode) + for encoder in self.encoders: + encoder.eval() + return self + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Forward pass of the SuperFELTRegressor. :param x: input tensor + :returns: predicted response """ return self.regressor(x) def predict(self, data_expr: np.ndarray, data_mut: np.ndarray, data_cnv: np.ndarray) -> np.ndarray: - """ - Predicts the response for the given input. + """Predicts the response for the given input. :param data_expr: expression data :param data_mut: mutation data :param data_cnv: copy number variation data + :returns: predicted response """ data_expr_tensor, data_mut_tensor, data_cnv_tensor = map( @@ -236,135 +255,111 @@ def predict(self, data_expr: np.ndarray, data_mut: np.ndarray, data_cnv: np.ndar return preds.squeeze().cpu().detach().numpy() def configure_optimizers(self) -> torch.optim.Optimizer: - """ - Override the configure_optimizers method to use the Adagrad optimizer. + """Override the configure_optimizers method to use the Adagrad optimizer. + + Only the regressor head is optimized; the encoders are frozen. :returns: Adagrad optimizer """ - return torch.optim.Adagrad(self.parameters(), lr=self.lr, weight_decay=self.weight_decay) + return torch.optim.Adagrad(self.regressor.parameters(), lr=self.lr, weight_decay=self.weight_decay) + + def _encoder(self, index: int) -> "SuperFELTEncoder": + """Return the registered encoder at ``index`` with its concrete type. + + ``nn.ModuleList.__getitem__`` is typed as ``Tensor | Module``, so the cast + keeps the ``encode`` calls below statically checkable. + + :param index: Position of the encoder in the registered list. + :returns: The encoder at that position. + """ + return cast("SuperFELTEncoder", self.encoders[index]) def _encode_and_concatenate( self, data_expr: torch.Tensor, data_mut: torch.Tensor, data_cnv: torch.Tensor ) -> torch.Tensor: - """ - Encodes the omic data and concatenates the encoded tensors. + """Encodes the omic data and concatenates the encoded tensors. :param data_expr: expression data :param data_mut: mutation data :param data_cnv: copy number variation data + :returns: concatenated encoded tensor """ - encoded_expr = self.encoders[0].encode(data_expr) - encoded_mut = self.encoders[1].encode(data_mut) - encoded_cnv = self.encoders[2].encode(data_cnv) + encoded_expr = self._encoder(0).encode(data_expr) + encoded_mut = self._encoder(1).encode(data_mut) + encoded_cnv = self._encoder(2).encode(data_cnv) return torch.cat((encoded_expr, encoded_mut, encoded_cnv), dim=1) def training_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Tensor: - """ - Override the training_step method to compute the regression loss. + """Override the training_step method to compute the regression loss. :param batch: batch containing the omic data and response :param batch_idx: index of the batch + :returns: regression loss """ data_expr, data_mut, data_cnv, response = batch + response = response.squeeze(-1) encoded = self._encode_and_concatenate(data_expr, data_mut, data_cnv) pred = self.regressor(encoded) loss = self.regression_loss(pred.squeeze(), response) self.log("train_loss", loss, on_step=False, on_epoch=True, prog_bar=True) - # Store predictions and targets for epoch-end metrics via mixin - self._store_predictions(pred.squeeze(), response, is_training=True) - return loss def validation_step(self, batch: list[torch.Tensor], batch_idx: int) -> torch.Tensor: - """ - Override the validation_step method to compute the regression loss. + """Override the validation_step method to compute the regression loss. :param batch: batch containing the omic data and response :param batch_idx: index of the batch + :returns: regression loss """ data_expr, data_mut, data_cnv, response = batch + response = response.squeeze(-1) encoded = self._encode_and_concatenate(data_expr, data_mut, data_cnv) pred = self.regressor(encoded) loss = self.regression_loss(pred.squeeze(), response) self.log("val_loss", loss, on_step=False, on_epoch=True, prog_bar=True) - # Store predictions and targets for epoch-end metrics via mixin - self._store_predictions(pred.squeeze(), response, is_training=False) - return loss def train_superfeltr_model( model: SuperFELTEncoder | SuperFELTRegressor, hpams: dict[str, int | float | dict], - output_train: DrugResponseDataset, - cell_line_input: FeatureDataset, - output_earlystopping: DrugResponseDataset | None = None, + train: OmicsSplit, + val: OmicsSplit | None = None, patience: int = 5, - model_checkpoint_dir: str = "superfeltr_checkpoints", + model_checkpoint_dir: str | Path = "superfeltr_checkpoints", wandb_project: str | None = None, ) -> pl.callbacks.ModelCheckpoint: - """ - Trains one encoder or the regressor. - - First, the dataset and loaders are created. Then, the model is trained with the Lightning trainer. + """Trains one encoder or the regressor using lazy pair-level lookup. :param model: either one of the encoders or the regressor :param hpams: hyperparameters for the model - :param output_train: response data for training - :param cell_line_input: cell line omics features - :param output_earlystopping: response data for early stopping + :param train: training split of the three omic views + :param val: validation split for early stopping, or None to monitor the training loss :param patience: for early stopping, defaults to 5 :param model_checkpoint_dir: directory to save the model checkpoints - :param wandb_project: optional wandb project name for logging. If provided, uses WandbLogger - for PyTorch Lightning training. + :param wandb_project: Optional Weights & Biases project name for Lightning logging. + :returns: checkpoint callback with the best model + :raises ValueError: if the epochs and mini_batch are not integers """ if not isinstance(hpams["epochs"], int) or not isinstance(hpams["mini_batch"], int): raise ValueError("epochs and mini_batch must be integers!") - train_loader, val_loader = create_dataset_and_loaders( - batch_size=hpams["mini_batch"], - output_train=output_train, - cell_line_input=cell_line_input, - output_earlystopping=output_earlystopping, - ) - monitor = "train_loss" if (val_loader is None) else "val_loss" - early_stop_callback = EarlyStopping(monitor=monitor, mode="min", patience=patience) - name = "version-" + "".join( - [secrets.choice("0123456789abcdef") for _ in range(20)] - ) # preventing conflicts of filenames - checkpoint_callback = pl.callbacks.ModelCheckpoint( - dirpath=os.path.join(model_checkpoint_dir, name), - monitor=monitor, - mode="min", - save_top_k=1, - ) - # Set up wandb logger if project is provided - loggers = [] - if wandb_project is not None: - from pytorch_lightning.loggers import WandbLogger - - logger = WandbLogger(project=wandb_project, log_model=False) - loggers.append(logger) - - # Initialize the Lightning trainer - trainer = pl.Trainer( - max_epochs=hpams["epochs"], - logger=loggers if loggers else True, # Use default logger if no wandb - callbacks=[ - early_stop_callback, - checkpoint_callback, - TQDMProgressBar(refresh_rate=0), - ], + train_loader, val_loader = make_omics_loaders(train, val, hpams["mini_batch"]) + return run_lightning_fit( + model, + train_loader, + val_loader, + LightningRun( + max_epochs=hpams["epochs"], + patience=patience, + checkpoint_dir=model_checkpoint_dir, + wandb_project=wandb_project, + ), ) - if val_loader is None: - trainer.fit(model, train_loader) - else: - trainer.fit(model, train_loader, val_loader) - return checkpoint_callback diff --git a/drevalpy/components/predictors/naive/__init__.py b/drevalpy/components/predictors/naive/__init__.py new file mode 100644 index 000000000..3a03c56fb --- /dev/null +++ b/drevalpy/components/predictors/naive/__init__.py @@ -0,0 +1,15 @@ +"""Naive baseline predictors.""" + +from .effects import NaiveMeanEffectsPredictor +from .entity_mean import NaiveCellLineMeanPredictor, NaiveDrugMeanPredictor +from .mean import NaiveMeanPredictor +from .tissue import NaiveTissueDrugMeanPredictor, NaiveTissueMeanPredictor + +__all__ = [ + "NaiveCellLineMeanPredictor", + "NaiveDrugMeanPredictor", + "NaiveMeanEffectsPredictor", + "NaiveMeanPredictor", + "NaiveTissueDrugMeanPredictor", + "NaiveTissueMeanPredictor", +] diff --git a/drevalpy/components/predictors/naive/_matrix_means.py b/drevalpy/components/predictors/naive/_matrix_means.py new file mode 100644 index 000000000..136977ab6 --- /dev/null +++ b/drevalpy/components/predictors/naive/_matrix_means.py @@ -0,0 +1,153 @@ +"""Matrix helpers for one-hot naive mean predictors.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +def pair_align(entity_matrix: np.ndarray, pair_idx: np.ndarray | None) -> np.ndarray: + """Index an entity-level feature matrix to pair rows. + + :param entity_matrix: entity matrix. + :param pair_idx: pair idx. + :returns: Result. + :raises ValueError: Raised on invalid input. + """ + matrix = np.asarray(entity_matrix) + if matrix.ndim == 1: + matrix = matrix.reshape(-1, 1) + if pair_idx is None: + msg = "pair index is required to align entity features" + raise ValueError(msg) + return matrix[np.asarray(pair_idx, dtype=np.int64)] + + +def require_pair_matrix( + batch: ModelInputBatch, + *, + side: str, +) -> np.ndarray: + """Return a pair-aligned dense matrix for the cell-line or drug side. + + :param batch: batch. + :param side: side. + :returns: Result. + :raises ValueError: Raised on invalid input. + """ + if side == "cell_line": + return pair_align(batch.cell_line_features, batch.cell_line_pair_idx) + if side == "drug": + if batch.drug_features is None: + msg = "drug features are required" + raise ValueError(msg) + return pair_align(batch.drug_features, batch.drug_pair_idx) + msg = f"Unknown feature side {side!r}" + raise ValueError(msg) + + +def block_pair_matrix(batch: ModelInputBatch, block_name: str) -> np.ndarray: + """Return a pair-aligned named cell-line block matrix. + + :param batch: batch. + :param block_name: block name. + :returns: Result. + :raises ValueError: Raised on invalid input. + """ + if block_name not in batch.cell_line_blocks: + msg = f"Required cell-line block {block_name!r} is missing" + raise ValueError(msg) + return pair_align(batch.cell_line_blocks[block_name].values, batch.cell_line_pair_idx) + + +def category_means(design: np.ndarray, y: np.ndarray) -> np.ndarray: + """Return per-column means for a one-hot design matrix. + + :param design: design. + :param y: y. + :returns: Result. + :raises ValueError: Raised on invalid input. + """ + matrix = np.asarray(design, dtype=np.float64) + if matrix.ndim != 2: + msg = "design matrix must be 2-dimensional" + raise ValueError(msg) + target = np.asarray(y, dtype=np.float64).reshape(-1) + if matrix.shape[0] != target.shape[0]: + msg = "design rows must match response length" + raise ValueError(msg) + if matrix.shape[1] == 0: + return np.empty((0,), dtype=np.float64) + counts = matrix.sum(axis=0) + sums = matrix.T @ target + means = np.zeros(matrix.shape[1], dtype=np.float64) + np.divide(sums, counts, out=means, where=counts > 0) + return means + + +def additive_effects(design: np.ndarray, y: np.ndarray, *, baseline: float) -> np.ndarray: + """Return additive effects relative to *baseline* for observed one-hot columns. + + :param design: design. + :param y: y. + :param baseline: baseline. + :returns: Result. + """ + matrix = np.asarray(design, dtype=np.float64) + if matrix.ndim != 2 or matrix.shape[1] == 0: + return category_means(design, y) + counts = matrix.sum(axis=0) + effects = category_means(design, y) - float(baseline) + return np.where(counts > 0, effects, 0.0) + + +def predict_with_effects(design: np.ndarray, effects: np.ndarray, *, baseline: float) -> np.ndarray: + """Predict baseline + design @ effects for a one-hot design matrix. + + :param design: design. + :param effects: effects. + :param baseline: baseline. + :returns: Result. + :raises ValueError: Raised on invalid input. + """ + matrix = np.asarray(design, dtype=np.float64) + coeffs = np.asarray(effects, dtype=np.float64).reshape(-1) + if matrix.ndim != 2: + msg = "design matrix must be 2-dimensional" + raise ValueError(msg) + if matrix.shape[1] == 0: + return np.full(matrix.shape[0], float(baseline), dtype=np.float64) + if matrix.shape[1] != coeffs.shape[0]: + msg = "effect vector length must match design columns" + raise ValueError(msg) + return float(baseline) + matrix @ coeffs + + +def state_float_vector(state: dict[str, object], key: str) -> np.ndarray | None: + """Restore a 1D float vector stored under *key*. + + :param state: state. + :param key: key. + :returns: Result. + """ + value = state.get(key) + if value is None: + return None + return np.asarray(value, dtype=np.float64).reshape(-1) + + +def state_float_matrix(state: dict[str, object], key: str) -> np.ndarray | None: + """Restore a 2D float matrix stored under *key*. + + :param state: state. + :param key: key. + :returns: Result. + """ + value = state.get(key) + if value is None: + return None + matrix = np.asarray(value, dtype=np.float64) + if matrix.ndim == 1: + return matrix.reshape(-1, 1) + return matrix diff --git a/drevalpy/components/predictors/naive/_single_entity.py b/drevalpy/components/predictors/naive/_single_entity.py new file mode 100644 index 000000000..2fc7db115 --- /dev/null +++ b/drevalpy/components/predictors/naive/_single_entity.py @@ -0,0 +1,52 @@ +"""Shared base for entity-level naive predictors.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.naive._matrix_means import ( + additive_effects, + predict_with_effects, + require_pair_matrix, +) +from drevalpy.components.predictors.naive._state_mixin import MeanEffectsStateMixin +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +class SingleEntityNaivePredictor(MeanEffectsStateMixin, BlockPredictor): + """Predict per-entity means from a one-hot design matrix.""" + + _feature_side: ClassVar[str] = "cell_line" + + state_effects: ClassVar[tuple[str, ...]] = ("effects",) + + _effects: np.ndarray | None + + def _fit(self, batch: ModelInputBatch) -> None: + """Fit on training data. + + :param batch: batch. + :raises RuntimeError: If batch.response is None. + """ + y = batch.response + if y is None: + raise RuntimeError("batch.response is required for fit") + design = require_pair_matrix(batch, side=self._feature_side) + self._dataset_mean = float(np.mean(y)) + self._effects = additive_effects(design, y, baseline=self._dataset_mean) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict responses for each pair in the batch. + + :param batch: batch. + :returns: Result. + :raises RuntimeError: Raised on invalid input. + """ + if self._dataset_mean is None or self._effects is None: + msg = "Call fit before predict" + raise RuntimeError(msg) + design = require_pair_matrix(batch, side=self._feature_side) + return predict_with_effects(design, self._effects, baseline=self._dataset_mean) diff --git a/drevalpy/components/predictors/naive/_state_mixin.py b/drevalpy/components/predictors/naive/_state_mixin.py new file mode 100644 index 000000000..2fde37591 --- /dev/null +++ b/drevalpy/components/predictors/naive/_state_mixin.py @@ -0,0 +1,82 @@ +"""Serialized state for the naive mean-effect predictors.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from drevalpy.components.predictors._state_helpers import state_float +from drevalpy.components.predictors.naive._matrix_means import state_float_matrix, state_float_vector + +if TYPE_CHECKING: + import numpy as np + + +class MeanEffectsStateMixin: + """Persist a dataset mean plus zero or more named effect arrays. + + Every naive predictor holds fitted state of exactly that shape, so the three + persistence methods are written once here and configured per class through + :attr:`state_effects` and :attr:`state_effects_ndim`. An effect named + ``"drug_effects"`` is read from and written to the attribute + ``_drug_effects``. + """ + + #: Effect arrays this predictor holds, in serialization order. + state_effects: ClassVar[tuple[str, ...]] = () + + #: Dimensionality of every effect array: 1 for a vector, 2 for a matrix. + state_effects_ndim: ClassVar[int] = 1 + + _dataset_mean: float | None + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize instance state. + + :param hyperparameters: hyperparameters. + """ + super().__init__(hyperparameters) + self._dataset_mean = None + for name in self.state_effects: + setattr(self, f"_{name}", None) + + def _fitted_arrays(self) -> list[np.ndarray] | None: + if self._dataset_mean is None: + return None + arrays = [getattr(self, f"_{name}") for name in self.state_effects] + if any(array is None for array in arrays): + return None + return arrays + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + arrays = self._fitted_arrays() + if arrays is None: + return {} + state: dict[str, object] = {"dataset_mean": self._dataset_mean} + for name, array in zip(self.state_effects, arrays, strict=True): + state[name] = array.tolist() + return state + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + """ + mean = state_float(state, "dataset_mean") + if mean is not None: + self._dataset_mean = mean + restore = state_float_matrix if self.state_effects_ndim == 2 else state_float_vector + for name in self.state_effects: + array = restore(state, name) + if array is not None: + setattr(self, f"_{name}", array) + + def is_fitted(self) -> bool: + """Return whether the component has been fit. + + :returns: Result. + """ + return self._fitted_arrays() is not None diff --git a/drevalpy/components/predictors/naive/effects.py b/drevalpy/components/predictors/naive/effects.py new file mode 100644 index 000000000..a560a4e8b --- /dev/null +++ b/drevalpy/components/predictors/naive/effects.py @@ -0,0 +1,97 @@ +"""Naive mean-effects predictor.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.naive._matrix_means import ( + additive_effects, + block_pair_matrix, + pair_align, + require_pair_matrix, +) +from drevalpy.components.predictors.naive._state_mixin import MeanEffectsStateMixin +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +@register( + "naiveMeanEffects", + tags=("baseline",), + description="Predict mean plus cell-line and drug effects.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class NaiveMeanEffectsPredictor(MeanEffectsStateMixin, BlockPredictor): + """Naive mean effects predictor component.""" + + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ("identity",) + required_drug_blocks: ClassVar[tuple[str, ...]] = ("identity",) + + state_effects: ClassVar[tuple[str, ...]] = ("tissue_effects", "cell_line_effects", "drug_effects") + + _tissue_effects: np.ndarray | None + _cell_line_effects: np.ndarray | None + _drug_effects: np.ndarray | None + + def _cell_and_tissue(self, batch: ModelInputBatch) -> tuple[np.ndarray, np.ndarray]: + if "identity" in batch.cell_line_blocks: + cell = block_pair_matrix(batch, "identity") + else: + cell = require_pair_matrix(batch, side="cell_line") + if "tissue" in batch.cell_line_blocks: + tissue = pair_align(batch.cell_line_blocks["tissue"].values, batch.cell_line_pair_idx) + else: + tissue = np.empty((batch.n_pairs, 0), dtype=np.float64) + return np.asarray(cell, dtype=np.float64), np.asarray(tissue, dtype=np.float64) + + def _fit(self, batch: ModelInputBatch) -> None: + """Fit on training data. + + :param batch: batch. + :raises RuntimeError: If batch.response is None. + """ + y = batch.response + if y is None: + raise RuntimeError("batch.response is required for fit") + cell, tissue = self._cell_and_tissue(batch) + drugs = np.asarray(require_pair_matrix(batch, side="drug"), dtype=np.float64) + self._dataset_mean = float(np.mean(y)) + if tissue.shape[1] > 0: + self._tissue_effects = additive_effects(tissue, y, baseline=self._dataset_mean) + residual = y - self._dataset_mean - tissue @ self._tissue_effects + self._cell_line_effects = additive_effects(cell, residual, baseline=0.0) + else: + self._tissue_effects = np.empty((0,), dtype=np.float64) + self._cell_line_effects = additive_effects(cell, y, baseline=self._dataset_mean) + self._drug_effects = additive_effects(drugs, y, baseline=self._dataset_mean) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict responses for each pair in the batch. + + :param batch: batch. + :returns: Result. + :raises RuntimeError: Raised on invalid input. + """ + if ( + self._dataset_mean is None + or self._tissue_effects is None + or self._cell_line_effects is None + or self._drug_effects is None + ): + msg = "Call fit before predict" + raise RuntimeError(msg) + cell, tissue = self._cell_and_tissue(batch) + drugs = np.asarray(require_pair_matrix(batch, side="drug"), dtype=np.float64) + preds = np.full(batch.n_pairs, self._dataset_mean, dtype=np.float64) + if cell.shape[1] > 0: + preds = preds + cell @ self._cell_line_effects + if tissue.shape[1] > 0 and self._tissue_effects.size > 0: + preds = preds + tissue @ self._tissue_effects + if drugs.shape[1] > 0: + preds = preds + drugs @ self._drug_effects + return preds diff --git a/drevalpy/components/predictors/naive/entity_mean.py b/drevalpy/components/predictors/naive/entity_mean.py new file mode 100644 index 000000000..833f7f676 --- /dev/null +++ b/drevalpy/components/predictors/naive/entity_mean.py @@ -0,0 +1,35 @@ +"""Per-entity naive mean predictors.""" + +from __future__ import annotations + +from typing import ClassVar + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.naive._single_entity import SingleEntityNaivePredictor +from drevalpy.registry.predictor import register + + +@register( + "naiveDrugMean", + tags=("baseline",), + description="Predict per-drug mean response with global fallback.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class NaiveDrugMeanPredictor(SingleEntityNaivePredictor): + """Naive drug mean predictor component.""" + + _feature_side: ClassVar[str] = "drug" + + +@register( + "naiveCellLineMean", + tags=("baseline",), + description="Predict per-cell-line mean response with global fallback.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class NaiveCellLineMeanPredictor(SingleEntityNaivePredictor): + """Naive cell line mean predictor component.""" + + _feature_side: ClassVar[str] = "cell_line" diff --git a/drevalpy/components/predictors/naive/mean.py b/drevalpy/components/predictors/naive/mean.py new file mode 100644 index 000000000..7ababe15e --- /dev/null +++ b/drevalpy/components/predictors/naive/mean.py @@ -0,0 +1,49 @@ +"""Global mean naive predictor.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.components.predictors.naive._state_mixin import MeanEffectsStateMixin +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +@register( + "naiveMean", + tags=("baseline",), + description="Predict the global mean response.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class NaiveMeanPredictor(MeanEffectsStateMixin, FeatureFreePredictor): + """Naive mean predictor component.""" + + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def _fit(self, batch: ModelInputBatch) -> None: + """Fit on training data. + + :param batch: batch. + :raises RuntimeError: If batch.response is None. + """ + if batch.response is None: + raise RuntimeError("batch.response is required for fit") + self._dataset_mean = float(np.mean(batch.response)) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict responses for each pair in the batch. + + :param batch: batch. + :returns: Result. + :raises RuntimeError: Raised on invalid input. + """ + if self._dataset_mean is None: + msg = "Call fit before predict" + raise RuntimeError(msg) + return np.full(batch.n_pairs, self._dataset_mean, dtype=np.float64) diff --git a/drevalpy/components/predictors/naive/tissue.py b/drevalpy/components/predictors/naive/tissue.py new file mode 100644 index 000000000..969b0cfba --- /dev/null +++ b/drevalpy/components/predictors/naive/tissue.py @@ -0,0 +1,134 @@ +"""Tissue-aware naive mean predictors.""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.naive._matrix_means import ( + additive_effects, + predict_with_effects, + require_pair_matrix, +) +from drevalpy.components.predictors.naive._state_mixin import MeanEffectsStateMixin +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +@register( + "naiveTissueMean", + tags=("baseline",), + description="Predict per-tissue mean response with global fallback.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class NaiveTissueMeanPredictor(MeanEffectsStateMixin, BlockPredictor): + """Naive tissue mean predictor component.""" + + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ("tissue",) + + state_effects: ClassVar[tuple[str, ...]] = ("effects",) + + _effects: np.ndarray | None + + def _fit(self, batch: ModelInputBatch) -> None: + """Fit on training data. + + :param batch: batch. + :raises ValueError: Raised on invalid input. + :raises RuntimeError: If batch.response is None. + """ + design = require_pair_matrix(batch, side="cell_line") + if design.shape[1] == 0: + msg = "NaiveTissueMeanPredictor requires tissue featurizer output" + raise ValueError(msg) + y = batch.response + if y is None: + raise RuntimeError("batch.response is required for fit") + self._dataset_mean = float(np.mean(y)) + self._effects = additive_effects(design, y, baseline=self._dataset_mean) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict responses for each pair in the batch. + + :param batch: batch. + :returns: Result. + :raises RuntimeError: Raised on invalid input. + :raises ValueError: Raised on invalid input. + """ + if self._dataset_mean is None or self._effects is None: + msg = "Call fit before predict" + raise RuntimeError(msg) + design = require_pair_matrix(batch, side="cell_line") + if design.shape[1] == 0: + msg = "NaiveTissueMeanPredictor requires tissue featurizer output" + raise ValueError(msg) + return predict_with_effects(design, self._effects, baseline=self._dataset_mean) + + +@register( + "naiveTissueDrugMean", + tags=("baseline",), + description="Predict per tissue-drug combination mean response.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class NaiveTissueDrugMeanPredictor(MeanEffectsStateMixin, BlockPredictor): + """Naive tissue drug mean predictor component.""" + + required_cell_line_blocks: ClassVar[tuple[str, ...]] = ("tissue",) + required_drug_blocks: ClassVar[tuple[str, ...]] = ("identity",) + + state_effects: ClassVar[tuple[str, ...]] = ("effects",) + state_effects_ndim: ClassVar[int] = 2 + + _effects: np.ndarray | None + + def _fit(self, batch: ModelInputBatch) -> None: + """Fit on training data. + + :param batch: batch. + :raises ValueError: Raised on invalid input. + :raises RuntimeError: If batch.response is None. + """ + tissue = require_pair_matrix(batch, side="cell_line") + drugs = require_pair_matrix(batch, side="drug") + if tissue.shape[1] == 0 or drugs.shape[1] == 0: + msg = "NaiveTissueDrugMeanPredictor requires tissue featurizer output" + raise ValueError(msg) + y = batch.response + if y is None: + raise RuntimeError("batch.response is required for fit") + self._dataset_mean = float(np.mean(y)) + tissue64 = np.asarray(tissue, dtype=np.float64) + drugs64 = np.asarray(drugs, dtype=np.float64) + counts = tissue64.T @ drugs64 + sums = tissue64.T @ (drugs64 * y[:, None]) + effects = np.zeros_like(counts, dtype=np.float64) + np.divide(sums, counts, out=effects, where=counts > 0) + effects = effects - self._dataset_mean + effects = np.where(counts > 0, effects, 0.0) + self._effects = effects + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict responses for each pair in the batch. + + :param batch: batch. + :returns: Result. + :raises RuntimeError: Raised on invalid input. + :raises ValueError: Raised on invalid input. + """ + if self._dataset_mean is None or self._effects is None: + msg = "Call fit before predict" + raise RuntimeError(msg) + tissue = require_pair_matrix(batch, side="cell_line") + drugs = require_pair_matrix(batch, side="drug") + if tissue.shape[1] == 0 or drugs.shape[1] == 0: + msg = "NaiveTissueDrugMeanPredictor requires tissue featurizer output" + raise ValueError(msg) + tissue64 = np.asarray(tissue, dtype=np.float64) + drugs64 = np.asarray(drugs, dtype=np.float64) + return self._dataset_mean + np.einsum("ni,ij,nj->n", tissue64, self._effects, drugs64) diff --git a/drevalpy/components/predictors/neural_network/__init__.py b/drevalpy/components/predictors/neural_network/__init__.py new file mode 100644 index 000000000..eb60146f4 --- /dev/null +++ b/drevalpy/components/predictors/neural_network/__init__.py @@ -0,0 +1,5 @@ +"""Dense feed-forward neural network predictor.""" + +from .predictor import NeuralNetworkPredictor + +__all__ = ["NeuralNetworkPredictor"] diff --git a/drevalpy/components/predictors/neural_network/network.py b/drevalpy/components/predictors/neural_network/network.py new file mode 100644 index 000000000..a14aaeec1 --- /dev/null +++ b/drevalpy/components/predictors/neural_network/network.py @@ -0,0 +1,115 @@ +"""Lightning network used by the dense neural-network predictor.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import pytorch_lightning as pl +import torch +from torch import nn + + +class FeedForwardNetwork(pl.LightningModule): + """Feed-forward regression network with batch normalization and dropout.""" + + def __init__(self, hyperparameters: dict[str, Any], input_dim: int) -> None: + """Initialize instance state. + + :param hyperparameters: hyperparameters. + :param input_dim: input dim. + :raises TypeError: Raised on invalid input. + """ + super().__init__() + self.save_hyperparameters() + + units_per_layer = hyperparameters["units_per_layer"] + if not isinstance(units_per_layer, list) or not all(isinstance(unit, int) for unit in units_per_layer): + msg = "units_per_layer must be a list of integers" + raise TypeError(msg) + dropout_prob = hyperparameters["dropout_prob"] + if not isinstance(dropout_prob, float): + msg = "dropout_prob must be a float" + raise TypeError(msg) + + self.n_units_per_layer: list[int] = units_per_layer + self.dropout_prob: float = dropout_prob + self.loss = nn.MSELoss() + self.fully_connected_layers = nn.ModuleList() + self.batch_norm_layers = nn.ModuleList() + self.dropout_layer: nn.Dropout | None = None + + self.fully_connected_layers.append(nn.Linear(input_dim, self.n_units_per_layer[0])) + self.batch_norm_layers.append(nn.BatchNorm1d(self.n_units_per_layer[0])) + for index in range(1, len(self.n_units_per_layer)): + self.fully_connected_layers.append( + nn.Linear(self.n_units_per_layer[index - 1], self.n_units_per_layer[index]) + ) + self.batch_norm_layers.append(nn.BatchNorm1d(self.n_units_per_layer[index])) + self.fully_connected_layers.append(nn.Linear(self.n_units_per_layer[-1], 1)) + self.dropout_layer = nn.Dropout(p=self.dropout_prob) + + def forward(self, features: torch.Tensor) -> torch.Tensor: + """Predict responses from a batch of concatenated feature rows. + + :param features: features. + :returns: Result. + """ + hidden = features + for index in range(len(self.fully_connected_layers) - 2): + hidden = self.fully_connected_layers[index](hidden) + hidden = self.batch_norm_layers[index](hidden) + if self.dropout_layer is not None: + hidden = self.dropout_layer(hidden) + hidden = torch.relu(hidden) + hidden = torch.relu(self.fully_connected_layers[-2](hidden)) + return self.fully_connected_layers[-1](hidden).squeeze(dim=-1) + + def _loss_and_log(self, features: torch.Tensor, response: torch.Tensor, name: str) -> torch.Tensor: + predictions = self(features) + loss = self.loss(predictions, response) + self.log(name, loss, on_step=True, on_epoch=True, prog_bar=True) + return loss + + @staticmethod + def _unpack_batch(batch: Sequence[torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]: + """Concatenate feature tensors and separate the response. + + The batch is ``(*feature_tensors, response)`` where the number of feature + tensors depends on how many entity blocks are present (e.g. cell-line + only vs cell-line + drug). + + :param batch: Sequence of tensors from the DataLoader. + :returns: ``(concatenated_features, response)``. + """ + features = torch.cat(list(batch[:-1]), dim=1) + return features, batch[-1] + + def training_step(self, batch: Sequence[torch.Tensor], batch_idx: int) -> torch.Tensor: + """Compute and log training loss for one batch. + + :param batch: batch. + :param batch_idx: batch idx. + :returns: Result. + """ + _ = batch_idx + features, response = self._unpack_batch(batch) + return self._loss_and_log(features, response, name="train_loss") + + def validation_step(self, batch: Sequence[torch.Tensor], batch_idx: int) -> torch.Tensor: + """Compute and log validation loss for one batch. + + :param batch: batch. + :param batch_idx: batch idx. + :returns: Result. + """ + _ = batch_idx + features, response = self._unpack_batch(batch) + return self._loss_and_log(features, response, name="val_loss") + + def configure_optimizers(self) -> torch.optim.Optimizer: + """Build the Adam optimizer used by the original predictor. + + :returns: Result. + """ + return torch.optim.Adam(self.parameters()) diff --git a/drevalpy/components/predictors/neural_network/predictor.py b/drevalpy/components/predictors/neural_network/predictor.py new file mode 100644 index 000000000..639bef6e5 --- /dev/null +++ b/drevalpy/components/predictors/neural_network/predictor.py @@ -0,0 +1,334 @@ +"""Dense feed-forward neural network predictor.""" + +from __future__ import annotations + +import io +import secrets +from typing import TYPE_CHECKING, Any, ClassVar + +import numpy as np +from upath import UPath as Path + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.config import PredictionMode +from drevalpy.registry.predictor import register +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.tensor_data import make_pair_loader +from drevalpy.utils.torch_io import load_state_dict, load_trusted_mapping, save_torch_payload + +# torch, pytorch_lightning and .network (which imports lightning) are imported +# inside the methods that need them. This module is imported eagerly by +# register_builtin_components(), so a module-scope import would put ~1.2s of +# lightning on the critical path of every `import drevalpy`. Guarded by +# tests/test_import_cost_policy.py. +if TYPE_CHECKING: + from drevalpy.components.predictors.neural_network.network import FeedForwardNetwork + + +@register( + "neuralNetwork", + description="Dense feed-forward network on concatenated cell-line and drug features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class NeuralNetworkPredictor(MatrixPredictor): + """Neural network predictor component.""" + + supports_early_stopping: ClassVar[bool] = True + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize instance state. + + :param hyperparameters: hyperparameters. + """ + super().__init__(hyperparameters) + self._model: FeedForwardNetwork | None = None + self._input_dim: int | None = None + self._is_fitted = False + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, object]: + """Get default hyperparameters. + + :returns: Result. + """ + return { + "units_per_layer": [512, 256, 128], + "dropout_prob": 0.2, + "max_epochs": 50, + "batch_size": 16, + "patience": 5, + } + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "dropout_prob": {"type": "float", "low": 0.0, "high": 0.5, "default": 0.2}, + "max_epochs": {"type": "int", "low": 10, "high": 100, "default": 50}, + "batch_size": {"type": "int", "low": 8, "high": 64, "default": 16}, + } + + def _materialize(self, input_dim: int) -> None: + """Allocate the network once input dimensionality is known. + + :param input_dim: Flattened feature width for one response pair. + """ + from drevalpy.components.predictors.neural_network.network import FeedForwardNetwork + + self._input_dim = input_dim + self._model = FeedForwardNetwork( + hyperparameters={ + "units_per_layer": self._hyperparameters["units_per_layer"], + "dropout_prob": self._hyperparameters["dropout_prob"], + }, + input_dim=input_dim, + ) + self._is_fitted = False + + def _fit(self, batch: ModelInputBatch) -> None: + """Fit on training data using lazy pair-level lookup. + + :param batch: batch. + """ + input_dim = self._compute_input_dim(batch) + self._materialize(input_dim) + if batch.n_pairs == 0: + self._is_fitted = False + return + self._train_with_optional_early_stopping(batch) + self._is_fitted = True + + @staticmethod + def _compute_input_dim(batch: ModelInputBatch) -> int: + """Determine the concatenated feature width from entity matrices. + + :param batch: Input batch. + :returns: Total feature dimensionality per pair. + """ + dim = 0 + if batch.cell_line_features.size > 0 and batch.cell_line_features.ndim == 2: + dim += batch.cell_line_features.shape[1] + if batch.drug_features is not None and batch.drug_features.size > 0 and batch.drug_features.ndim == 2: + dim += batch.drug_features.shape[1] + return dim + + def _build_pair_loader( + self, + batch: ModelInputBatch, + cell_pair_idx: np.ndarray, + drug_pair_idx: np.ndarray | None, + response: np.ndarray | None, + *, + shuffle: bool, + drop_last: bool, + ): + """Build a lazy pair loader from entity matrices and pair indices. + + :param batch: Batch providing entity-level feature matrices. + :param cell_pair_idx: Cell-line pair indices. + :param drug_pair_idx: Drug pair indices (or None). + :param response: Optional response array. + :param shuffle: Whether to shuffle. + :param drop_last: Whether to drop last incomplete batch. + :returns: DataLoader. + """ + batch_size = min(int(self._hyperparameters.get("batch_size", 16)), len(cell_pair_idx)) + if batch_size < 1: + batch_size = 1 + + specs: list[tuple[np.ndarray, np.ndarray]] = [] + if batch.cell_line_features.size > 0: + specs.append((batch.cell_line_features, cell_pair_idx)) + if batch.drug_features is not None and batch.drug_features.size > 0 and drug_pair_idx is not None: + specs.append((batch.drug_features, drug_pair_idx)) + + return make_pair_loader( + *specs, + response=response, + batch_size=batch_size, + shuffle=shuffle, + drop_last=drop_last, + ) + + def _train_with_optional_early_stopping(self, batch: ModelInputBatch) -> None: + import pytorch_lightning as pl + from pytorch_lightning.callbacks import EarlyStopping + + if self._model is None: + msg = "Neural network predictor must be materialized before training" + raise RuntimeError(msg) + + y = np.asarray(batch.response, dtype=np.float32).reshape(-1) + batch_size = min(int(self._hyperparameters.get("batch_size", 16)), batch.n_pairs) + train_loader = self._build_pair_loader( + batch, + batch.cell_line_pair_idx, + batch.drug_pair_idx, + y, + shuffle=True, + drop_last=batch_size < batch.n_pairs, + ) + + val_loader = None + es_resp = batch.early_stopping_response + if es_resp is not None and len(es_resp) > 0 and es_resp.response is not None: + es_cell_idx, es_drug_idx = batch._pair_indices_for(es_resp) + y_val = np.asarray(es_resp.response, dtype=np.float32).reshape(-1) + if len(y_val) > 0: + val_loader = self._build_pair_loader( + batch, + es_cell_idx, + es_drug_idx, + y_val, + shuffle=False, + drop_last=False, + ) + + monitor = "val_loss" if val_loader is not None else "train_loss" + patience = int(self._hyperparameters.get("patience", 5)) + callbacks: list[pl.Callback] = [ + EarlyStopping(monitor=monitor, mode="min", patience=patience), + ] + + checkpoint_dir = batch.training_context.checkpoint_dir + unique_subfolder = Path(checkpoint_dir) / ("run_" + secrets.token_hex(8)) + unique_subfolder.mkdir(parents=True, exist_ok=True) + checkpoint_callback = pl.callbacks.ModelCheckpoint( + dirpath=unique_subfolder, + monitor=monitor, + mode="min", + save_top_k=1, + filename="best", + ) + callbacks.append(checkpoint_callback) + + trainer = pl.Trainer( + max_epochs=int(self._hyperparameters.get("max_epochs", 50)), + accelerator="cpu", + devices=1, + callbacks=callbacks, + enable_progress_bar=False, + logger=False, + ) + if val_loader is None: + trainer.fit(self._model, train_dataloaders=train_loader) + else: + trainer.fit(self._model, train_dataloaders=train_loader, val_dataloaders=val_loader) + + if checkpoint_callback.best_model_path: + checkpoint = load_state_dict(checkpoint_callback.best_model_path) + self._model.load_state_dict(checkpoint["state_dict"]) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict using lazy pair-level lookup (no full feature expansion). + + :param batch: Featurized pairs to score. + :returns: Predicted responses. + """ + import torch + + if not self._is_fitted or self._model is None or batch.n_pairs == 0: + return np.full(batch.n_pairs, np.nan, dtype=np.float64) + + loader = self._build_pair_loader( + batch, + batch.cell_line_pair_idx, + batch.drug_pair_idx, + response=None, + shuffle=False, + drop_last=False, + ) + + self._model.eval() + predictions: list[np.ndarray] = [] + with torch.no_grad(): + for tensors in loader: + x = torch.cat(tensors, dim=1) + preds = self._model(x).cpu().numpy() + predictions.append(preds) + + return np.concatenate(predictions, axis=0).astype(np.float64).reshape(-1) + + def _fit_matrix(self, x: np.ndarray, y: np.ndarray) -> None: + _ = x, y + + def _predict_matrix(self, x: np.ndarray) -> np.ndarray: + import torch + + if not self._is_fitted or self._model is None or len(x) == 0: + return np.full(len(x), np.nan, dtype=np.float64) + self._model.eval() + with torch.no_grad(): + preds = self._model(torch.as_tensor(x, dtype=torch.float32)).cpu().numpy() + return np.asarray(preds, dtype=np.float64).reshape(-1) + + def is_fitted(self) -> bool: + """Return whether the component has been fit. + + :returns: Result. + """ + return self._is_fitted + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + if not self._is_fitted or self._model is None: + return {} + buffer = io.BytesIO() + save_torch_payload( + { + "hyperparameters": dict(self._hyperparameters), + "state_dict": self._model.state_dict(), + "input_dim": self._input_dim, + }, + buffer, + ) + return {"checkpoint": buffer.getvalue()} + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + :raises PredictorStateError: Raised on invalid input. + """ + checkpoint = state.get("checkpoint") + if not isinstance(checkpoint, (bytes, bytearray)): + msg = "NeuralNetworkPredictor state requires a checkpoint byte blob" + raise PredictorStateError(msg) + try: + data = load_trusted_mapping(checkpoint) + except Exception as exc: + msg = "NeuralNetworkPredictor checkpoint could not be deserialized" + raise PredictorStateError(msg) from exc + if not isinstance(data, dict): + msg = "NeuralNetworkPredictor checkpoint payload must be a mapping" + raise PredictorStateError(msg) + hyperparameters = data.get("hyperparameters") + if not isinstance(hyperparameters, dict): + msg = "NeuralNetworkPredictor checkpoint is missing hyperparameters" + raise PredictorStateError(msg) + input_dim = data.get("input_dim") + if input_dim is None: + msg = "NeuralNetworkPredictor checkpoint is missing input_dim" + raise PredictorStateError(msg) + state_dict = data.get("state_dict") + if state_dict is None: + msg = "NeuralNetworkPredictor checkpoint is missing state_dict" + raise PredictorStateError(msg) + self._hyperparameters = dict(hyperparameters) + self._input_dim = int(input_dim) + self._materialize(self._input_dim) + if self._model is None: + msg = "NeuralNetworkPredictor failed to materialize from checkpoint" + raise PredictorStateError(msg) + self._model.load_state_dict(state_dict) + self._is_fitted = True diff --git a/drevalpy/components/predictors/single_drug_routing.py b/drevalpy/components/predictors/single_drug_routing.py new file mode 100644 index 000000000..3c07f8931 --- /dev/null +++ b/drevalpy/components/predictors/single_drug_routing.py @@ -0,0 +1,72 @@ +"""Shared identity-based routing for single-drug predictors.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import numpy as np + +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + +ROUTING_DRUG_FEATURIZER = "identity" +"""The drug featurizer per-drug routing is built on. + +Not a choice: a single-drug predictor fits one estimator per drug, so it needs the drug's +identity to dispatch each pair to the right one. The identity featurizer emits exactly that, +and its output is used as a routing key rather than as features, which is why the design +matrix below is cell-line columns only. +""" + + +def routing_keys(batch: ModelInputBatch) -> np.ndarray: + """Decode per-pair drug IDs from identity blocks. + + :param batch: Featurized batch with drug identity blocks. + :returns: Drug id string per pair (empty string when unknown). + :raises ValueError: If identity blocks are missing or misaligned. + """ + identity_block = batch.drug_blocks.get(ROUTING_DRUG_FEATURIZER) + categories_block = batch.drug_blocks.get(f"{ROUTING_DRUG_FEATURIZER}_categories") + if identity_block is None or categories_block is None or batch.drug_pair_idx is None: + msg = "Single-drug predictors require drug identity features for per-drug routing" + raise ValueError(msg) + + identity_matrix = np.asarray(identity_block.values) + category_ids = np.asarray(categories_block.values, dtype=str).reshape(-1) + if identity_matrix.ndim != 2 or identity_matrix.shape[1] != len(category_ids): + msg = "Drug identity features and identity categories are misaligned" + raise ValueError(msg) + + # Index into entity-level one-hot matrix to get per-pair identity rows + pair_identity = identity_matrix[batch.drug_pair_idx] + # Pairs with a known drug have exactly one active column (sum == 1) + known = pair_identity.sum(axis=1) == 1.0 + keys = np.full(batch.n_pairs, "", dtype=object) + if np.any(known): + # Map back from column index to drug ID string + keys[known] = category_ids[np.argmax(pair_identity[known], axis=1)] + return np.asarray(keys, dtype=str) + + +def require_known_training_keys(keys: np.ndarray) -> None: + """Reject unknown drug identities during training. + + :param keys: Per-pair drug id strings from ``routing_keys``. + :raises ValueError: If any entry is an empty string. + """ + if np.any(keys == ""): + msg = "Training pairs contain unknown drug identities" + raise ValueError(msg) + + +def iter_drug_masks(batch: ModelInputBatch) -> Iterator[tuple[str, np.ndarray]]: + """Yield ``(drug_id, pair_mask)`` for each known drug in the batch. + + :param batch: Featurized batch with drug identity blocks. + :yields: Drug identifier and boolean mask over response pairs. + """ + keys = routing_keys(batch) + for drug_id in np.unique(keys): + if drug_id == "": + continue + yield str(drug_id), keys == drug_id diff --git a/drevalpy/components/predictors/single_drug_sklearn.py b/drevalpy/components/predictors/single_drug_sklearn.py new file mode 100644 index 000000000..5ff534581 --- /dev/null +++ b/drevalpy/components/predictors/single_drug_sklearn.py @@ -0,0 +1,111 @@ +"""Shared per-drug routing for scikit-learn matrix predictors.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.predictors._state_helpers import state_mapping +from drevalpy.components.predictors.single_drug_routing import ( + iter_drug_masks, + require_known_training_keys, + routing_keys, +) +from drevalpy.components.predictors.sklearn_tabular import SklearnTabularPredictor +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.enums.model_scope import ModelScope + + +class SingleDrugSklearnPredictor(SklearnTabularPredictor): + """Fit one estimator per drug, using drug identity only for routing.""" + + scope: ClassVar[ModelScope] = ModelScope.SINGLE_DRUG + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize instance state. + + :param hyperparameters: hyperparameters. + """ + super().__init__(hyperparameters) + self._estimators: dict[str, Any] = {} + + @staticmethod + def _cell_line_matrix(batch: ModelInputBatch) -> np.ndarray: + """Expand deduplicated cell-line features to one row per pair. + + Features are stored entity-level (one row per unique cell line). + ``cell_line_pair_idx`` maps each pair to its cell-line row, repeating + rows when multiple pairs share the same cell line. + + :param batch: Featurized batch. + :returns: Pair-level cell-line feature matrix. + """ + if batch.cell_line_features.size == 0: + return np.empty((batch.n_pairs, 0), dtype=np.float32) + return batch.cell_line_features[batch.cell_line_pair_idx] + + def _fit(self, batch: ModelInputBatch) -> None: + """Fit on training data. + + :param batch: batch. + :raises RuntimeError: If batch.response is None. + """ + x = self._cell_line_matrix(batch) + if batch.response is None: + raise RuntimeError("batch.response is required for fit") + y = batch.response.ravel() + keys = routing_keys(batch) + require_known_training_keys(keys) + + self._estimators = {} + for drug_id, mask in iter_drug_masks(batch): + estimator = self._make_estimator() + estimator.fit(x[mask], y[mask]) + self._estimators[drug_id] = estimator + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + """Predict responses for each pair in the batch. + + :param batch: batch. + :returns: Result. + """ + x = self._cell_line_matrix(batch) + keys = routing_keys(batch) + predictions = np.full(batch.n_pairs, np.nan, dtype=np.float64) + for drug_id in np.unique(keys): + estimator = self._estimators.get(str(drug_id)) + if drug_id == "" or estimator is None: + continue + mask = keys == drug_id + predictions[mask] = np.asarray(estimator.predict(x[mask]), dtype=np.float64) + return predictions + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + return {"estimators": dict(self._estimators), **self._shared_state()} + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + :raises PredictorStateError: Raised on invalid input. + """ + estimators = state_mapping(state, "estimators") + if not estimators: + msg = f"{self.__class__.__name__} state is missing fitted per-drug estimators" + raise PredictorStateError(msg) + shared = self._validated_shared_state(state) + self._estimators = {str(key): value for key, value in estimators.items()} + self._apply_shared_state(shared) + + def is_fitted(self) -> bool: + """Return whether the component has been fit. + + :returns: Result. + """ + return bool(self._estimators) diff --git a/drevalpy/components/predictors/sklearn_models.py b/drevalpy/components/predictors/sklearn_models.py new file mode 100644 index 000000000..a1146a8a2 --- /dev/null +++ b/drevalpy/components/predictors/sklearn_models.py @@ -0,0 +1,348 @@ +"""Scikit-learn tabular predictors. + +The estimator imports live inside each ``_make_estimator`` rather than at module +scope. ``drevalpy.registry`` imports this module to register its nine predictors +on ``import drevalpy``, and importing any part of ``sklearn`` costs ~0.4s because +``sklearn.utils`` pulls in ``scipy.stats``. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.single_drug_sklearn import SingleDrugSklearnPredictor +from drevalpy.components.predictors.sklearn_tabular import SklearnTabularPredictor +from drevalpy.registry.predictor import register + + +@register( + "elasticNet", + description="Elastic Net regression on concatenated dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class ElasticNetPredictor(SklearnTabularPredictor): + """Elastic net predictor component. + + At the extremes of ``l1_ratio`` (0 or 1), the estimator falls back to Ridge + or Lasso; prefer the dedicated ``ridge`` / ``lasso`` predictors when that is + the intended model. + """ + + # Coordinate descent here is sensitive to feature scaling, not to the iteration + # budget: on unstandardized features it fails to converge at any tolerance, and + # raising max_iter only makes each failure proportionally slower. Keep a modest + # budget and the looser tolerance/random selection that LassoPredictor uses. + # ``selection="random"`` needs a seed: unseeded, the coordinate order is drawn + # from global randomness and two fits on identical data disagree. + non_tunable_hyperparameters: ClassVar[dict[str, object]] = { + "max_iter": 2000, + "tol": 1e-2, + "selection": "random", + "random_state": 0, + } + + def _make_estimator(self): + from sklearn.linear_model import ElasticNet, Lasso, Ridge + + l1_ratio = float(self._h.get("l1_ratio", 0.5)) + alpha = float(self._h.get("alpha", 1.0)) + max_iter = int(self._h.get("max_iter", 2000)) + tol = float(self._h.get("tol", 1e-2)) + random_state = self._h.get("random_state") + if l1_ratio == 0.0: + # Ridge is not coordinate descent and has no ``selection`` parameter. + return Ridge(alpha=alpha, max_iter=max_iter, tol=tol, random_state=random_state) + selection = str(self._h.get("selection", "random")) + if l1_ratio == 1.0: + return Lasso( + alpha=alpha, + max_iter=max_iter, + tol=tol, + selection=selection, + random_state=random_state, + ) + return ElasticNet( + alpha=alpha, + l1_ratio=l1_ratio, + max_iter=max_iter, + tol=tol, + selection=selection, + random_state=random_state, + ) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "alpha": {"type": "float", "low": 1e-4, "high": 10.0, "log": True, "default": 1.0}, + "l1_ratio": {"type": "float", "low": 0.0, "high": 1.0, "default": 0.5}, + } + + +@register( + "singleDrugElasticNet", + description="ElasticNet fitted independently per drug on dense cell-line features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class SingleDrugElasticNetPredictor(SingleDrugSklearnPredictor, ElasticNetPredictor): + """Single-drug ElasticNet predictor component.""" + + +@register( + "lasso", + description="Lasso regression on dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class LassoPredictor(SklearnTabularPredictor): + """Lasso predictor component.""" + + non_tunable_hyperparameters: ClassVar[dict[str, object]] = { + "max_iter": 2000, + "tol": 1e-2, + "selection": "random", + "random_state": 0, + } + + def _make_estimator(self): + from sklearn.linear_model import Lasso + + return Lasso( + alpha=float(self._h.get("alpha", 1.0)), + max_iter=int(self._h.get("max_iter", 2000)), + tol=float(self._h.get("tol", 1e-2)), + selection=str(self._h.get("selection", "random")), + random_state=self._h.get("random_state"), + ) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "alpha": {"type": "float", "low": 1e-4, "high": 10.0, "log": True, "default": 1.0}, + } + + +@register( + "ridge", + description="Ridge regression on dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class RidgePredictor(SklearnTabularPredictor): + """Ridge predictor component.""" + + def _make_estimator(self): + from sklearn.linear_model import Ridge + + return Ridge(alpha=float(self._h.get("alpha", 1.0))) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "alpha": {"type": "float", "low": 1e-4, "high": 10.0, "log": True, "default": 1.0}, + } + + +@register( + "randomForest", + description="Random forest on concatenated dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class RandomForestPredictor(SklearnTabularPredictor): + """Random forest predictor component.""" + + non_tunable_hyperparameters: ClassVar[dict[str, object]] = { + "n_jobs": -1, + "random_state": None, + } + + def _make_estimator(self): + from sklearn.ensemble import RandomForestRegressor + + max_depth_raw = self._h.get("max_depth", 20) + max_depth = None if max_depth_raw is None else int(max_depth_raw) + return RandomForestRegressor( + n_estimators=int(self._h.get("n_estimators", 100)), + criterion=str(self._h.get("criterion", "squared_error")), + max_samples=float(self._h.get("max_samples", 0.2)), + max_depth=max_depth, + n_jobs=int(self._h.get("n_jobs", -1)), + random_state=self._h.get("random_state"), + ) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "n_estimators": {"type": "int", "low": 50, "high": 200, "default": 100}, + "max_samples": {"type": "float", "low": 0.1, "high": 0.5, "default": 0.2}, + "max_depth": {"type": "int", "low": 5, "high": 25, "default": 15}, + } + + +@register( + "singleDrugRandomForest", + description="Random forest fitted independently per drug on dense cell-line features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class SingleDrugRandomForestPredictor(SingleDrugSklearnPredictor, RandomForestPredictor): + """Single-drug random-forest predictor component.""" + + +@register( + "svr", + description="Support vector regression on dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class SVRPredictor(SklearnTabularPredictor): + """Svrpredictor component.""" + + non_tunable_hyperparameters: ClassVar[dict[str, object]] = { + "max_iter": -1, + } + + def _make_estimator(self): + from sklearn.svm import SVR + + return SVR( + C=float(self._h.get("C", 1.0)), + epsilon=float(self._h.get("epsilon", 0.1)), + kernel=str(self._h.get("kernel", "rbf")), + max_iter=int(self._h.get("max_iter", -1)), + ) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "C": {"type": "float", "low": 1e-3, "high": 100.0, "log": True, "default": 1.0}, + "epsilon": {"type": "float", "low": 1e-3, "high": 1.0, "log": True, "default": 0.1}, + "kernel": {"type": "categorical", "choices": ["rbf", "linear"], "default": "rbf"}, + } + + +@register( + "gradientBoosting", + description="Histogram gradient boosting on dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class GradientBoostingPredictor(SklearnTabularPredictor): + """Gradient boosting predictor component.""" + + def _make_estimator(self): + from sklearn.ensemble import HistGradientBoostingRegressor + + max_iter = int(self._h.get("max_iter", self._h.get("n_estimators", 100))) + return HistGradientBoostingRegressor( + max_depth=int(self._h.get("max_depth", 6)), + learning_rate=float(self._h.get("learning_rate", 0.1)), + max_iter=max_iter, + ) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "max_depth": {"type": "int", "low": 3, "high": 12, "default": 6}, + "learning_rate": {"type": "float", "low": 0.01, "high": 0.3, "log": True, "default": 0.1}, + "max_iter": {"type": "int", "low": 50, "high": 300, "default": 100}, + } + + +@register( + "adaboost", + description="AdaBoost decision tree regressor on dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class AdaBoostPredictor(SklearnTabularPredictor): + """Ada boost predictor component.""" + + # min_samples_split / min_samples_leaf showed no runtime or accuracy signal in + # benchmark sweeps; they stay overridable but are excluded from tuning so the + # trial budget goes to max_depth and n_estimators. + non_tunable_hyperparameters: ClassVar[dict[str, object]] = { + "min_samples_split": 2, + "min_samples_leaf": 1, + } + + def _make_estimator(self): + from sklearn.ensemble import AdaBoostRegressor + from sklearn.tree import DecisionTreeRegressor + + return AdaBoostRegressor( + estimator=DecisionTreeRegressor( + max_depth=int(self._h.get("max_depth", 4)), + min_samples_split=int(self._h.get("min_samples_split", 2)), + min_samples_leaf=int(self._h.get("min_samples_leaf", 1)), + ), + n_estimators=int(self._h.get("n_estimators", 50)), + learning_rate=float(self._h.get("learning_rate", 1.0)), + ) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "n_estimators": {"type": "int", "low": 25, "high": 100, "default": 50}, + "max_depth": {"type": "int", "low": 2, "high": 8, "default": 4}, + } + + +@register( + "knn", + description="K-nearest neighbors on dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class KNNPredictor(SklearnTabularPredictor): + """Knnpredictor component.""" + + def _make_estimator(self): + from sklearn.neighbors import KNeighborsRegressor + + return KNeighborsRegressor( + n_neighbors=int(self._h.get("n_neighbors", 5)), + weights=str(self._h.get("weights", "distance")), + ) + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, Any]]: + """Get hyperparameter space. + + :returns: Result. + """ + return { + "n_neighbors": {"type": "int", "low": 3, "high": 15, "default": 5}, + "weights": {"type": "categorical", "choices": ["uniform", "distance"], "default": "distance"}, + } diff --git a/drevalpy/components/predictors/sklearn_tabular.py b/drevalpy/components/predictors/sklearn_tabular.py new file mode 100644 index 000000000..a3914e0bb --- /dev/null +++ b/drevalpy/components/predictors/sklearn_tabular.py @@ -0,0 +1,127 @@ +"""Shared helpers for scikit-learn tabular predictors.""" + +from __future__ import annotations + +from abc import abstractmethod +from typing import Any, ClassVar + +import numpy as np + +from drevalpy.components.predictors._state_helpers import state_mapping +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.types.enums.prediction_mode import PredictionMode + + +class SklearnTabularPredictor(MatrixPredictor): + """Fit a scikit-learn estimator on available cell-line and drug features.""" + + # Estimators are regressors only until classifier implementations exist. + supported_modes: ClassVar[frozenset[PredictionMode]] = frozenset({PredictionMode.REGRESSION}) + + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Initialize instance state. + + :param hyperparameters: hyperparameters. + """ + super().__init__(hyperparameters) + merged = dict(self._hyperparameters) + non_tunable = getattr(self, "non_tunable_hyperparameters", None) + if isinstance(non_tunable, dict): + merged = {**non_tunable, **merged} + self._h = merged + self._mode = PredictionMode(merged.get("prediction_mode", PredictionMode.REGRESSION)) + self._estimator: Any = None + + @abstractmethod + def _make_estimator(self) -> Any: + """Return an unfitted sklearn-compatible estimator.""" + + def _fit_matrix(self, x: np.ndarray, y: np.ndarray) -> None: + if len(x) == 0: + self._estimator = None + return + self._estimator = self._make_estimator() + self._estimator.fit(x, y) + + def _predict_matrix(self, x: np.ndarray) -> np.ndarray: + if self._estimator is None: + return np.full(len(x), np.nan, dtype=np.float64) + return np.asarray(self._estimator.predict(x), dtype=np.float64) + + def get_state(self) -> dict[str, object]: + """Return serializable fitted state. + + :returns: Result. + """ + return {"estimator": self._estimator, **self._shared_state()} + + def _shared_state(self) -> dict[str, object]: + """Return the state entries every sklearn predictor carries besides its estimator(s). + + :returns: The resolved hyperparameters and the prediction mode. + """ + return {"hyperparameters": dict(self._h), "mode": self._mode.value} + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + :raises PredictorStateError: Raised on invalid input. + """ + estimator = state.get("estimator") + if estimator is None: + msg = f"{self.__class__.__name__} state is missing a fitted estimator" + raise PredictorStateError(msg) + shared = self._validated_shared_state(state) + self._estimator = estimator + self._apply_shared_state(shared) + + def _validated_shared_state(self, state: dict[str, object]) -> tuple[dict[str, Any], PredictionMode]: + """Read the hyperparameters and prediction mode out of a state mapping. + + Purely a read: a subclass calls this before assigning anything, so an + unusable state leaves the predictor as it was. + + :param state: Mapping produced by ``get_state``. + :returns: The resolved hyperparameters and prediction mode. + :raises PredictorStateError: If the hyperparameters are absent or the + prediction mode is neither a string nor a :class:`PredictionMode`. + """ + hyperparameters = state_mapping(state, "hyperparameters") + if not hyperparameters: + msg = f"{self.__class__.__name__} state is missing hyperparameters" + raise PredictorStateError(msg) + return ( + {str(key): value for key, value in hyperparameters.items()}, + self._restored_mode(state.get("mode", PredictionMode.REGRESSION)), + ) + + def _apply_shared_state(self, shared: tuple[dict[str, Any], PredictionMode]) -> None: + """Assign what :meth:`_validated_shared_state` read. + + :param shared: The hyperparameters and prediction mode to adopt. + """ + self._h, self._mode = shared + self._hyperparameters = dict(self._h) + + def _restored_mode(self, mode: object) -> PredictionMode: + """Coerce a serialized prediction mode back to the enum. + + :param mode: Value stored under ``"mode"``. + :returns: The corresponding enum member. + :raises PredictorStateError: If *mode* is of an unusable type. + """ + if isinstance(mode, PredictionMode): + return mode + if isinstance(mode, str): + return PredictionMode(mode) + msg = f"{self.__class__.__name__} state has an invalid prediction mode" + raise PredictorStateError(msg) + + def is_fitted(self) -> bool: + """Return whether the component has been fit. + + :returns: Result. + """ + return self._estimator is not None diff --git a/drevalpy/components/predictors/state_errors.py b/drevalpy/components/predictors/state_errors.py new file mode 100644 index 000000000..4cc544a6f --- /dev/null +++ b/drevalpy/components/predictors/state_errors.py @@ -0,0 +1,5 @@ +"""Errors raised when predictor state cannot be restored.""" + + +class PredictorStateError(RuntimeError): + """Raised when ``set_state`` receives invalid or incomplete predictor state.""" diff --git a/drevalpy/components/predictors/xgboost_pred.py b/drevalpy/components/predictors/xgboost_pred.py new file mode 100644 index 000000000..f950d9e46 --- /dev/null +++ b/drevalpy/components/predictors/xgboost_pred.py @@ -0,0 +1,90 @@ +"""XGBoost tabular predictor. + +``xgboost`` itself is imported inside ``_make_estimator``: ``drevalpy.registry`` +imports this module to register the ``xgboost`` predictor on ``import drevalpy``, +and ``xgboost.compat`` pulls in ``sklearn`` (and through it ``scipy.stats``), which +costs ~0.4s. ``_set_xgboost_thread_defaults()`` still runs at *module* scope, +because the environment has to be prepared before anything anywhere imports +``xgboost`` - including a test's own ``importorskip`` - not merely before this +module's own deferred import. Setting four environment variables is free. +See ``tests/test_import_cost_policy.py``. + +Everything this shares with ``lightgbm_pred.py`` lives in ``_boosted_trees.py``. +""" + +from __future__ import annotations + +import os +from typing import Any, ClassVar + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors._boosted_trees import BoostedTreesPredictor +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.registry.predictor import register + +_XGBOOST_THREAD_ENV_DEFAULTS = { + "OMP_NUM_THREADS": "1", + "OPENBLAS_NUM_THREADS": "1", + "MKL_NUM_THREADS": "1", + "VECLIB_MAXIMUM_THREADS": "1", +} + + +def _set_xgboost_thread_defaults() -> None: + """Set conservative native-thread defaults before importing XGBoost. + + XGBoost 3.2 can segfault on macOS when PyTorch/OpenMP has already been + loaded in the same process; in tests this happened during fit and model + pickle load. These defaults are applied before importing XGBoost so its + native runtime initializes single-threaded unless the user has explicitly + configured thread limits in the environment. See the upstream discussion: + https://github.com/dmlc/xgboost/issues/11500 + """ + for name, value in _XGBOOST_THREAD_ENV_DEFAULTS.items(): + os.environ.setdefault(name, value) + + +# Runs at import time, i.e. while `drevalpy.registry` registers builtins, so the +# defaults are in place before any caller reaches `import xgboost`. +_set_xgboost_thread_defaults() + + +@register( + "xgboost", + description="XGBoost regressor on concatenated dense features.", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class XGBoostPredictor(BoostedTreesPredictor): + """XGBoost regressor for dense tabular pair features.""" + + # XGBoost tunes a shallower depth ceiling than LightGBM, which is preserved + # here rather than unified: it changes what a sweep explores. + boosting_space_overrides: ClassVar[dict[str, dict[str, Any]]] = { + "max_depth": {"high": 8}, + } + + tuned_hyperparameters: ClassVar[tuple[str, ...]] = ("n_estimators", "max_depth", "learning_rate") + + def _make_estimator(self): + """Return an unfitted XGBoost regressor. + + :returns: Unfitted ``XGBRegressor`` configured from hyperparameters. + """ + _set_xgboost_thread_defaults() + + from xgboost import XGBRegressor + + return XGBRegressor(**self._estimator_params(), n_jobs=-1) + + def set_state(self, state: dict[str, object]) -> None: + """Restore state from a prior ``get_state`` mapping. + + :param state: state. + :raises PredictorStateError: Raised on invalid input. + """ + _set_xgboost_thread_defaults() + super().set_state(state) + if self._estimator is None: + msg = "XGBoostPredictor state did not restore a fitted estimator" + raise PredictorStateError(msg) diff --git a/drevalpy/curation/__init__.py b/drevalpy/curation/__init__.py new file mode 100644 index 000000000..5be755e0f --- /dev/null +++ b/drevalpy/curation/__init__.py @@ -0,0 +1,135 @@ +"""Dose-response curve fitting via curve_curator, returning AnnData. + +:func:`curate` is the single entry point. It preprocesses the long-form +measurements, runs the parallel CurveCurator fit, extracts the per-curve metrics +and pivots them into an :class:`~anndata.AnnData`. + +Fitting is identifier-agnostic - CurveCurator groups by dose range and treats +``cell_line``/``drug`` purely as labels - and :func:`curate` keys the returned +``obs_names``/``var_names`` from whatever those two columns held. A pipeline can +therefore curate on *native* identifiers, persist the ``.h5ad``, and resolve +identity (Cellosaurus accessions, PubChem CIDs, duplicate collapse) in a later, +cheap stage that only renames indices. The ``.h5ad`` is that intermediate, so no +flat metrics frame needs to escape this package. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from drevalpy.curation._anndata import build_anndata + +if TYPE_CHECKING: + from concurrent.futures import Executor + + import anndata + import pandas as pd + +#: Only OLS is usable today. The pinned curve_curator fork raises +#: ``TypeError: _Model.fit_mle() got an unexpected keyword argument 'weights'`` +#: partway through the fit, so MLE is rejected up front instead of failing +#: after preprocessing. Re-enable by adding "MLE" back once the fork is fixed. +SUPPORTED_FIT_TYPES = ("OLS",) + +#: Fitting thoroughness levels curve_curator accepts. ``exhaustive`` is the +#: default and the only one the curation pipeline should use: ``fast`` takes a +#: single shot from one initial guess, which is what made published pEC50 values +#: depend on the starting point. The cheaper levels stay reachable because the +#: test suite fits hundreds of curves whose exact parameters it does not assert. +FIT_SPEEDS = ("fast", "standard", "exhaustive", "basinhopping") + +DEFAULT_FIT_SPEED = "exhaustive" + +__all__ = ["DEFAULT_FIT_SPEED", "FIT_SPEEDS", "SUPPORTED_FIT_TYPES", "build_anndata", "curate"] + + +def curate( + df: pd.DataFrame, + *, + max_chunk_size: int = 2000, + max_workers: int = 4, + normalize: bool = False, + fit_type: str = "OLS", + fit_speed: str = DEFAULT_FIT_SPEED, + executor: Executor | None = None, +) -> anndata.AnnData: + """Fit dose-response curves and return an AnnData of curve metrics. + + Parameters + ---------- + df + Long-form DataFrame with columns: drug, cell_line, concentration, + intensity, and optionally replicate. The ``drug`` and ``cell_line`` + values are treated as opaque labels, so native identifiers are fine - + they become the ``var_names``/``obs_names`` of the result. + max_chunk_size + Maximum number of curves in a single parallel chunk. Groups larger than + this are split into multiple chunks; smaller groups are kept as one chunk. + Smaller values produce more parallel jobs and better load balancing at the + cost of serialization overhead. The result does not depend on this value, + including when *normalize* is set. + max_workers + Number of local worker processes when no external *executor* is provided. + Ignored when *executor* is set. + normalize + Whether to apply median-centric normalization before fitting. Factors are + computed once per dose-range group, not per parallel chunk. + fit_type + Fitting method. Only "OLS" is currently supported. + fit_speed + Fitting thoroughness, one of :data:`FIT_SPEEDS`. + executor + Optional :class:`~concurrent.futures.Executor` instance. When provided, + chunk fitting is dispatched through this executor instead of an internal + :class:`~concurrent.futures.ProcessPoolExecutor`. This enables callers to + supply e.g. a ``submitit.AutoExecutor`` configured for SLURM, or any other + ``concurrent.futures``-compatible executor. The caller retains ownership + and is responsible for shutting down the executor. + + Returns: + ------- + AnnData of shape (n_cell_lines, n_drugs), indexed by the labels that were + fitted. ``X`` holds pEC50; every other curve metric is a layer - the derived + ``EC50``/``IC50``/``LN_IC50``, the goodness-of-fit columns, and the + per-parameter standard errors ``pec50_error``/``slope_error``/ + ``front_error``/``back_error``. No quality filtering is applied. + + Raises: + ------ + ValueError + If ``fit_type`` or ``fit_speed`` is not supported. + """ + from drevalpy.curation._fit import fit_groups + from drevalpy.curation._postprocess import postprocess + from drevalpy.curation._preprocess import preprocess + + _validate_fit_options(fit_type=fit_type, fit_speed=fit_speed) + + groups = preprocess(df) + fitted_groups = fit_groups( + groups, + max_chunk_size=max_chunk_size, + max_workers=max_workers, + normalize=normalize, + fit_type=fit_type, + fit_speed=fit_speed, + executor=executor, + ) + return build_anndata(postprocess(fitted_groups)) + + +def _validate_fit_options(*, fit_type: str, fit_speed: str) -> None: + """Reject unusable fit options before any preprocessing happens. + + :param fit_type: Requested fitting method. + :param fit_speed: Requested fitting thoroughness. + :raises ValueError: If either option is not supported. + """ + if fit_type not in SUPPORTED_FIT_TYPES: + raise ValueError( + f"fit_type={fit_type!r} is not supported; expected one of {list(SUPPORTED_FIT_TYPES)}. " + "MLE fitting is unavailable because the pinned curve_curator release rejects the " + "'weights' argument that curve fitting passes to _Model.fit_mle()." + ) + if fit_speed not in FIT_SPEEDS: + raise ValueError(f"fit_speed={fit_speed!r} is not supported; expected one of {list(FIT_SPEEDS)}.") diff --git a/drevalpy/curation/_anndata.py b/drevalpy/curation/_anndata.py new file mode 100644 index 000000000..a4f7fac11 --- /dev/null +++ b/drevalpy/curation/_anndata.py @@ -0,0 +1,77 @@ +"""Construct AnnData object from flat metrics DataFrame.""" + +from __future__ import annotations + +import anndata +import numpy as np +import pandas as pd + +_REGULATION_ENCODING = {"up": 1, "down": -1, "not": 0} + +#: Measure ``X`` holds. Not duplicated as a layer. +X_MEASURE = "pEC50" + +_LAYER_METRICS = [ + "EC50", + "IC50", + "LN_IC50", + "AUC", + "fold_change", + "slope", + "front", + "back", + "R2", + "RMSE", + "p_value", + "log_p_value", + "f_value", + "f_value_sam", + "relevance_score", + "signal_quality", + "regulation", + # Per-parameter standard errors from the fit's Jacobian. ``pec50_error`` is + # the uncertainty on X itself, so it belongs next to it. + "pec50_error", + "slope_error", + "front_error", + "back_error", +] + + +def _pivot_metric(df: pd.DataFrame, metric: str, cell_lines: pd.Index, drugs: pd.Index) -> np.ndarray: + """Pivot a single metric column into a (cell_lines x drugs) matrix.""" + pivoted = pd.pivot_table(df, values=metric, index="cell_line", columns="drug", aggfunc="first") + return pivoted.reindex(index=cell_lines, columns=drugs).values + + +def build_anndata(df: pd.DataFrame) -> anndata.AnnData: + """Convert a flat metrics DataFrame into an AnnData with (cell_lines x drugs) shape. + + Parameters + ---------- + df + DataFrame with columns: cell_line, drug, pEC50, and all other metric columns. + + Returns: + ------- + AnnData where X is the pEC50 matrix, and all other metrics are stored as layers. + """ + cell_lines = pd.Index(sorted(df["cell_line"].unique())) + drugs = pd.Index(sorted(df["drug"].unique())) + + x_matrix = _pivot_metric(df, X_MEASURE, cell_lines, drugs) + + work_df = df.copy() + work_df["regulation"] = work_df["regulation"].map(_REGULATION_ENCODING).astype(float) + + layers: dict[str, np.ndarray] = {} + for metric in _LAYER_METRICS: + if metric in work_df.columns: + layers[metric] = _pivot_metric(work_df, metric, cell_lines, drugs) + + return anndata.AnnData( + X=x_matrix.astype(np.float32), + obs=pd.DataFrame(index=cell_lines), + var=pd.DataFrame(index=drugs), + layers={k: v.astype(np.float32) for k, v in layers.items()}, + ) diff --git a/drevalpy/curation/_fit.py b/drevalpy/curation/_fit.py new file mode 100644 index 000000000..399c0a64c --- /dev/null +++ b/drevalpy/curation/_fit.py @@ -0,0 +1,226 @@ +"""Parallel dose-response curve fitting via the curve_curator API.""" + +from __future__ import annotations + +import copy +import math +from concurrent.futures import Executor, ProcessPoolExecutor + +import numpy as np +import pandas as pd + + +def _build_config( + n_experiments: int, + doses: list[float], + n_replicates: int, + normalize: bool = False, + fit_type: str = "OLS", + fit_speed: str = "exhaustive", +) -> dict: + """Build a curve_curator config dict equivalent to a parsed TOML.""" + config = { + "__file__": {"Path": "/tmp/dummy.toml"}, # noqa: S108 + "Meta": { + "id": "drevalpy", + "description": "drevalpy curation", + "condition": "", + "treatment_time": "72 h", + }, + "Experiment": { + "experiments": list(range(n_experiments)), + "doses": doses, + "dose_scale": "1e-06", + "dose_unit": "uM", + "control_experiment": list(range(n_replicates)), + "measurement_type": "OTHER", + "data_type": "OTHER", + "search_engine": "OTHER", + "search_engine_version": "0", + }, + "Paths": { + "input_file": "/tmp/input.tsv", # noqa: S108 + "curves_file": "/tmp/curves.tsv", # noqa: S108 + "normalization_file": "/tmp/norm.txt", # noqa: S108 + "mad_file": "/tmp/mad.txt", # noqa: S108 + "dashboard": "/tmp/dashboard.html", # noqa: S108 + }, + "Processing": { + "available_cores": 1, + "max_missing": max(len(doses) - 5, 0), + "imputation": False, + "normalization": normalize, + }, + "Curve Fit": { + "type": fit_type, + "speed": fit_speed, + "max_iterations": 1000, + "interpolation": False, + "control_fold_change": True, + }, + "F Statistic": { + "optimized_dofs": True, + "alpha": 0.05, + "fc_lim": 0.45, + }, + } + + from curve_curator.toml_parser import set_default_values + + config = set_default_values(config) + return config + + +def _fit_chunk(chunk_df: pd.DataFrame, config: dict) -> pd.DataFrame: + """Fit a single chunk using run_pipeline (single-core).""" + from curve_curator import quantification + + from drevalpy.curation._normalize import restore_signal_quality + + cfg = copy.deepcopy(config) + cfg["Processing"]["available_cores"] = 1 + return restore_signal_quality(quantification.run_pipeline(chunk_df, cfg)) + + +def _build_work_items( + groups: list[tuple[pd.DataFrame, dict]], + max_chunk_size: int, + normalize: bool, + fit_type: str, + fit_speed: str, +) -> tuple[list[tuple[pd.DataFrame, dict, int]], list[dict]]: + """Split every group into chunks and build the matching curve_curator configs. + + Each group is split into chunks of at most *max_chunk_size* curves. + Small groups produce a single chunk; large groups are split into as many + chunks as needed to stay at or below the cap. + + When *normalize* is set, the group is normalized here - once, over all of its + rows - and the config handed to each chunk has normalization switched off, so + curve_curator cannot recompute per-chunk factors. See + :mod:`drevalpy.curation._normalize`. + + :param groups: (wide_df, group_info) tuples from preprocess. + :param max_chunk_size: Maximum number of curves per chunk. + :param normalize: Whether to apply median-centric normalization. + :param fit_type: Fitting method. Only "OLS" is currently supported. + :param fit_speed: Fitting thoroughness. + :returns: (work items as (chunk_df, config, group_idx), one config per group). + """ + from drevalpy.curation._normalize import normalize_group + + work_items: list[tuple[pd.DataFrame, dict, int]] = [] + configs: list[dict] = [] + + for group_idx, (df, group_info) in enumerate(groups): + config = _build_config( + n_experiments=group_info["n_experiments"], + doses=group_info["doses"], + n_replicates=group_info["n_replicates"], + normalize=normalize, + fit_type=fit_type, + fit_speed=fit_speed, + ) + configs.append(config) + + chunk_df_source, chunk_config = df, config + if normalize: + chunk_df_source = normalize_group(df, config) + chunk_config = copy.deepcopy(config) + chunk_config["Processing"]["normalization"] = False + + n_chunks = max(1, math.ceil(len(chunk_df_source) / max_chunk_size)) + for chunk_df in np.array_split(chunk_df_source, n_chunks): + work_items.append((chunk_df.reset_index(drop=True), chunk_config, group_idx)) + + return work_items, configs + + +def _run_work_items( + work_items: list[tuple[pd.DataFrame, dict, int]], + max_workers: int, + executor: Executor | None = None, +) -> list[tuple[pd.DataFrame, int]]: + """Fit all chunks, in parallel when more than one worker and chunk are available. + + :param work_items: (chunk_df, config, group_idx) tuples to fit. + :param max_workers: Number of local worker processes to use when no external + *executor* is provided. + :param executor: Optional :class:`~concurrent.futures.Executor` instance. When + provided, all chunks are submitted to this executor instead of creating an + internal :class:`~concurrent.futures.ProcessPoolExecutor`. The executor is + **not** shut down by this function — the caller retains ownership. + :returns: (fitted_df, group_idx) tuples in submission order. + """ + if executor is None and (max_workers <= 1 or len(work_items) == 1): + return [(_fit_chunk(chunk_df, config), group_idx) for chunk_df, config, group_idx in work_items] + + if executor is not None: + futures = [ + (executor.submit(_fit_chunk, chunk_df, config), group_idx) for chunk_df, config, group_idx in work_items + ] + return [(future.result(), group_idx) for future, group_idx in futures] + + with ProcessPoolExecutor(max_workers=max_workers) as pool: + futures = [ + (pool.submit(_fit_chunk, chunk_df, config), group_idx) for chunk_df, config, group_idx in work_items + ] + return [(future.result(), group_idx) for future, group_idx in futures] + + +def fit_groups( + groups: list[tuple[pd.DataFrame, dict]], + max_chunk_size: int = 2000, + max_workers: int = 4, + normalize: bool = False, + fit_type: str = "OLS", + fit_speed: str = "exhaustive", + executor: Executor | None = None, +) -> list[tuple[pd.DataFrame, dict]]: + """Fit all groups with chunk-level parallelism. + + Parameters + ---------- + groups + List of (wide_df, group_info) from preprocess. + max_chunk_size + Maximum number of curves in a single parallel chunk. Groups larger than + this are split into multiple chunks; smaller groups are kept as one chunk. + Controls the granularity of parallelism: smaller values produce more chunks + but add serialization overhead. + max_workers + Number of local worker processes when no external *executor* is provided. + Ignored when *executor* is set. + normalize + Whether to apply median-centric normalization. + fit_type + Fitting method. Only "OLS" is currently supported. + fit_speed + Fitting thoroughness: "fast", "standard", "exhaustive", or "basinhopping". + executor + Optional :class:`~concurrent.futures.Executor` instance (e.g. a + ``submitit.AutoExecutor``). When supplied, chunk fitting is dispatched + through this executor instead of an internal + :class:`~concurrent.futures.ProcessPoolExecutor`. The caller is responsible + for configuring and shutting down the executor. + + Returns: + ------- + List of (fitted_df, config) tuples. + """ + work_items, configs = _build_work_items(groups, max_chunk_size, normalize, fit_type, fit_speed) + fitted_chunks = _run_work_items(work_items, max_workers, executor=executor) + + group_results: list[list[pd.DataFrame]] = [[] for _ in groups] + for fitted_df, group_idx in fitted_chunks: + group_results[group_idx].append(fitted_df) + + from curve_curator import thresholding + + results: list[tuple[pd.DataFrame, dict]] = [] + for group_idx, config in enumerate(configs): + assembled = pd.concat(group_results[group_idx], ignore_index=True) + assembled = thresholding.apply_significance_thresholds(assembled, config) + results.append((assembled, config)) + + return results diff --git a/drevalpy/curation/_normalize.py b/drevalpy/curation/_normalize.py new file mode 100644 index 000000000..d3d0eee0e --- /dev/null +++ b/drevalpy/curation/_normalize.py @@ -0,0 +1,107 @@ +"""Group-wide median-centric normalization, applied before chunking. + +curve_curator normalizes inside ``quantification.run_pipeline``, and its factors +are column medians over *the rows of the frame it was handed* +(``quantification.normalize_values``). Because :mod:`drevalpy.curation._fit` +calls ``run_pipeline`` once per parallel chunk, a normalized dataset used to get +one independent set of factors per chunk, which made its output depend on the +core count. + +This module hoists that step out: the factors are computed once over a whole +dose-range group, the normalized intensities are written back into the ``Raw`` +columns, and each chunk then runs with ``Processing.normalization`` disabled so +``run_pipeline`` derives its ratios from values that are already normalized. +Everything downstream of the ratios is row-wise, so the result no longer depends +on how the group is split. + +``Signal Quality`` is the one column that would otherwise change meaning: +``run_pipeline`` derives it from the *raw* control intensities, which we have +overwritten. It is therefore computed here, carried on the frame under +:data:`PRE_NORM_SIGNAL_QUALITY`, and restored after the fit. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.log import get_logger + +if TYPE_CHECKING: + import pandas as pd + +logger = get_logger(__name__) + +#: Column holding ``Signal Quality`` as computed from the pre-normalization raw +#: controls. Prefixed so it cannot collide with a curve_curator output column. +PRE_NORM_SIGNAL_QUALITY = "drevalpy Pre-Norm Signal Quality" + +_SIGNAL_QUALITY = "Signal Quality" + + +def _column_names(config: dict) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return ``(raw, normalized, dosed raw, control raw)`` column names. + + Mirrors the names ``quantification.run_pipeline`` builds from the same + config, so the normalization done here lands on exactly the columns it would + have used. + + :param config: A curve_curator config dict. + :returns: Raw, normalized, dosed-raw and control-raw column name arrays. + """ + from curve_curator import toolbox + + experiments = np.array(config["Experiment"]["experiments"]) + control_experiments = np.array(config["Experiment"]["control_experiment"]) + doses = np.array(config["Experiment"]["doses"], dtype=float) + dosed_mask = doses != 0.0 + + raw = toolbox.build_col_names("Raw {}", experiments) + normalized = toolbox.build_col_names("Normalized {}", experiments) + return raw, normalized, raw[dosed_mask], toolbox.build_col_names("Raw {}", control_experiments) + + +def normalize_group(df: pd.DataFrame, config: dict) -> pd.DataFrame: + """Normalize a whole dose-range group in one pass. + + The returned frame is ready to be chunked and fitted with + ``Processing.normalization`` disabled. + + :param df: Wide-form group frame from :func:`drevalpy.curation._preprocess.preprocess`. + :param config: The curve_curator config the group will be fitted with. + :returns: A copy whose ``Raw`` columns hold normalized intensities and which + carries :data:`PRE_NORM_SIGNAL_QUALITY`. + """ + from curve_curator import quantification + + raw, normalized, dosed, controls = _column_names(config) + + work = quantification.filter_nans(df, dosed, config["Processing"]["max_missing"]) + signal_quality = np.log2(work[controls].mean(axis=1)) + + work, factors = quantification.normalize_values(work, raw, normalized) + work[raw] = work[normalized].to_numpy() + work = work.drop(columns=list(normalized)) + work[PRE_NORM_SIGNAL_QUALITY] = signal_quality + + logger.info( + "Normalization factors for a group of %d curves: %s", + len(work), + factors.round(4).to_dict(), + ) + return work + + +def restore_signal_quality(fitted_df: pd.DataFrame) -> pd.DataFrame: + """Put the pre-normalization ``Signal Quality`` back and drop the carrier. + + A no-op on frames that never went through :func:`normalize_group`. + + :param fitted_df: A frame returned by ``quantification.run_pipeline``. + :returns: The same frame with ``Signal Quality`` measured on raw controls. + """ + if PRE_NORM_SIGNAL_QUALITY not in fitted_df.columns: + return fitted_df + fitted_df[_SIGNAL_QUALITY] = fitted_df[PRE_NORM_SIGNAL_QUALITY] + return fitted_df.drop(columns=[PRE_NORM_SIGNAL_QUALITY]) diff --git a/drevalpy/curation/_postprocess.py b/drevalpy/curation/_postprocess.py new file mode 100644 index 000000000..3aa0a87c5 --- /dev/null +++ b/drevalpy/curation/_postprocess.py @@ -0,0 +1,102 @@ +"""Extract and rename curve metrics from curve_curator output, derive IC50/EC50.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +_COLUMN_RENAME = { + "pEC50": "pEC50", + "Curve Slope": "slope", + "Curve Front": "front", + "Curve Back": "back", + "Curve Fold Change": "fold_change", + "Curve AUC": "AUC", + "Curve RMSE": "RMSE", + "Curve R2": "R2", + "Curve P_Value": "p_value", + "Curve Log P_Value": "log_p_value", + "Curve F_Value": "f_value", + "Curve F_Value SAM Corrected": "f_value_sam", + "Curve Relevance Score": "relevance_score", + "Curve Regulation": "regulation", + "Signal Quality": "signal_quality", + # Per-parameter standard errors. CurveCurator computes these on every fit at + # every speed, from a Moore-Penrose pseudo-inverse of the Jacobian + # (``models.LogisticModel.calculate_parameter_error``), and emits them in + # ``quantification.add_logistic_model``'s ``fit_cols``. They are the only + # per-curve uncertainty estimate the pipeline produces, so they are kept. + "pEC50 Error": "pec50_error", + "Curve Slope Error": "slope_error", + "Curve Front Error": "front_error", + "Curve Back Error": "back_error", +} + +_LABEL_COLUMNS = ["cell_line", "drug"] + +#: Metrics derived here rather than read from the fit. +DERIVED_METRICS = ("EC50", "IC50", "LN_IC50") + +_KEEP_COLUMNS = [*_LABEL_COLUMNS, *_COLUMN_RENAME.values(), *DERIVED_METRICS] + + +def _compute_ic50(front: np.ndarray, back: np.ndarray, slope: np.ndarray, pec50: np.ndarray) -> np.ndarray: + """Compute IC50 in uM from fitted curve parameters using closed-form solution.""" + with np.errstate(invalid="ignore"): + pec50_um = pec50 - 6 + return np.power(10, (np.log10((front - 0.5) / (0.5 - back)) - slope * pec50_um) / slope) + + +def postprocess(groups: list[tuple[pd.DataFrame, dict]]) -> pd.DataFrame: + """Extract/rename metrics from curve_curator output and compute derived metrics. + + Parameters + ---------- + groups + Fitted DataFrames from curve_curator (one per dose-range group), + each paired with its config dict. + + Returns: + ------- + Single DataFrame with columns: cell_line, drug, plus all metric columns. + All curves are preserved (no filtering). The label columns hold whatever + ``cell_line``/``drug`` values were fitted, so native identifiers survive the + round trip - as strings, because they travel through curve_curator's + ``Name`` column. + + Raises: + ------ + KeyError + If a fitted frame is missing a metric curve_curator is expected to emit. + Silently dropping one is what lost the per-curve errors for a year. + """ + frames: list[pd.DataFrame] = [] + + for fitted_df, _config in groups: + _require_metric_columns(fitted_df) + df = fitted_df.copy() + + df[_LABEL_COLUMNS] = df["Name"].str.split("|", expand=True) + + df = df.rename(columns=_COLUMN_RENAME) + + df["EC50"] = np.power(10, -df["pEC50"].values) * 1e6 + + df["IC50"] = _compute_ic50( + front=df["front"].values, + back=df["back"].values, + slope=df["slope"].values, + pec50=df["pEC50"].values, + ) + df["LN_IC50"] = np.log(df["IC50"].values) + + frames.append(df[_KEEP_COLUMNS]) + + return pd.concat(frames, ignore_index=True) + + +def _require_metric_columns(fitted_df: pd.DataFrame) -> None: + """Fail loudly when curve_curator did not emit a metric we rename.""" + missing = [column for column in _COLUMN_RENAME if column not in fitted_df.columns] + if missing: + raise KeyError(f"curve_curator output is missing expected metric column(s): {missing}") diff --git a/drevalpy/curation/_preprocess.py b/drevalpy/curation/_preprocess.py new file mode 100644 index 000000000..14b0271d2 --- /dev/null +++ b/drevalpy/curation/_preprocess.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import warnings + +import numpy as np +import pandas as pd + +_REQUIRED_COLUMNS = ["drug", "cell_line", "concentration", "intensity"] + + +def preprocess(df: pd.DataFrame) -> list[tuple[pd.DataFrame, dict]]: + """Validate, group by dose range, pivot to wide-form, and return (df, group_info) tuples. + + Parameters + ---------- + df + Long-form DataFrame with columns: drug, cell_line, concentration, + intensity, and optionally replicate. + + Returns: + ------- + List of (wide_df, group_info) tuples where group_info contains + n_experiments, doses, n_replicates for building the config later. + """ + _validate_columns(df) + df = df.copy() + + has_replicate = "replicate" in df.columns + + df["mindose"] = df.groupby(["cell_line", "drug"])["concentration"].transform("min") + df["maxdose"] = df.groupby(["cell_line", "drug"])["concentration"].transform("max") + + groupby: list[str] = [] + if df["maxdose"].nunique() > 1: + groupby.append("maxdose") + if df["mindose"].nunique() > 1: + groupby.append("mindose") + if has_replicate: + df["nreplicates"] = df.groupby(["cell_line", "drug"])["replicate"].transform("nunique") + if df["nreplicates"].nunique() > 1: + groupby.append("nreplicates") + + if groupby: + groups = df.groupby(groupby) + else: + groups = [("all", df)] + + results: list[tuple[pd.DataFrame, dict]] = [] + for _index, group_df in groups: + wide_df, group_info = _prepare_wide(group_df, has_replicate) + results.append((wide_df, group_info)) + + return results + + +def _validate_columns(df: pd.DataFrame) -> None: + missing = [c for c in _REQUIRED_COLUMNS if c not in df.columns] + if missing: + raise ValueError(f"Missing required columns: {missing}") + + +def _prepare_wide( + curve_df: pd.DataFrame, + has_replicate: bool, +) -> tuple[pd.DataFrame, dict]: + """Pivot long-form to wide-form with synthetic control columns.""" + if has_replicate: + n_replicates = curve_df["replicate"].nunique() + pivot_columns = ["concentration", "replicate"] + else: + n_replicates = 1 + pivot_columns = ["concentration"] + + dup_subset = ["cell_line", "drug"] + pivot_columns + if curve_df.duplicated(subset=dup_subset).any(): + warnings.warn( + f"Duplicate entries found for {tuple(dup_subset)} combinations. Aggregating using mean of 'intensity'.", + UserWarning, + stacklevel=2, + ) + curve_df = curve_df.groupby(dup_subset, as_index=False)["intensity"].mean() + + wide = curve_df.pivot(index=["cell_line", "drug"], columns=pivot_columns, values="intensity") + + if has_replicate: + control_df = pd.DataFrame( + {(0.0, col_id): 1.0 for col_id in range(n_replicates)}, + index=wide.index, + ) + else: + control_df = pd.DataFrame({0.0: 1.0}, index=wide.index) + + wide = pd.concat([control_df, wide], axis=1) + + concentrations = wide.columns.sort_values() + doses = concentrations.get_level_values(0).to_list() + wide = wide[concentrations] + + n_experiments = wide.shape[1] + experiments = np.arange(n_experiments) + wide.insert(0, "Name", ["|".join(map(str, idx)) for idx in wide.index.tolist()]) + + wide.columns = ["Name"] + [f"Raw {i}" for i in experiments] + wide = wide.reset_index(drop=True) + + group_info = { + "n_experiments": n_experiments, + "doses": doses, + "n_replicates": n_replicates, + } + return wide, group_info diff --git a/drevalpy/data/__init__.py b/drevalpy/data/__init__.py new file mode 100644 index 000000000..84d39f7ea --- /dev/null +++ b/drevalpy/data/__init__.py @@ -0,0 +1,74 @@ +"""Data loading, splitting, and registries.""" + +from __future__ import annotations + +import hashlib + +from drevalpy.types import SplitMasks +from drevalpy.types.data.dataset import Dataset as Dataset + +from .datasets._load import load +from .quality import curve_quality_mask + + +def split( + dataset: Dataset, + mode: str, + n_splits: int = 5, + validation_ratio: float = 0.1, + random_state: int = 42, +) -> list[SplitMasks]: + """Split a dataset using a registered splitter. + + :param dataset: Dataset instance. + :param mode: Splitter mode (e.g. "LCO", "LPO", "LDO", "LTO"). + :param n_splits: Number of CV folds. + :param validation_ratio: Fraction of training data for validation. + :param random_state: Seed for reproducibility. + :returns: List of SplitMasks, one per fold. + """ + from drevalpy.registry.splitter import splitter_registry + + splitter = splitter_registry.get(mode) + folds = splitter(dataset, n_splits=n_splits, validation_ratio=validation_ratio, random_state=random_state) + + fold_ids: list[str] = [] + for i, fold in enumerate(folds): + hasher = hashlib.sha256() + hasher.update(fold.train.mask.tobytes()) + hasher.update(fold.test.mask.tobytes()) + hasher.update(fold.val.mask.tobytes()) + fold_id = hasher.hexdigest()[:12] + fold_ids.append(fold_id) + + fold.metadata.setdefault("dataset", dataset.name) + fold.metadata.setdefault("split_mode", mode) + fold.metadata.setdefault("fold_index", i) + fold.metadata.setdefault("fold_id", fold_id) + + if len(set(fold_ids)) != len(fold_ids): + raise ValueError(f"Duplicate fold_ids generated: {fold_ids}") + + return folds + + +def __getattr__(name: str): + """Lazy access to registry singletons to avoid circular imports.""" + if name == "dataset_registry": + from drevalpy.registry.dataset import dataset_registry + + return dataset_registry + if name == "splitter_registry": + from drevalpy.registry.splitter import splitter_registry + + return splitter_registry + raise AttributeError(f"module 'drevalpy.data' has no attribute {name!r}") + + +__all__ = [ + "curve_quality_mask", + "dataset_registry", + "load", + "split", + "splitter_registry", +] diff --git a/drevalpy/data/_paths.py b/drevalpy/data/_paths.py new file mode 100644 index 000000000..e0bb75d08 --- /dev/null +++ b/drevalpy/data/_paths.py @@ -0,0 +1,52 @@ +"""Central path resolution for drevalpy (cache + config directories).""" + +from __future__ import annotations + +import os + +from platformdirs import user_cache_dir, user_config_dir +from upath import UPath as Path + +_CACHE_ENV_VAR = "DREVALPY_CACHE_DIR" +_CONFIG_ENV_VAR = "DREVALPY_CONFIG_DIR" + + +def get_default_data_dir() -> Path: + """Return the cache directory for built-in datasets and meta. + + Resolution order: + + 1. ``DREVALPY_CACHE_DIR`` environment variable (if set and non-empty). + 2. Platform-specific user cache directory via ``platformdirs``. + + :returns: Resolved cache directory path. + """ + env = os.environ.get(_CACHE_ENV_VAR, "").strip() + if env: + return Path(env) + return Path(user_cache_dir("drevalpy")) + + +def get_config_dir() -> Path: + """Return the config directory for user settings. + + Resolution order: + + 1. ``DREVALPY_CONFIG_DIR`` environment variable (if set and non-empty). + 2. Platform-specific user config directory via ``platformdirs``. + + :returns: Resolved config directory path. + """ + env = os.environ.get(_CONFIG_ENV_VAR, "").strip() + if env: + return Path(env) + return Path(user_config_dir("drevalpy")) + + +def resolve_h5mu_path(dataset_name: str) -> Path: + """Return the expected .h5mu path for a given dataset name. + + :param dataset_name: Name of the dataset (e.g. "GDSC1", "CTRPv2"). + :returns: Full path to the .h5mu file within the cache directory. + """ + return get_default_data_dir() / f"{dataset_name}.h5mu" diff --git a/drevalpy/data/_transfer.py b/drevalpy/data/_transfer.py new file mode 100644 index 000000000..457419aa1 --- /dev/null +++ b/drevalpy/data/_transfer.py @@ -0,0 +1,76 @@ +"""Streaming downloads with progress reporting for remote datasets and artifacts.""" + +from __future__ import annotations + +import os +from collections.abc import Iterable + +from rich.progress import DownloadColumn, Progress, TimeRemainingColumn, TransferSpeedColumn +from upath import UPath as Path + +from drevalpy.log import get_logger + +logger = get_logger(__name__) + +_CHUNK_SIZE = 1024 * 256 + + +def download_file(remote: Path, local_path: Path, label: str) -> Path: + """Download a single remote file to a local path. + + :param remote: Remote source path (any fsspec-compatible protocol). + :param local_path: Destination path on the local filesystem. + :param label: Human-readable name shown in the progress bar. + :returns: The local destination path. + """ + local_path.parent.mkdir(parents=True, exist_ok=True) + with _progress() as progress: + _stream(remote, local_path, label, progress) + return local_path + + +def download_files(remote_dir: Path, local_dir: Path, label: str, filenames: Iterable[str]) -> Path: + """Download several files from a remote directory into a local directory. + + :param remote_dir: Remote directory holding the files. + :param local_dir: Local destination directory (created if absent). + :param label: Human-readable name shown in the progress bar. + :param filenames: Names of the files to fetch from *remote_dir*. + :returns: The local destination directory. + """ + local_dir.mkdir(parents=True, exist_ok=True) + with _progress() as progress: + for name in filenames: + _stream(remote_dir / name, local_dir / name, f"{label}/{name}", progress) + return local_dir + + +def _progress() -> Progress: + """Build a progress bar with transfer size, speed and ETA columns.""" + return Progress( + *Progress.get_default_columns(), + DownloadColumn(), + TransferSpeedColumn(), + TimeRemainingColumn(), + ) + + +def _stream(remote: Path, local_path: Path, label: str, progress: Progress) -> None: + """Copy one remote file to *local_path*, staging it under a unique ``.part`` file. + + Staging keeps concurrent readers from ever observing a truncated file, which + matters when many workers share a cache directory. + """ + fs = remote.fs + remote_key = remote.path + task = progress.add_task(f"Downloading {label}", total=fs.size(remote_key)) + + partial = local_path.with_name(f"{local_path.name}.{os.getpid()}.part") + try: + with fs.open(remote_key, "rb", block_size=0) as src, open(partial, "wb") as dst: + while chunk := src.read(_CHUNK_SIZE): + dst.write(chunk) + progress.advance(task, len(chunk)) + os.replace(partial, local_path) + finally: + partial.unlink(missing_ok=True) diff --git a/drevalpy/data/artifacts.py b/drevalpy/data/artifacts.py new file mode 100644 index 000000000..10d51975b --- /dev/null +++ b/drevalpy/data/artifacts.py @@ -0,0 +1,103 @@ +"""Download and cache external model artifacts (PPI embeddings, checkpoints, etc.).""" + +from __future__ import annotations + +import json +import os +from collections.abc import Iterable + +from upath import UPath as Path + +from drevalpy.data._paths import get_default_data_dir +from drevalpy.data._transfer import download_file, download_files +from drevalpy.log import get_logger + +logger = get_logger(__name__) + +_DEFAULT_ARTIFACTS_URI = "s3://orakl-open-source-data/drevalpy/artifacts/" +_URI_ENV_VAR = "DREVALPY_ARTIFACTS_URI" +_STORAGE_OPTIONS_ENV_VAR = "DREVALPY_ARTIFACTS_STORAGE_OPTIONS" + + +def get_artifacts_uri() -> str: + """Return the base URI artifacts are fetched from. + + Override with ``DREVALPY_ARTIFACTS_URI`` to point at a mirror, a local + directory, or another fsspec-supported protocol. + + :returns: Base URI ending in a path separator. + """ + return os.environ.get(_URI_ENV_VAR, "").strip() or _DEFAULT_ARTIFACTS_URI + + +def get_artifacts_storage_options() -> dict: + """Return fsspec storage options for the artifacts location. + + Empty by default so that fsspec applies the ambient credential chain (env + vars, shared config, EC2/ECS instance roles). Set + ``DREVALPY_ARTIFACTS_STORAGE_OPTIONS`` to a JSON object to pass explicit + options such as ``{"profile": "my-profile"}`` or ``{"anon": true}``. + + :returns: Mapping forwarded to the fsspec filesystem. + """ + raw = os.environ.get(_STORAGE_OPTIONS_ENV_VAR, "").strip() + if not raw: + return {} + try: + options = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Ignoring %s: not valid JSON.", _STORAGE_OPTIONS_ENV_VAR) + return {} + if not isinstance(options, dict): + logger.warning("Ignoring %s: expected a JSON object.", _STORAGE_OPTIONS_ENV_VAR) + return {} + return options + + +def _remote_root() -> Path: + """Return the remote artifacts root as a configured UPath.""" + return Path(get_artifacts_uri(), **get_artifacts_storage_options()) + + +def _cache_root() -> Path: + """Return the local directory artifacts are cached in.""" + return get_default_data_dir() / "artifacts" + + +def get_artifact(name: str) -> Path: + """Return the local path to a named artifact, downloading it if absent. + + Artifacts are cached under ``DREVALPY_CACHE_DIR / artifacts / ``. + + :param name: Filename of the artifact (e.g. ``"human_ppi_features.tsv"``). + :returns: Local path to the cached artifact file. + """ + local_path = _cache_root() / name + if local_path.exists(): + return local_path + + logger.info("Downloading artifact %s ...", name) + download_file(_remote_root() / name, local_path, name) + logger.info("Cached artifact at %s", local_path) + return local_path + + +def get_artifact_dir(name: str, filenames: Iterable[str]) -> Path: + """Return the local path to a multi-file artifact, downloading it if absent. + + The artifact is considered cached only once every expected file is present, + so a download interrupted midway is retried rather than silently reused. + + :param name: Directory name of the artifact within the artifacts location. + :param filenames: Files the artifact directory must contain. + :returns: Local path to the cached artifact directory. + """ + expected = tuple(filenames) + local_dir = _cache_root() / name + if all((local_dir / filename).exists() for filename in expected): + return local_dir + + logger.info("Downloading artifact directory %s ...", name) + download_files(_remote_root() / name, local_dir, name, expected) + logger.info("Cached artifact at %s", local_dir) + return local_dir diff --git a/drevalpy/data/datasets/__init__.py b/drevalpy/data/datasets/__init__.py new file mode 100644 index 000000000..367def6e8 --- /dev/null +++ b/drevalpy/data/datasets/__init__.py @@ -0,0 +1,71 @@ +"""Dataset registry: Pydantic models, config I/O, and registration API. + +The user config lives at ``/drevalpy.json`` (override with +``DREVALPY_CONFIG_DIR``). Sources and datasets registered there are merged +with the built-in registry at import time. + +Usage:: + + from drevalpy.data import registry + + registry # displays Rich table via __repr__ + registry.dataset_names # programmatic access + registry.register_source("my_s3", "s3://bucket/data") + registry.register_dataset("MyStudy", source="my_s3", file="MyStudy.h5mu") +""" + +from __future__ import annotations + +from ._load import load + + +def __getattr__(name: str): + """Lazy imports from drevalpy.registry.dataset to avoid circular imports.""" + from drevalpy.registry.dataset import ( + DatasetEntry, + DatasetRegistry, + DrevalConfig, + SourceEntry, + config_lock, + dataset_registry, + get_config_path, + load_config, + register_dataset, + register_source, + save_config, + ) + + _lazy = { + "DatasetEntry": DatasetEntry, + "DatasetRegistry": DatasetRegistry, + "DrevalConfig": DrevalConfig, + "SourceEntry": SourceEntry, + "config_lock": config_lock, + "dataset_registry": dataset_registry, + "get_config_path": get_config_path, + "load_config": load_config, + "register_dataset": register_dataset, + "register_source": register_source, + "registry": dataset_registry, + "save_config": save_config, + } + if name in _lazy: + return _lazy[name] + raise AttributeError(f"module 'drevalpy.data.datasets' has no attribute {name!r}") + + +__all__ = [ + "DatasetEntry", + "DatasetRegistry", + "DrevalConfig", + "SourceEntry", + "config_lock", + "dataset_registry", + "get_config_path", + "load", + "load_config", + "register_dataset", + "register_source", + "registry", + "save_config", +] diff --git a/drevalpy/data/datasets/_load.py b/drevalpy/data/datasets/_load.py new file mode 100644 index 000000000..2d83e3c60 --- /dev/null +++ b/drevalpy/data/datasets/_load.py @@ -0,0 +1,113 @@ +"""Load MuDatasets with protocol-agnostic downloads via universal-pathlib.""" + +from __future__ import annotations + +from upath import UPath as Path + +from drevalpy.data._paths import get_default_data_dir, resolve_h5mu_path +from drevalpy.data._transfer import download_file +from drevalpy.log import get_logger +from drevalpy.registry.dataset._registry import dataset_registry +from drevalpy.types.data.dataset import Dataset + +logger = get_logger(__name__) + + +def _download(name: str) -> Path: + """Download the .h5mu file for a registered dataset. + + :param name: Dataset name from the registry. + :returns: Local path to the downloaded file. + """ + entry = dataset_registry.datasets[name] + source = dataset_registry.sources[entry.source] + remote = Path(source.url, **source.storage_options) / entry.file + return download_file(remote, get_default_data_dir() / entry.file, name) + + +def _carries_curve_quality(dataset: Dataset) -> bool: + """Whether the default curve-quality thresholds can be evaluated on *dataset*. + + The CurveCurator refit of the screens kept the file names of the generation + before it, so a cache filled by an older drevalpy holds a file that parses + perfectly but has none of the quality layers every splitter now reads. Left + alone that surfaces as a ``KeyError`` from deep inside a split, so the loader + treats such a file as stale and fetches it again. + + Asking the filter itself, rather than checking a list of layer names, keeps + this honest when the default rule changes. + """ + from drevalpy.data.quality import curve_quality_mask + + try: + curve_quality_mask(dataset) + except KeyError: + return False + return True + + +def _load_from_cache(path: Path) -> Dataset | None: + """Load a cached .h5mu, or return ``None`` when it must be fetched again. + + :param path: Local candidate path. + :returns: The dataset, or ``None`` if the file was unusable and removed. + """ + try: + dataset = Dataset.load(path) + except Exception: + logger.warning("Corrupted file at %s, removing and re-downloading.", path) + path.unlink() + return None + + if not _carries_curve_quality(dataset): + logger.warning( + "Cached file at %s predates the curve-quality layers, removing and re-downloading.", + path, + ) + path.unlink() + return None + + return dataset + + +def load(dataset_name: str) -> Dataset: + """Load a registered or custom dataset as a Dataset from its .h5mu file. + + Resolution order: + + 1. If the .h5mu exists at the standard cache path, load it directly. + 2. If *dataset_name* is registered, download the .h5mu if needed and load. + 3. If *dataset_name* is a path to an existing .h5mu file, load it directly. + + A cached file is re-downloaded when it cannot be parsed, or when it is too + old to carry the curve-quality layers the splitters filter on. An explicit + path in case 3 is always taken at face value. + + :param dataset_name: Registered dataset name, or path to a .h5mu file. + :returns: Loaded Dataset. + :raises FileNotFoundError: If the .h5mu file cannot be found or downloaded. + """ + h5mu_path = resolve_h5mu_path(dataset_name) + if h5mu_path.is_file(): + dataset = _load_from_cache(h5mu_path) + if dataset is not None: + return dataset + + if dataset_registry.is_registered(dataset_name): + entry = dataset_registry.datasets[dataset_name] + candidate = get_default_data_dir() / entry.file + if candidate.is_file(): + dataset = _load_from_cache(candidate) + if dataset is not None: + return dataset + downloaded = _download(dataset_name) + return Dataset.load(downloaded) + + candidate_path = Path(dataset_name) + if candidate_path.is_file() and candidate_path.suffix == ".h5mu": + return Dataset.load(candidate_path) + + raise FileNotFoundError( + f"Cannot locate .h5mu for dataset '{dataset_name}'. " + f"Checked: {h5mu_path}, registry ({dataset_registry.dataset_names}), and direct path." + ) diff --git a/drevalpy/data/datasets/available_datasets.json b/drevalpy/data/datasets/available_datasets.json new file mode 100644 index 000000000..9aa238152 --- /dev/null +++ b/drevalpy/data/datasets/available_datasets.json @@ -0,0 +1,16 @@ +{ + "sources": { + "orakl": { + "url": "s3://orakl-open-source-data/drevalpy_h5mu/gen3" + } + }, + "datasets": { + "GDSC1": { "source": "orakl", "file": "GDSC1.h5mu" }, + "GDSC2": { "source": "orakl", "file": "GDSC2.h5mu" }, + "CTRPv1": { "source": "orakl", "file": "CTRPv1.h5mu" }, + "CTRPv2": { "source": "orakl", "file": "CTRPv2.h5mu" }, + "BeatAML2": { "source": "orakl", "file": "BeatAML2.h5mu" }, + "PDX_Bruna": { "source": "orakl", "file": "PDX_Bruna.h5mu" }, + "TOYv1": { "source": "orakl", "file": "TOYv1.h5mu" } + } +} diff --git a/drevalpy/data/quality.py b/drevalpy/data/quality.py new file mode 100644 index 000000000..d2224b686 --- /dev/null +++ b/drevalpy/data/quality.py @@ -0,0 +1,230 @@ +"""Curve-quality filtering for the response matrix. + +The published datasets are refit with `CurveCurator +`_ and ship **every** fitted curve, +including the ones the fit itself says are meaningless. The refit generation +stores ``pEC50`` as the response matrix, and a pEC50 exists for every curve that +converged, so ``~np.isnan(response_matrix)`` alone counts junk curves as usable +observations. Filtering on the quality metrics that ship alongside is what +separates a measured pair from a trustworthy one. + +:func:`curve_quality_mask` is the single entry point. Every quality metric a +dataset carries is a keyword option, and passing ``None`` disables that check, +so the default call applies exactly the CurveCurator-derived rule: + +.. code-block:: python + + relevance_score >= -log10(0.05) and abs(fold_change) >= 0.45 + +Those two numbers are the ``alpha`` and ``fc_lim`` that +:mod:`drevalpy.curation._fit` passes to CurveCurator's ``F Statistic`` block. +Upstream they only *annotate* - ``apply_significance_thresholds`` adds columns +and drops no rows - but they are not inert: they determine the ``s0`` fudge +factor behind ``relevance_score``, and they define the ``regulation`` label. The +default rule therefore reproduces ``regulation != 0`` exactly. + +Two choices worth stating, because the obvious alternatives are wrong: + +* **Gate on ``relevance_score``, not ``p_value``.** ``p_value`` is the raw, + uncorrected F-test p-value. The multiple-testing control lives in + ``relevance_score``, which is the SAM-corrected statistic. Thresholding + ``p_value`` across the tens of thousands of curves in a screen would apply no + correction at all. It is exposed, but off by default. +* **Recompute rather than read ``regulation``.** It gives identical results by + default, but it is NaN wherever CurveCurator reached no verdict and it encodes + direction rather than quality. It is exposed as a categorical option. + +There is no capability check and no silent fallback: the layers are guaranteed +by the file format, so a missing one raises :class:`KeyError`. +""" + +from __future__ import annotations + +import operator +from collections.abc import Callable, Collection +from typing import TYPE_CHECKING, Final + +import numpy as np + +if TYPE_CHECKING: + from drevalpy.types.data.mudatalike import MuDataLike + +#: Sentinel layer name for the response matrix itself. ``pEC50`` is stored as +#: ``X`` (see ``response.uns["x_column"]``), not as a layer, so the two pEC50 +#: options resolve through ``response_matrix``. +_RESPONSE_MATRIX: Final = "pEC50" + +_Comparison = Callable[[np.ndarray, float], np.ndarray] +_Transform = Callable[[np.ndarray], np.ndarray] | None + +#: Keyword argument of :func:`curve_quality_mask` -> (response layer, +#: comparison that a *passing* curve satisfies, optional value transform). +#: +#: Driving the checks from a table rather than a branch per metric is what keeps +#: :func:`curve_quality_mask` flat, and it lets the tests parametrize over every +#: option so a new rule cannot land untested. +_RULES: Final[dict[str, tuple[str, _Comparison, _Transform]]] = { + "min_relevance_score": ("relevance_score", operator.ge, None), + "min_abs_fold_change": ("fold_change", operator.ge, np.abs), + "max_p_value": ("p_value", operator.le, None), + "min_log_p_value": ("log_p_value", operator.ge, None), + "min_f_value": ("f_value", operator.ge, None), + "min_f_value_sam": ("f_value_sam", operator.ge, None), + "min_r2": ("R2", operator.ge, None), + "max_rmse": ("RMSE", operator.le, None), + "min_signal_quality": ("signal_quality", operator.ge, None), + "min_abs_slope": ("slope", operator.ge, np.abs), + "max_abs_slope": ("slope", operator.le, np.abs), + "min_front": ("front", operator.ge, None), + "max_back": ("back", operator.le, None), + "min_pec50": (_RESPONSE_MATRIX, operator.ge, None), + "max_pec50": (_RESPONSE_MATRIX, operator.le, None), +} + +#: Layer holding CurveCurator's own up/down/not verdict. +_REGULATION_LAYER: Final = "regulation" + + +def curve_quality_mask( + dataset: MuDataLike, + *, + # The CurveCurator-derived rule, on by default. + min_relevance_score: float | None = 1.3010299956639813, # -log10(0.05), i.e. alpha + min_abs_fold_change: float | None = 0.45, # fc_lim, already log2 in the layer + # Significance, off by default. + max_p_value: float | None = None, + min_log_p_value: float | None = None, + min_f_value: float | None = None, + min_f_value_sam: float | None = None, + # Goodness of fit, off by default. + min_r2: float | None = None, + max_rmse: float | None = None, + min_signal_quality: float | None = None, + # Curve shape, off by default. + min_abs_slope: float | None = None, + max_abs_slope: float | None = None, + min_front: float | None = None, + max_back: float | None = None, + min_pec50: float | None = None, + max_pec50: float | None = None, + # CurveCurator's own verdict, off by default. + regulation: Collection[str] | None = None, +) -> np.ndarray: + """Mask of the pairs whose fitted curve meets every requested threshold. + + The defaults are the ``alpha = 0.05`` and ``fc_lim = 0.45`` that + :mod:`drevalpy.curation._fit` passes to CurveCurator, expressed as + ``relevance_score >= -log10(alpha)`` and ``abs(fold_change) >= fc_lim``. + Every other option is ``None``, meaning "do not check this metric". + + A metric that is NaN fails: a curve CurveCurator could not score is not a + curve worth training on. + + Args: + dataset: Dataset (or any :class:`~drevalpy.types.data.mudatalike.MuDataLike`) + whose response layers hold the quality metrics. + min_relevance_score: Minimum SAM-corrected relevance score. This is the + multiple-testing-corrected statistic; prefer it over *max_p_value*. + min_abs_fold_change: Minimum absolute log2 curve fold change, i.e. the + effect size. The layer is already log2, so no transform is applied + beyond the absolute value. + max_p_value: Maximum raw, **uncorrected** F-test p-value. + min_log_p_value: Minimum ``-log10(p_value)``, the uncorrected p-value on + a log scale. + min_f_value: Minimum F statistic of the fit. + min_f_value_sam: Minimum s0-corrected F statistic. + min_r2: Minimum coefficient of determination of the fit. + max_rmse: Maximum root-mean-square error of the fit. + min_signal_quality: Minimum signal quality. + min_abs_slope: Minimum absolute Hill slope. + max_abs_slope: Maximum absolute Hill slope. Useful because a slope + pinned at the fitting bound describes a step, which is usually an + artefact rather than a dose response. + min_front: Minimum fitted upper plateau. + max_back: Maximum fitted lower plateau. + min_pec50: Minimum pEC50, read from the response matrix rather than a + layer. Together with *max_pec50* this brackets fits whose inflection + point falls outside the tested dose range. + max_pec50: Maximum pEC50. + regulation: Keep only curves CurveCurator labelled with one of these, + out of ``"up"``, ``"down"`` and ``"not"``. + + Returns: + Boolean array of shape ``(n_cell_lines, n_drugs)``, True where the curve + passes. Use ``~mask`` to blank the failing pairs of a response matrix. + + Raises: + KeyError: If a layer a requested threshold needs is not in the dataset. + ValueError: If *regulation* contains a label CurveCurator does not use. + """ + thresholds: dict[str, float | None] = { + "min_relevance_score": min_relevance_score, + "min_abs_fold_change": min_abs_fold_change, + "max_p_value": max_p_value, + "min_log_p_value": min_log_p_value, + "min_f_value": min_f_value, + "min_f_value_sam": min_f_value_sam, + "min_r2": min_r2, + "max_rmse": max_rmse, + "min_signal_quality": min_signal_quality, + "min_abs_slope": min_abs_slope, + "max_abs_slope": max_abs_slope, + "min_front": min_front, + "max_back": max_back, + "min_pec50": min_pec50, + "max_pec50": max_pec50, + } + return _combine(dataset, thresholds, regulation) + + +def _combine( + dataset: MuDataLike, + thresholds: dict[str, float | None], + regulation: Collection[str] | None, +) -> np.ndarray: + """AND together every requested check, starting from "everything passes".""" + mask = np.ones(dataset.response_matrix.shape, dtype=bool) + for name, threshold in thresholds.items(): + if threshold is not None: + mask &= _threshold_mask(dataset, name, threshold) + if regulation is not None: + mask &= _regulation_mask(dataset, regulation) + return mask + + +def _threshold_mask(dataset: MuDataLike, name: str, threshold: float) -> np.ndarray: + """Apply one row of :data:`_RULES`.""" + layer, comparison, transform = _RULES[name] + values = _metric(dataset, layer) + if transform is not None: + values = transform(values) + # A NaN comparison is already False for both directions, but say it outright: + # "no score" must never read as "passed". + return comparison(values, threshold) & ~np.isnan(values) + + +def _metric(dataset: MuDataLike, layer: str) -> np.ndarray: + """Read one metric, resolving the pEC50 sentinel to the response matrix.""" + source = dataset.response_matrix if layer == _RESPONSE_MATRIX else dataset.get_response_layer(layer) + return np.asarray(source, dtype=np.float64) + + +def _regulation_mask(dataset: MuDataLike, labels: Collection[str]) -> np.ndarray: + """Keep pairs whose ``regulation`` layer matches one of *labels*. + + The layer is numeric because an AnnData layer has to be; the encoding lives + with the code that writes it, so it is imported rather than restated. That + import is deferred because :mod:`drevalpy.curation._anndata` pulls in + ``anndata`` and ``pandas``, and this module is on the critical path of + ``import drevalpy`` via the splitter registration. + """ + from drevalpy.curation._anndata import _REGULATION_ENCODING + + unknown = sorted(set(labels) - set(_REGULATION_ENCODING)) + if unknown: + raise ValueError(f"Unknown regulation label(s) {unknown}. Valid: {sorted(_REGULATION_ENCODING)}") + + wanted = [_REGULATION_ENCODING[label] for label in labels] + values = np.asarray(dataset.get_response_layer(_REGULATION_LAYER), dtype=np.float64) + # NaN is in no category, so an undetermined curve is excluded. + return np.isin(values, wanted) diff --git a/drevalpy/data/splitters/__init__.py b/drevalpy/data/splitters/__init__.py new file mode 100644 index 000000000..17a1bf46a --- /dev/null +++ b/drevalpy/data/splitters/__init__.py @@ -0,0 +1,40 @@ +"""Cross-validation splitting strategies for drug response prediction. + +Splitters are plain callables with the signature:: + + (mudataset: MuDataLike, n_splits: int, validation_ratio: float, random_state: int) -> list[SplitMasks] + +Built-in modes (LPO, LCO, LDO, LTO) are registered via decorator on import. +Register custom splitters with:: + + @splitter_registry.register("MY_MODE", "Description", validation="LCO") + def my_splitter(mudataset, n_splits=5, validation_ratio=0.1, random_state=42): ... +""" + +from drevalpy.registry.splitter import ( + Splitter, + SplitterRegistry, + SplitValidationError, + Validation, + splitter_registry, +) + +from .lco import leave_cell_line_out as leave_cell_line_out +from .ldo import leave_drug_out as leave_drug_out +from .lpo import leave_pair_out as leave_pair_out +from .lto import leave_tissue_out as leave_tissue_out + +get_splitter = splitter_registry.get + +__all__ = [ + "Splitter", + "SplitValidationError", + "SplitterRegistry", + "Validation", + "get_splitter", + "leave_cell_line_out", + "leave_drug_out", + "leave_pair_out", + "leave_tissue_out", + "splitter_registry", +] diff --git a/drevalpy/data/splitters/_folds.py b/drevalpy/data/splitters/_folds.py new file mode 100644 index 000000000..d5f849385 --- /dev/null +++ b/drevalpy/data/splitters/_folds.py @@ -0,0 +1,139 @@ +"""Shared fold construction for the built-in splitters. + +The four modes differ only in *what* they hold out - rows, columns, tissues or +individual pairs - so the quality-filtered observation mask, the k-fold +train/validation/test partition and the mask assembly all live here. Keeping the +partition in one place also keeps the folds reproducible across modes: the +validation slice is drawn with a generator seeded from ``random_state`` on every +fold, which is a property of this function rather than of any one splitter. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.data.quality import curve_quality_mask +from drevalpy.types import SplitMask, SplitMasks + +if TYPE_CHECKING: + from collections.abc import Iterator + + from drevalpy.types import MuDataLike + + +def observed_mask(mudataset: MuDataLike) -> np.ndarray: + """Return the pairs that are measured *and* pass the curve-quality filter. + + :param mudataset: Dataset to read the response matrix and quality layers from. + :returns: Boolean cell-line-by-drug mask of usable pairs. + """ + response = mudataset.response_matrix.copy() + response[~curve_quality_mask(mudataset)] = np.nan + return ~np.isnan(response) + + +def group_folds( + n_groups: int, + *, + n_splits: int, + validation_ratio: float, + random_state: int, +) -> Iterator[tuple[np.ndarray, np.ndarray, np.ndarray]]: + """Partition ``range(n_groups)`` into train, validation and test indices per fold. + + :param n_groups: Number of groups to distribute over the folds. + :param n_splits: Number of folds; each group is in exactly one test set. + :param validation_ratio: Fraction of the non-test groups held out for validation. + :param random_state: Seed for both the fold assignment and the validation draw. + :returns: One ``(train, validation, test)`` index triple per fold. + """ + from sklearn.model_selection import KFold + + kf = KFold(n_splits=n_splits, shuffle=True, random_state=random_state) + for train_val, test in kf.split(np.arange(n_groups)): + n_val = max(1, int(len(train_val) * validation_ratio)) if validation_ratio > 0 else 0 + rng = np.random.default_rng(random_state) + rng.shuffle(train_val) + yield train_val[n_val:], train_val[:n_val], test + + +def entity_masks( + observed: np.ndarray, + *, + train: np.ndarray, + validation: np.ndarray, + test: np.ndarray, + axis: int, +) -> SplitMasks: + """Assemble one fold that holds out whole rows (*axis* 0) or columns (*axis* 1). + + :param observed: Mask of usable pairs, as returned by :func:`observed_mask`. + :param train: Indices along *axis* assigned to training. + :param validation: Indices along *axis* assigned to validation. + :param test: Indices along *axis* assigned to testing. + :param axis: 0 to split cell lines, 1 to split drugs. + :returns: The three masks of one fold. + """ + return SplitMasks( + train=SplitMask(_entity_mask(observed, train, axis)), + test=SplitMask(_entity_mask(observed, test, axis)), + val=SplitMask(_entity_mask(observed, validation, axis)), + ) + + +def pair_masks( + shape: tuple[int, ...], + rows: np.ndarray, + columns: np.ndarray, + *, + train: np.ndarray, + validation: np.ndarray, + test: np.ndarray, +) -> SplitMasks: + """Assemble one fold that holds out individual observed pairs. + + :param shape: Shape of the response matrix. + :param rows: Row coordinate of every observed pair. + :param columns: Column coordinate of every observed pair, aligned with *rows*. + :param train: Positions into *rows* / *columns* assigned to training. + :param validation: Positions assigned to validation. + :param test: Positions assigned to testing. + :returns: The three masks of one fold. + """ + return SplitMasks( + train=SplitMask(_pair_mask(shape, rows, columns, train)), + test=SplitMask(_pair_mask(shape, rows, columns, test)), + val=SplitMask(_pair_mask(shape, rows, columns, validation)), + ) + + +def rows_with_labels(labels: np.ndarray, selected: np.ndarray) -> np.ndarray: + """Return the row indices whose label is one of *selected*. + + :param labels: One label per row of the response matrix. + :param selected: Labels belonging to this side of the split. + :returns: Matching row indices. + """ + return np.where(np.isin(labels, selected))[0] + + +def _entity_mask(observed: np.ndarray, indices: np.ndarray, axis: int) -> np.ndarray: + mask = np.zeros_like(observed) + if axis == 0: + mask[indices, :] = observed[indices, :] + else: + mask[:, indices] = observed[:, indices] + return mask + + +def _pair_mask( + shape: tuple[int, ...], + rows: np.ndarray, + columns: np.ndarray, + positions: np.ndarray, +) -> np.ndarray: + mask = np.zeros(shape, dtype=bool) + mask[rows[positions], columns[positions]] = True + return mask diff --git a/drevalpy/data/splitters/lco.py b/drevalpy/data/splitters/lco.py new file mode 100644 index 000000000..7f538c6c7 --- /dev/null +++ b/drevalpy/data/splitters/lco.py @@ -0,0 +1,28 @@ +"""Leave-Cell-Line-Out splitting function.""" + +from __future__ import annotations + +from drevalpy.data.splitters._folds import entity_masks, group_folds, observed_mask +from drevalpy.registry.splitter import register +from drevalpy.types import MuDataLike, SplitMasks + + +@register("LCO", "Leave-Cell-Line-Out: test folds contain unseen cell lines", validation="LCO") +def leave_cell_line_out( + mudataset: MuDataLike, + n_splits: int = 5, + validation_ratio: float = 0.1, + random_state: int = 42, +) -> list[SplitMasks]: + """Generate LCO folds where each cell line appears in exactly one test set.""" + observed = observed_mask(mudataset) + folds = group_folds( + observed.shape[0], + n_splits=n_splits, + validation_ratio=validation_ratio, + random_state=random_state, + ) + return [ + entity_masks(observed, train=train, validation=validation, test=test, axis=0) + for train, validation, test in folds + ] diff --git a/drevalpy/data/splitters/ldo.py b/drevalpy/data/splitters/ldo.py new file mode 100644 index 000000000..c9a7370a4 --- /dev/null +++ b/drevalpy/data/splitters/ldo.py @@ -0,0 +1,28 @@ +"""Leave-Drug-Out splitting function.""" + +from __future__ import annotations + +from drevalpy.data.splitters._folds import entity_masks, group_folds, observed_mask +from drevalpy.registry.splitter import register +from drevalpy.types import MuDataLike, SplitMasks + + +@register("LDO", "Leave-Drug-Out: test folds contain unseen drugs", validation="LDO") +def leave_drug_out( + mudataset: MuDataLike, + n_splits: int = 5, + validation_ratio: float = 0.1, + random_state: int = 42, +) -> list[SplitMasks]: + """Generate LDO folds where each drug appears in exactly one test set.""" + observed = observed_mask(mudataset) + folds = group_folds( + observed.shape[1], + n_splits=n_splits, + validation_ratio=validation_ratio, + random_state=random_state, + ) + return [ + entity_masks(observed, train=train, validation=validation, test=test, axis=1) + for train, validation, test in folds + ] diff --git a/drevalpy/data/splitters/lpo.py b/drevalpy/data/splitters/lpo.py new file mode 100644 index 000000000..b2b1bb367 --- /dev/null +++ b/drevalpy/data/splitters/lpo.py @@ -0,0 +1,31 @@ +"""Leave-Pair-Out splitting function.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.data.splitters._folds import group_folds, observed_mask, pair_masks +from drevalpy.registry.splitter import register +from drevalpy.types import MuDataLike, SplitMasks + + +@register("LPO", "Leave-Pair-Out: groups by (cell_line, drug) pairs", validation="LPO") +def leave_pair_out( + mudataset: MuDataLike, + n_splits: int = 5, + validation_ratio: float = 0.1, + random_state: int = 42, +) -> list[SplitMasks]: + """Generate LPO folds where each (cell_line, drug) pair appears in exactly one test set.""" + observed = observed_mask(mudataset) + obs_rows, obs_cols = np.where(observed) + folds = group_folds( + len(obs_rows), + n_splits=n_splits, + validation_ratio=validation_ratio, + random_state=random_state, + ) + return [ + pair_masks(observed.shape, obs_rows, obs_cols, train=train, validation=validation, test=test) + for train, validation, test in folds + ] diff --git a/drevalpy/data/splitters/lto.py b/drevalpy/data/splitters/lto.py new file mode 100644 index 000000000..e2e821967 --- /dev/null +++ b/drevalpy/data/splitters/lto.py @@ -0,0 +1,38 @@ +"""Leave-Tissue-Out splitting function.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.data.splitters._folds import entity_masks, group_folds, observed_mask, rows_with_labels +from drevalpy.registry.splitter import register +from drevalpy.types import MuDataLike, SplitMasks + + +@register("LTO", "Leave-Tissue-Out: test folds contain unseen tissue types", validation="LTO") +def leave_tissue_out( + mudataset: MuDataLike, + n_splits: int = 5, + validation_ratio: float = 0.1, + random_state: int = 42, +) -> list[SplitMasks]: + """Generate LTO folds where each tissue appears in exactly one test set.""" + observed = observed_mask(mudataset) + tissues = mudataset.get_tissue(mudataset.cell_line_ids) + unique_tissues = np.unique(tissues) + folds = group_folds( + len(unique_tissues), + n_splits=n_splits, + validation_ratio=validation_ratio, + random_state=random_state, + ) + return [ + entity_masks( + observed, + train=rows_with_labels(tissues, unique_tissues[train]), + validation=rows_with_labels(tissues, unique_tissues[validation]), + test=rows_with_labels(tissues, unique_tissues[test]), + axis=0, + ) + for train, validation, test in folds + ] diff --git a/drevalpy/data/utils.py b/drevalpy/data/utils.py new file mode 100644 index 000000000..fce6b53bb --- /dev/null +++ b/drevalpy/data/utils.py @@ -0,0 +1,5 @@ +"""Runtime constants for dataset identifiers.""" + +DRUG_IDENTIFIER = "pubchem_id" +CELL_LINE_IDENTIFIER = "cell_line_name" +TISSUE_IDENTIFIER = "tissue" diff --git a/drevalpy/datasets/__init__.py b/drevalpy/datasets/__init__.py deleted file mode 100644 index 91f8d8652..000000000 --- a/drevalpy/datasets/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Module for handling datasets.""" - -from .loader import AVAILABLE_DATASETS - -__all__ = ["AVAILABLE_DATASETS"] diff --git a/drevalpy/datasets/curvecurator.py b/drevalpy/datasets/curvecurator.py deleted file mode 100644 index a3655b1f6..000000000 --- a/drevalpy/datasets/curvecurator.py +++ /dev/null @@ -1,340 +0,0 @@ -""" -Contains all function required for CurveCurator fitting. - -CurveCurator publication: -Bayer, F.P., Gander, M., Kuster, B. et al. CurveCurator: a recalibrated F-statistic to assess, -classify, and explore significance of dose–response curves. Nat Commun 14, 7902 (2023). -https://doi-org.eaccess.tum.edu/10.1038/s41467-023-43696-z - -CurveCurator applies a recalibrated F-statistic for p-value estimation of 4-point log-logistic -regression fits. In drevalpy, this can be used to generate training data with higher quality, since -quality measures, such as p-value, R2, or relevance score can be used to filter out viability -measurements of low quality. -""" - -import subprocess -import warnings -from pathlib import Path - -import numpy as np -import pandas as pd -import toml - -from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER - -from ..pipeline_function import pipeline_function - - -def _prepare_raw_data(curve_df: pd.DataFrame, output_dir: Path, prefix: str = ""): - if "replicate" in curve_df.columns: - n_replicates = curve_df["replicate"].nunique() - pivot_columns = ["dose", "replicate"] - else: - n_replicates = 1 - pivot_columns = ["dose"] - - if curve_df.duplicated(subset=["sample", "drug", "dose", "replicate"]).any(): - warnings.warn( - "CurveCurator Raw Data Processing: Duplicate entries found for some (sample, drug, dose, replicate)" - " combinations. Aggregating using mean of the 'response'.", - UserWarning, - stacklevel=1, - ) - curve_df = curve_df.groupby(["sample", "drug", "dose", "replicate"], as_index=False)["response"].mean() - - df = curve_df.pivot(index=["sample", "drug"], columns=pivot_columns, values="response") - - if "replicate" in curve_df.columns: - control_df = pd.DataFrame({(0.0, col_id): 1.0 for col_id in range(n_replicates)}, index=df.index) - else: - control_df = pd.DataFrame({0.0: 1.0}, index=df.index) - - df = pd.concat([control_df, df], axis=1) - - concentrations = df.columns.sort_values() - doses = concentrations.get_level_values(0).to_list() - df = df[concentrations] - - experiments = np.arange(df.shape[1]) - df.insert(0, "Name", ["|".join(map(str, i)) for i in df.index.tolist()]) - - df.columns = ["Name"] + [f"Raw {i}" for i in experiments] - - curvecurator_folder = output_dir / prefix - curvecurator_folder.mkdir(exist_ok=True, parents=True) - df.to_csv(curvecurator_folder / "curvecurator_input.tsv", sep="\t", index=False) - - return len(experiments), doses, n_replicates, len(df) - - -def _prepare_toml( - filename: str, - n_exp: int, - n_replicates: int, - doses: list[float], - dataset_name: str, - cores: int, - condition: str = "", - normalize: bool = False, -): - config = { - "Meta": { - "id": filename, - "description": dataset_name, - "condition": condition, - "treatment_time": "72 h", - }, - "Experiment": { - "experiments": range(n_exp), - "doses": doses, - "dose_scale": "1e-06", - "dose_unit": "uM", - "control_experiment": [i for i in range(n_replicates)], - "measurement_type": "OTHER", - "data_type": "OTHER", - "search_engine": "OTHER", - "search_engine_version": "0", - }, - "Paths": { - "input_file": "curvecurator_input.tsv", - "curves_file": "curves.tsv", - "normalization_file": "norm.txt", - "mad_file": "mad.txt", - "dashboard": "dashboard.html", - }, - "Processing": { - "available_cores": cores, - "max_missing": max(len(doses) - 5, 0), - "imputation": False, - "normalization": normalize, - }, - "Curve Fit": { - "type": "OLS", - "speed": "exhaustive", - "max_iterations": 1000, - "interpolation": False, - "control_fold_change": True, - }, - "F Statistic": { - "optimized_dofs": True, - "alpha": 0.05, - "fc_lim": 0.45, - }, - } - return config - - -def _exec_curvecurator(output_dir: Path, batched: bool = True): - """ - Execute CurveCurator in batch mode. - - This function spawns a subprocess that runs CurveCurator for all config.toml files that - are listed in a file "configlist.txt" in the provided output directory. - - :param output_dir: The directory containing einter configlist.txt as well as subfolders for - all the paths listed in configlist.txt that function as input and output directories for - batched CurveCurator execution, or the directory containig a single config.toml and - corresponding viability input. - :param batched: If True, run CurveCurator in batched mode (default), iterating over a list - of configs spefified in /configlist.txt and consecutively executing each - CurveCurator run. If False, run a single CurveCurator run (this can be used for - parallelisation). - :raises RuntimeError: If CurveCurator fails to execute, the error message is printed to stdout and stderr. - """ - if batched: - command = ["CurveCurator", str(output_dir / "configlist.txt"), "--mad", "--batch"] - else: - command = ["CurveCurator", str(output_dir / "config.toml"), "--mad"] - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - stdout, stderr = process.communicate() - - if process.returncode != 0: - print("CurveCurator stdout:") - print(stdout) - print("CurveCurator stderr:") - print(stderr) - - raise RuntimeError(f"CurveCurator failed with exit code {process.returncode}") - - -def _calc_ic50(model_params_df: pd.DataFrame): - """ - Calculate the IC50 in M from a fitted model. - - This function expects a dataframe that was processed in the postprocess function, containing - the columns "Front", "Back", "Slope", "pEC50". It calculates the IC50 for all the models in the - dataframe in closed form and adds the column IC50_curvecurator to the input dataframe. - Also adds the natural logarithm of the IC50 as LN_IC50_curvecurator. - - :param model_params_df: a dataframe containing the fitted parameters - """ - - def ic50(front, back, slope, pec50): - with np.errstate(invalid="ignore"): - return np.power(10, (np.log10((front - 0.5) / (0.5 - back)) - slope * pec50) / slope) - - front = model_params_df["Front"].values - back = model_params_df["Back"].values - slope = model_params_df["Slope"].values - # we need the pEC50 in uM; now it is in M: -log10(EC50[M] * 10^6) = -log10(EC50[M])-6 = pEC50 -6 - pec50 = model_params_df["pEC50_curvecurator"].values - 6 - - model_params_df["IC50_curvecurator"] = ic50(front, back, slope, pec50) - model_params_df["LN_IC50_curvecurator"] = np.log(model_params_df["IC50_curvecurator"].values) - - -@pipeline_function -def preprocess(input_file: str, output_dir: str, dataset_name: str, cores: int, normalize: bool = False): - """ - Preprocess raw viability data and create required input files for CurveCurator. - - This function takes an input file containing raw viability in long format. The required columns - are "dose", "response", "sample", and "drug", with an optional "replicate" column. - If there are multiple dose ranges or numbers of replicates, groups in the form - (maxdose, mindose, n_replicates) are created to keep the number of parameters for fitting low - and the input dataframes for curvecurator as dense as possible. - All dosages must be provided in µM! - All responses must be normalized against the control already without the response for the control. - - :param input_file: Path to csv file containing the raw viability data - :param output_dir: Path to store all the files to, including the preprocessed data, the config.toml - for CurveCurator, CurveCurator's output files, and the postprocessed data - :param dataset_name: Name of the dataset - :param cores: The number of cores to be used for fitting the curves using CurveCurator. - This parameter is written into the config.toml, but it is min of the number of curves to fit - and the number given (min(n_curves, cores)) - :param normalize: Whether to normalize the response values to [0, 1] for curvecurator. Default = False. - :raises ValueError: If required columns are not found in the provided input file. - """ - input_path = Path(input_file) - output_path = Path(output_dir) - required_columns = ["dose", "response", "sample", "drug", "replicate"] - converters = {"dose": float, "response": float, "sample": str, "drug": str, "replicate": int} - try: - curve_df = pd.read_csv(input_path, usecols=required_columns, converters=converters) - except ValueError: - required_columns.pop() - del converters["replicate"] - curve_df = pd.read_csv(input_path, usecols=required_columns, converters=converters) - - if not all([col in curve_df.columns for col in required_columns]): - raise ValueError(f"Missing columns in viability data. Required columns are {required_columns}.") - groupby = [] - - curve_df["mindose"] = curve_df.groupby(["sample", "drug"], as_index=False)["dose"].transform("min") - curve_df["maxdose"] = curve_df.groupby(["sample", "drug"], as_index=False)["dose"].transform("max") - - if curve_df["maxdose"].nunique() > 1: - groupby.append("maxdose") - if curve_df["mindose"].nunique() > 1: - groupby.append("mindose") - if "replicate" in curve_df.columns: - curve_df["nreplicates"] = curve_df.groupby(["sample", "drug"])["replicate"].transform("nunique") - if curve_df["nreplicates"].nunique() > 1: - groupby.append("nreplicates") - - if len(groupby) > 0: - drug_df_groups = curve_df.groupby(groupby) - else: - drug_df_groups = [("drug_treatment", curve_df)] - - configs = [] - - for index, df in drug_df_groups: - prefix = "_".join([f"{s}" for s in index]) - n_exp, doses, n_replicates, n_curves_to_fit = _prepare_raw_data( - curve_df=df, output_dir=output_path, prefix=prefix - ) - config = _prepare_toml( - filename=input_path.name, - n_exp=n_exp, - n_replicates=n_replicates, - doses=doses, - dataset_name=dataset_name, - cores=min(n_curves_to_fit, cores), - condition=prefix, - normalize=normalize, - ) - config_path = output_path / prefix / "config.toml" - with open(config_path, "w") as f: - toml.dump(config, f) - configs.append(f"{config_path}\n") - - with open(output_path / "configlist.txt", "w") as f: - f.writelines(configs) - - -@pipeline_function -def postprocess(output_folder: str, dataset_name: str): - """ - Postprocess CurveCurator output files. - - This function reads all curves.tsv files created by CurveCurator, which contain the - fitted curve parameters, postprocesses them to be used by drevalpy and combines everything - in one .csv file for usage by drevalpy. - - :param output_folder: Path to the output folder of CurveCurator containing the curves.txt file. - :param dataset_name: The name of the dataset, will be used to prepend the postprocessed .csv file - """ - output_path = Path(output_folder) - curvecurator_output_files = output_path.rglob("curves.tsv") - required_columns = { - "Name": "Name", - "pEC50": "pEC50_curvecurator", - "pEC50 Error": "pEC50Error", - "Curve Slope": "Slope", - "Curve Front": "Front", - "Curve Back": "Back", - "Curve Fold Change": "FoldChange", - "Curve AUC": "AUC_curvecurator", - "Curve R2": "R2", - "Curve P_Value": "pValue", - "Curve Relevance Score": "RelevanceScore", - "Curve F_Value": "fValue", - "Curve Log P_Value": "negLog10pValue", - "Signal Quality": "SignalQuality", - "Curve RMSE": "RMSE", - "Curve F_Value SAM Corrected": "fValueSAMCorrected", - "Curve Regulation": "Regulation", - } - - with open(output_path / f"{dataset_name}.csv", "w") as f: - first_file = True - for output_file in curvecurator_output_files: - fitted_curve_data = pd.read_csv(output_file, sep="\t", usecols=required_columns).rename( - columns=required_columns - ) - fitted_curve_data[[CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER]] = fitted_curve_data.Name.str.split( - "|", expand=True - ) - fitted_curve_data["EC50_curvecurator"] = ( - np.power(10, -fitted_curve_data["pEC50_curvecurator"].values) * 10**6 - ) # in CurveCurator 10^-pEC50 = EC50 - _calc_ic50(fitted_curve_data) - fitted_curve_data.to_csv(f, index=None, header=first_file, mode="a") - first_file = False - f.close() - - -def fit_curves(input_file: str, output_dir: str, dataset_name: str, cores: int, normalize: bool = False): - """ - Fit curves for provided raw viability data. - - This functions reads viability data in a predefined input format, preprocesses the data - to be readable by CurveCurator, fits curves to the data using CurveCurator, and postprocesses - the fitted data to a format required by drevalpy. - - :param input_file: Path to the file containing the raw viability data - :param output_dir: Path to store all the files to, including the preprocessed data, the config.toml - for CurveCurator, CurveCurator's output files, and the postprocessed data - :param dataset_name: The name of the dataset, will be used to prepend the postprocessed .csv file - :param cores: The number of cores to be used for fitting the curves using CurveCurator. - This parameter is written into the config.toml, but it is min of the number of curves to fit - and the number given (min(n_curves, cores)) - :param normalize: Whether to normalize the response values to [0, 1] for curvecurator. Default = False. - """ - preprocess( - input_file=input_file, output_dir=output_dir, dataset_name=dataset_name, cores=cores, normalize=normalize - ) - _exec_curvecurator(output_dir=Path(output_dir)) - postprocess(output_folder=output_dir, dataset_name=dataset_name) diff --git a/drevalpy/datasets/custom_splits.py b/drevalpy/datasets/custom_splits.py deleted file mode 100644 index 85752ae6f..000000000 --- a/drevalpy/datasets/custom_splits.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Compatibility exports for the split provider package (issue #407).""" - -from __future__ import annotations - -from pathlib import Path - -from .dataset import DrugResponseDataset -from .splits import ( - MANIFEST_FILENAME, - OPTIONAL_ROLES, - REQUIRED_ROLES, - TEST_MODES, - ExternalSplitCreator, - SplitCreator, - SplitError, - SplitParams, - SplitResult, - create_splits, - ensure_early_stopping_splits, - load_external_splitter, - read_manifest_test_mode, - read_split_manifest, - run_builtin_splitter, - run_external_splitter, - validate_split_label, - validate_splits, - write_split_manifest, -) - -CustomSplitError = SplitError -CustomSplitParams = SplitParams -CustomSplitCreator = ExternalSplitCreator -load_custom_splitter = load_external_splitter -validate_cv_splits = validate_splits - - -def run_custom_splitter( - response_data: DrugResponseDataset, - splitter: ExternalSplitCreator | str | Path, - *, - test_mode: str, - n_cv_splits: int = 5, - validation_ratio: float = 0.1, - random_state: int = 42, - split_early_stopping: bool = True, -) -> SplitResult: - """ - Compatibility wrapper for external split scripts. - - :param response_data: full response dataset passed to the splitter - :param splitter: callable or path to a script defining ``create_splits`` - :param test_mode: one of ``LPO``, ``LCO``, ``LDO``, or ``LTO`` - :param n_cv_splits: requested number of CV splits from the pipeline - :param validation_ratio: validation fraction from the pipeline - :param random_state: random seed from the pipeline - :param split_early_stopping: whether to derive early-stopping roles when absent - :returns: validated splits and per-split metadata rows - """ - return create_splits( - response_data, - test_mode=test_mode, - external_splitter=splitter, - n_cv_splits=n_cv_splits, - validation_ratio=validation_ratio, - random_state=random_state, - split_early_stopping=split_early_stopping, - ) - - -def run_splitter( - response_data: DrugResponseDataset, - *, - custom_splitter: ExternalSplitCreator | str | Path | None = None, - test_mode: str | None = None, - n_cv_splits: int = 5, - validation_ratio: float = 0.1, - random_state: int = 42, - split_early_stopping: bool = True, - params: SplitParams | None = None, -) -> SplitResult: - """ - Compatibility alias for ``create_splits`` using legacy argument names. - - :param response_data: full response dataset passed to the splitter - :param custom_splitter: optional callable or script path defining ``create_splits`` - :param test_mode: one of ``LPO``, ``LCO``, ``LDO``, or ``LTO``; required when ``params`` is omitted - :param n_cv_splits: requested number of CV splits from the pipeline - :param validation_ratio: validation fraction from the pipeline - :param random_state: random seed from the pipeline - :param split_early_stopping: whether to derive early-stopping roles when absent - :param params: optional pre-built split settings; overrides individual keyword args - :returns: validated splits and per-split metadata rows - :raises ValueError: if neither ``params`` nor ``test_mode`` is provided - """ - if params is None and test_mode is None: - msg = "Either params or test_mode must be provided" - raise ValueError(msg) - return create_splits( - response_data, - test_mode=params.test_mode if params is not None else test_mode, # type: ignore[arg-type] - external_splitter=custom_splitter, - n_cv_splits=params.n_cv_splits if params is not None else n_cv_splits, - validation_ratio=params.validation_ratio if params is not None else validation_ratio, - random_state=params.random_state if params is not None else random_state, - split_early_stopping=params.split_early_stopping if params is not None else split_early_stopping, - params=params, - ) - - -__all__ = [ - "MANIFEST_FILENAME", - "OPTIONAL_ROLES", - "REQUIRED_ROLES", - "TEST_MODES", - "CustomSplitCreator", - "CustomSplitError", - "CustomSplitParams", - "ExternalSplitCreator", - "SplitCreator", - "SplitError", - "SplitParams", - "create_splits", - "ensure_early_stopping_splits", - "load_custom_splitter", - "load_external_splitter", - "read_manifest_test_mode", - "read_split_manifest", - "run_builtin_splitter", - "run_custom_splitter", - "run_external_splitter", - "run_splitter", - "validate_cv_splits", - "validate_split_label", - "validate_splits", - "write_split_manifest", -] diff --git a/drevalpy/datasets/dataset.py b/drevalpy/datasets/dataset.py deleted file mode 100644 index 8dd9da5a5..000000000 --- a/drevalpy/datasets/dataset.py +++ /dev/null @@ -1,1161 +0,0 @@ -# flake8: noqa: RST201, RST203, RST301, RST401 - -""" -Defines the different dataset classes. - -DrugResponseDataset for response values and FeatureDataset for feature values. -They both inherit from the abstract class Dataset. -The DrugResponseDataset class is used -to store drug response values per cell line and drug. -The FeatureDataset class is used to store -feature values per cell line or drug. -The FeatureDataset class can also store meta information -for the feature views. The DrugResponseDataset class -can be split into training, validation and test sets for cross-validation. -The FeatureDataset class can be used to randomize feature vectors. -""" - -import copy -import os -from pathlib import Path -from typing import Any, Callable - -import networkx as nx -import numpy as np -import pandas as pd -from sklearn.base import TransformerMixin -from sklearn.model_selection import GroupKFold, train_test_split - -from ..pipeline_function import pipeline_function -from .utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER, permute_features, randomize_graph - -np.set_printoptions(threshold=6) - - -class DrugResponseDataset: - """Drug response dataset.""" - - _response: np.ndarray - _cell_line_ids: np.ndarray - _tissues: np.ndarray | None = None - _drug_ids: np.ndarray - _predictions: np.ndarray | None = None - _cv_splits: list[dict[str, "DrugResponseDataset"]] - _name: str - - @pipeline_function - def __init__( - self, - response: np.ndarray, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - tissues: np.ndarray | None = None, - predictions: np.ndarray | None = None, - dataset_name: str = "unnamed", - ) -> None: - """ - Initializes the drug response dataset. - - :param response: drug response values per cell line and drug - :param cell_line_ids: cell line IDs - :param drug_ids: drug IDs - :param tissues: Optionally, tissue types of the cell lines for leave-tissue-out cv - :param predictions: optional. Predicted drug response values per cell line and drug - :param dataset_name: optional. Name of the dataset, default: "unnamed" - :raises AssertionError: If response, cell_line_ids, drug_ids, (and the optional predictions) do not all have - the same length. - """ - super().__init__() - if len(response) != len(cell_line_ids): - raise AssertionError("Response and cell line identifiers have different lengths.") - if len(response) != len(drug_ids): - raise AssertionError("Response and drug identifiers have different lengths.") - if predictions is not None and len(response) != len(predictions): - raise AssertionError("Response and predictions have different lengths.") - self._response = response - self._cell_line_ids = cell_line_ids.astype(str) - self._drug_ids = drug_ids.astype(str) - self._predictions = predictions - self._name = dataset_name - self._cv_splits = [] - if tissues is not None: - self._tissues = np.array(tissues).astype(str) - else: - self._tissues = None - - @pipeline_function - @classmethod - def from_csv( - cls: type["DrugResponseDataset"], - input_file: str | Path, - dataset_name: str = "unknown", - measure: str = "response", - tissue_column: str | None = "tissue", - ) -> "DrugResponseDataset": - """ - Load a dataset from a csv file. - - This function creates a DrugResponseDataset from a provided input file in csv format. - The following columns are required: - - - response: the drug response values as floating point values - - cell_line_name: a string identifier for cell lines - - pubchem_id: a string identifier for drugs - - predictions: an optional column containing drug response predictions - - LN_IC50_curvecurator: the name of the column containing the measure to predict - - :param input_file: Path to the csv file containing the data to be loaded - :param dataset_name: Optional name to associate the dataset with, default = "unknown" - :param measure: The name of the column containing the measure to predict, default = "response" - :param tissue_column: Optional column name of column containing tissue types - :raises ValueError: If the required columns are not found in the input file - :returns: DrugResponseDataset object containing data from provided csv file. - """ - data = pd.read_csv(input_file, dtype={DRUG_IDENTIFIER: str, CELL_LINE_IDENTIFIER: str}, low_memory=False) - - if measure not in data.columns: - raise ValueError(f"Column {measure} not found in the input file.") - elif CELL_LINE_IDENTIFIER not in data.columns: - raise ValueError(f"Column {CELL_LINE_IDENTIFIER} not found in the input file.") - elif DRUG_IDENTIFIER not in data.columns: - raise ValueError(f"Column {DRUG_IDENTIFIER} not found in the input file.") - - data[DRUG_IDENTIFIER] = data[DRUG_IDENTIFIER].astype(str) - if "predictions" in data.columns: - predictions = data["predictions"].values - else: - predictions = None - return cls( - response=data[measure].values, - cell_line_ids=data[CELL_LINE_IDENTIFIER].values, - drug_ids=data[DRUG_IDENTIFIER].values, - predictions=predictions, - dataset_name=dataset_name, - tissues=data[tissue_column].values.astype(str) if tissue_column in data.columns else None, - ) - - @property - def response(self) -> np.ndarray: - """ - Returns the response values. - - :returns: numpy array containing response values. - """ - return self._response - - @property - def cell_line_ids(self) -> np.ndarray: - """ - Returns the cell_line_ids. - - :returns: numpy array containing cell_line_ids values. - """ - return self._cell_line_ids - - @property - def drug_ids(self) -> np.ndarray: - """ - Returns the drug_ids. - - :returns: numpy array containing drug_ids values. - """ - return self._drug_ids - - @property - def predictions(self) -> np.ndarray | None: - """ - Returns the predictions if they exist. - - :returns: numpy array containing prediction values or None. - """ - return self._predictions - - @property - def tissue(self) -> np.ndarray | None: - """ - Returns the tissue types if they exist. - - :returns: numpy array containing tissue types or None. - """ - return self._tissues - - @property - def cv_splits(self) -> list[dict[str, "DrugResponseDataset"]]: - """ - Returns the cv_splits. - - :returns: DrugResponseDatasets containing the CV_splits. - """ - return self._cv_splits - - @property - def dataset_name(self) -> str: - """ - Returns the name of this DrugResponseDataset. - - Used in the pipeline. - - :returns: dataset name. - """ - return self._name - - def __len__(self) -> int: - """ - Overwrites the default length method. - - :returns: Number of samples in the dataset - """ - return len(self.response) - - def __str__(self) -> str: - """ - Overwrite the default str method. - - :return: Text summary of the dataset - """ - string = ( - f"{self.dataset_name} DrugResponseDataset with {len(self)} entries:\n" - f"CLs {self.cell_line_ids}\n" - f"Drugs {self.drug_ids}\n" - f"Response {self.response}\n" - ) - if self.predictions is not None: - string += f"Predictions {self.predictions}\n" - return string - - def to_dataframe(self) -> pd.DataFrame: - """ - Convert the dataset into a pandas DataFrame. - - :returns: pandas DataFrame of the dataset) - """ - data = { - CELL_LINE_IDENTIFIER: self.cell_line_ids, - DRUG_IDENTIFIER: self.drug_ids, - "response": self.response, - } - if self.predictions is not None: - data["predictions"] = self.predictions - if self.tissue is not None: - data["tissue"] = self.tissue - - return pd.DataFrame(data) - - def to_csv(self, path: str | Path): - """ - Stores the drug response dataset on disk. - - :param path: path to desired storage location - """ - self.to_dataframe().to_csv(path, index=False) - - @pipeline_function - def add_rows(self, other: "DrugResponseDataset") -> None: - """ - Adds rows from another dataset. - - :param other: other dataset - """ - self._response = np.concatenate([self._response, other.response]) - self._cell_line_ids = np.concatenate([self._cell_line_ids, other.cell_line_ids]) - self._drug_ids = np.concatenate([self._drug_ids, other.drug_ids]) - - if self.tissue is not None and other.tissue is not None: - self._tissues = np.concatenate([self.tissue, other.tissue]) - - if self.predictions is not None and other.predictions is not None: - self._predictions = np.concatenate([self._predictions, other.predictions]) - - @pipeline_function - def remove_nan_responses(self) -> None: - """Removes rows with NaN values in the response.""" - mask = ~np.isnan(self.response) - self.mask(mask) - - @pipeline_function - def shuffle(self, random_state: int = 42) -> None: - """ - Shuffles the dataset. - - :param random_state: random state - """ - indices = np.arange(len(self)) - rng = np.random.default_rng(random_state) - rng.shuffle(indices) - self._response = self.response[indices] - self._cell_line_ids = self.cell_line_ids[indices] - self._drug_ids = self.drug_ids[indices] - if self.predictions is not None: - self._predictions = self.predictions[indices] - if self.tissue is not None: - self._tissues = self.tissue[indices] - - def _remove_drugs(self, drugs_to_remove: str | list[str]) -> None: - """ - Removes one or more drugs from the dataset. - - :param drugs_to_remove: A single drug ID (str) or a list of IDs to remove. - """ - if isinstance(drugs_to_remove, str): - drugs_to_remove = [drugs_to_remove] - - mask: np.ndarray = ~np.isin(self.drug_ids, drugs_to_remove) - self.mask(mask) - - def _remove_cell_lines(self, cell_lines_to_remove: str | list[str]) -> None: - """ - Removes one or more cell lines from the dataset. - - :param cell_lines_to_remove: A single cell line ID (str) or a list of IDs to remove. - """ - if isinstance(cell_lines_to_remove, str): - cell_lines_to_remove = [cell_lines_to_remove] - - mask: np.ndarray = ~np.isin(self.cell_line_ids, cell_lines_to_remove) - self.mask(mask) - - def remove_rows(self, indices: np.ndarray) -> None: - """ - Removes rows from the dataset. - - :param indices: indices of rows to remove - :raises ValueError: if indices are out of bounds or not 1-dimensional - """ - if indices.ndim != 1: - raise ValueError("Indices must be a 1-dimensional array.") - if np.any(indices >= len(self)) or np.any(indices < 0): - raise ValueError("Indices are out of bounds.") - if len(indices) == 0: - return - - mask = np.ones(len(self), dtype=bool) - mask[indices] = False - self.mask(mask) - - def reduce_to(self, cell_line_ids: np.ndarray | None = None, drug_ids: np.ndarray | None = None) -> None: - """ - Removes all rows which contain a cell_line not in cell_line_ids or a drug not in drug_ids. - - :param cell_line_ids: cell line IDs or None to keep all cell lines - :param drug_ids: drug IDs or None to keep all cell lines - """ - if drug_ids is not None: - self._remove_drugs(list(set(self.drug_ids) - set(drug_ids.astype(str)))) - - if cell_line_ids is not None: - self._remove_cell_lines(list(set(self.cell_line_ids) - set(cell_line_ids.astype(str)))) - - @pipeline_function - def split_dataset( - self, - n_cv_splits: int, - mode: str, - split_validation: bool = True, - split_early_stopping: bool = True, - validation_ratio: float = 0.1, - random_state: int = 42, - ) -> list[dict]: - """ - Splits the dataset into training, validation and test sets for cross-validation. - - :param n_cv_splits: number of cross-validation splits, e.g., 5 - :param mode: split mode ('LPO', 'LCO', 'LDO') - :param split_validation: if True, a validation set is generated - :param split_early_stopping: if True, an early stopping set is generated - :param validation_ratio: ratio of validation set size to training set size - :param random_state: random state - :returns: list of dictionaries containing the cross-validation datasets. - Each fold is a dictionary with keys 'train', 'validation', 'test', 'validation_es', 'early_stopping'. - :raises ValueError: if mode is not 'LPO', 'LCO', or 'LDO' - :raises ValueError: if LTO cross-validation but tissue information not provided - """ - if mode == "LPO": - cv_splits = _leave_pair_out_cv( - n_cv_splits=n_cv_splits, - response=self.response, - cell_line_ids=self.cell_line_ids, - drug_ids=self.drug_ids, - tissues=self.tissue, - split_validation=split_validation, - validation_ratio=validation_ratio, - random_state=random_state, - dataset_name=self.dataset_name, - ) - - elif mode in ["LCO", "LTO", "LDO"]: - if mode == "LTO": - # Leave-tissue-out cross-validation - group = "tissue" - if self.tissue is None: - raise ValueError("Tissue information is required for LTO cross-validation.") - elif mode == "LCO": - # Leave-cell-line-out cross-validation - group = "cell_line" - else: - # Leave-drug-out cross-validation - group = "drug" - - cv_splits = _leave_group_out_cv( - group=group, - n_cv_splits=n_cv_splits, - response=self.response, - cell_line_ids=self.cell_line_ids, - drug_ids=self.drug_ids, - tissues=self.tissue, - split_validation=split_validation, - validation_ratio=validation_ratio, - random_state=random_state, - dataset_name=self.dataset_name, - ) - else: - raise ValueError(f"Unknown split mode {mode!r}. Choose from 'LPO', 'LCO', 'LTO', 'LDO'.") - - if split_validation and split_early_stopping: - for split in cv_splits: - validation_es, early_stopping = split_early_stopping_data(split["validation"], test_mode=mode) - split["validation_es"] = validation_es - split["early_stopping"] = early_stopping - self._cv_splits = cv_splits - return cv_splits - - def save_splits(self, path: str): - """ - Save cross validation splits to path/cv_split_0_train.csv and path/cv_split_0_test.csv. - - :param path: path to the directory where the cv split files are saved - :raises AssertionError: if DrugResponseDataset was not split - """ - if not self.cv_splits: - raise AssertionError("Trying to save splits, but DrugResponseDataset was not split.") - os.makedirs(path, exist_ok=True) - for i, split in enumerate(self.cv_splits): - - for mode in [ - "train", - "validation", - "test", - "validation_es", - "early_stopping", - ]: - if mode in split: - split_path = os.path.join(path, f"cv_split_{i}_{mode}.csv") - split[mode].to_csv(path=split_path) - - def load_splits(self, path: str) -> None: - """ - Load cross validation splits from path/cv_split_0_train.csv and path/cv_split_0_test.csv. - - :param path: path to the directory containing the cv split files - :raises AssertionError: if no cv split files are found in path - """ - files = os.listdir(path) - files = [file for file in files if (file.endswith(".csv") and file.startswith("cv_split"))] - if len(files) == 0: - raise AssertionError(f"No cv split files found in {path}") - - train_splits = [file for file in files if "train" in file] - test_splits = [file for file in files if "test" in file] - - validation_es_splits = [file for file in files if "validation_es" in file] - validation_splits = [file for file in files if "validation" in file and file not in validation_es_splits] - early_stopping_splits = [file for file in files if "early_stopping" in file] - - for ds in [ - train_splits, - test_splits, - validation_splits, - validation_es_splits, - early_stopping_splits, - ]: - ds.sort() - - optional_splits = { - "validation": validation_splits, - "validation_es": validation_es_splits, - "early_stopping": early_stopping_splits, - } - self._cv_splits.clear() - - for split_train, split_test in zip(train_splits, test_splits, strict=True): - tr_split = DrugResponseDataset.from_csv(os.path.join(path, split_train), dataset_name=self.dataset_name) - te_split = DrugResponseDataset.from_csv(os.path.join(path, split_test), dataset_name=self.dataset_name) - self._cv_splits.append({"train": tr_split, "test": te_split}) - - for mode in ["validation", "validation_es", "early_stopping"]: - if len(optional_splits[mode]) > 0: - for i, v_split in enumerate(optional_splits[mode]): - split = DrugResponseDataset.from_csv(os.path.join(path, v_split), dataset_name=self.dataset_name) - self._cv_splits[i][mode] = split - - def copy(self): - """Returns a copy of the drug response dataset. - - :returns: copy of the dataset - """ - return DrugResponseDataset( - response=copy.deepcopy(self.response), - cell_line_ids=copy.deepcopy(self.cell_line_ids), - drug_ids=copy.deepcopy(self.drug_ids), - predictions=copy.deepcopy(self.predictions), - tissues=copy.deepcopy(self.tissue), - dataset_name=self.dataset_name, - ) - - def __hash__(self) -> int: - """Overwrites default hash method. - - :returns: hash value of the dataset - """ - return hash( - ( - self.dataset_name, - tuple(self.cell_line_ids), - tuple(self.drug_ids), - tuple(self.response), - (tuple(self.predictions) if self.predictions is not None else None), - (tuple(self.tissue) if self.tissue is not None else None), - ) - ) - - def mask(self, mask: np.ndarray) -> None: - """ - Removes rows from the dataset based on a boolean mask. - - :param mask: boolean mask - :raises ValueError: if mask is not boolean or integer - """ - if mask.dtype != bool and not np.issubdtype(mask.dtype, np.integer): - raise ValueError("Mask must be of boolean or integer dtype.") - - self._response = self.response[mask] - self._cell_line_ids = self.cell_line_ids[mask] - self._drug_ids = self.drug_ids[mask] - if self.predictions is not None: - self._predictions = self.predictions[mask] - if self.tissue is not None: - self._tissues = self.tissue[mask] - - @pipeline_function - def transform(self, response_transformation: TransformerMixin) -> None: - """ - Apply transformation to the response data and prediction data of the dataset. - - :param response_transformation: e.g., StandardScaler, MinMaxScaler, RobustScaler - """ - self._response = response_transformation.transform(self.response.reshape(-1, 1)).squeeze() - if self.predictions is not None: - self._predictions = response_transformation.transform(self.predictions.reshape(-1, 1)).squeeze() - - @pipeline_function - def fit_transform(self, response_transformation: TransformerMixin) -> None: - """ - Fit and transform the response data and prediction data of the dataset. - - :param response_transformation: e.g., StandardScaler, MinMaxScaler, RobustScaler - """ - response_transformation.fit(self.response.reshape(-1, 1)) - self.transform(response_transformation) - - def inverse_transform(self, response_transformation: TransformerMixin) -> None: - """ - Inverse transform the response data and prediction data of the dataset. - - :param response_transformation: e.g., StandardScaler, MinMaxScaler, RobustScaler - """ - self._response = response_transformation.inverse_transform(self.response.reshape(-1, 1)).squeeze() - if self.predictions is not None: - self._predictions = response_transformation.inverse_transform(self.predictions.reshape(-1, 1)).squeeze() - - -@pipeline_function -def split_early_stopping_data( - validation_dataset: DrugResponseDataset, test_mode: str -) -> tuple[DrugResponseDataset, DrugResponseDataset]: - """ - Splits the validation dataset into a validation and an early stopping dataset. - - :param validation_dataset: input validation dataset - :param test_mode: LPO, LCO, LTO, LDO - :raises ValueError: if test_mode is not one of the expected values - :returns: the resulting validation and early stopping datasets - """ - validation_dataset.shuffle(random_state=42) - - # Determine the number of splits b (default 4, - # but can be less if there are not enough groups) - if test_mode == "LTO": - tissues = validation_dataset.tissue - if tissues is None: - raise ValueError("Tissue information is required for LTO.") - n_splits = min(4, len(np.unique(tissues))) - elif test_mode == "LCO": - n_splits = min(4, len(np.unique(validation_dataset.cell_line_ids))) - elif test_mode == "LDO": - n_splits = min(4, len(np.unique(validation_dataset.drug_ids))) - else: - n_splits = 4 - - cv_v = validation_dataset.split_dataset( - n_cv_splits=n_splits, - mode=test_mode, - split_validation=False, - split_early_stopping=False, - random_state=42, - ) - # take the first fold of a 4 cv as the split i.e. 3/4 for validation and 1/4 for early stopping - # when n_groups is less than 4, we splits the validation dataset into 2/3 and 1/3 or 1/2 and 1/2 - validation_dataset = cv_v[0]["train"] - early_stopping_dataset = cv_v[0]["test"] - return validation_dataset, early_stopping_dataset - - -def _leave_pair_out_cv( - n_cv_splits: int, - response: np.ndarray, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - tissues: np.ndarray | None = None, - split_validation: bool = True, - validation_ratio: float = 0.1, - random_state: int = 42, - dataset_name: str = "unknown", -) -> list[dict[str, DrugResponseDataset]]: - """ - Leave pair out cross validation. Splits data into n_cv_splits number of cross validation splits. - - :param n_cv_splits: number of cross validation splits - :param response: response (e.g. ic50 values) - :param cell_line_ids: cell line IDs - :param drug_ids: drug IDs - :param tissues: tissue types of the cell line, if available - :param split_validation: whether to split the training set into training and validation set - :param validation_ratio: ratio of validation set (of the training set) - :param random_state: random state - :param dataset_name: name of the dataset - :returns: list of dicts of the cross validation sets - :raises AssertionError: if response, cell_line_ids and drug_ids have different lengths - """ - if not (len(response) == len(cell_line_ids) == len(drug_ids)): - raise AssertionError("response, cell_line_ids and drug_ids must have the same length") - indices = np.arange(len(response)) - rng = np.random.default_rng(random_state) - shuffled_indices = rng.permutation(indices) - response = response[shuffled_indices].copy() - cell_line_ids = cell_line_ids[shuffled_indices].copy() - drug_ids = drug_ids[shuffled_indices].copy() - if tissues is not None: - tissues = tissues[shuffled_indices].copy() - - # We use GroupKFold to ensure that each pair is only in one fold (prevent data leakage due to - # experimental replicates). - # If there are no replicates this is equivalent to KFold. - groups = [cell + "_" + drug for cell, drug in zip(cell_line_ids, drug_ids, strict=True)] - kf = GroupKFold(n_splits=n_cv_splits) - cv_sets = [] - - for train_indices, test_indices in kf.split(response, groups=groups): - if split_validation: - # split training set into training and validation set - train_indices, validation_indices = train_test_split( - train_indices, - test_size=validation_ratio, - shuffle=True, - random_state=random_state, - ) - cv_fold = { - "train": DrugResponseDataset( - cell_line_ids=cell_line_ids[train_indices], - drug_ids=drug_ids[train_indices], - response=response[train_indices], - tissues=tissues[train_indices] if tissues is not None else None, - dataset_name=dataset_name, - ), - "test": DrugResponseDataset( - cell_line_ids=cell_line_ids[test_indices], - drug_ids=drug_ids[test_indices], - response=response[test_indices], - tissues=tissues[test_indices] if tissues is not None else None, - dataset_name=dataset_name, - ), - } - - if split_validation: - cv_fold["validation"] = DrugResponseDataset( - cell_line_ids=cell_line_ids[validation_indices], - drug_ids=drug_ids[validation_indices], - response=response[validation_indices], - tissues=tissues[validation_indices] if tissues is not None else None, - dataset_name=dataset_name, - ) - - cv_sets.append(cv_fold) - return cv_sets - - -def _leave_group_out_cv( - group: str, - n_cv_splits: int, - response: np.ndarray, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - tissues: np.ndarray | None = None, - split_validation: bool = True, - validation_ratio: float = 0.1, - random_state: int = 42, - dataset_name: str = "unknown", -): - """ - Leave group out cross validation: Splits data into n_cv_splits number of cross validation splits. - - :param group: group to leave out (cell_line or drug) - :param n_cv_splits: number of cross validation splits - :param response: response (e.g. ic50 values) - :param cell_line_ids: cell line IDs - :param drug_ids: drug IDs - :param tissues: tissue types of the cell line, if available (required for LTO) - :param split_validation: whether to split the training set into training and validation set - :param validation_ratio: ratio of validation set (of the training set) - :param random_state: random state - :param dataset_name: name of the dataset - :returns: list of dicts of the cross validation sets - :raises AssertionError: if group is not 'cell_line' or 'drug' or 'tissue' - :raises AssertionError: Tissue information is required for LTO cross-validation - """ - if group not in {"cell_line", "drug", "tissue"}: - raise AssertionError(f"group must be 'cell_line' or 'drug', but is {group}") - - if group == "cell_line": - group_ids = cell_line_ids - elif group == "drug": - group_ids = drug_ids - elif group == "tissue": - if tissues is None: - raise AssertionError("Tissue information is required for LTO cross-validation.") - group_ids = tissues - else: - raise AssertionError(f"Unknown group {group}") - - # shuffle, since GroupKFold does not implement this - indices = np.arange(len(response)) - rng = np.random.default_rng(random_state) - shuffled_indices = rng.permutation(indices) - response = response[shuffled_indices].copy() - cell_line_ids = cell_line_ids[shuffled_indices].copy() - drug_ids = drug_ids[shuffled_indices].copy() - tissues = tissues[shuffled_indices].copy() if tissues is not None else None - group_ids = group_ids[shuffled_indices].copy() - gkf = GroupKFold(n_splits=n_cv_splits) - cv_sets = [] - - for train_indices, test_indices in gkf.split(response, groups=group_ids): - cv_fold = { - "train": DrugResponseDataset( - cell_line_ids=cell_line_ids[train_indices], - drug_ids=drug_ids[train_indices], - response=response[train_indices], - tissues=tissues[train_indices] if tissues is not None else None, - dataset_name=dataset_name, - ), - "test": DrugResponseDataset( - cell_line_ids=cell_line_ids[test_indices], - drug_ids=drug_ids[test_indices], - response=response[test_indices], - tissues=tissues[test_indices] if tissues is not None else None, - dataset_name=dataset_name, - ), - } - if split_validation: - # split training set into training and validation set. - # The validation set also does - # contain unqiue cell lines/drugs - unique_train_groups = np.unique(group_ids[train_indices]) - train_groups, validation_groups = train_test_split( - unique_train_groups, - test_size=validation_ratio, - shuffle=True, - random_state=random_state, - ) - train_indices = np.where(np.isin(group_ids, train_groups))[0] - validation_indices = np.where(np.isin(group_ids, validation_groups))[0] - cv_fold["train"] = DrugResponseDataset( - cell_line_ids=cell_line_ids[train_indices], - drug_ids=drug_ids[train_indices], - response=response[train_indices], - tissues=tissues[train_indices] if tissues is not None else None, - dataset_name=dataset_name, - ) - cv_fold["validation"] = DrugResponseDataset( - cell_line_ids=cell_line_ids[validation_indices], - drug_ids=drug_ids[validation_indices], - response=response[validation_indices], - tissues=tissues[validation_indices] if tissues is not None else None, - dataset_name=dataset_name, - ) - - cv_sets.append(cv_fold) - return cv_sets - - -class FeatureDataset: - """ - Class for feature datasets. - - This class represents datasets with one or more views of features associated with a set of entities, - such as drugs or cell lines. The feature data is stored in a nested dictionary structure: - - { - identifier_1: { - view_name_1: feature_vector, - view_name_2: feature_vector, - ... - }, - identifier_2: { - view_name_1: feature_vector, - view_name_2: feature_vector, - ... - }, - ... - } - - - Each outer key is a string identifier (e.g. a cell line ID or drug ID) - - Each inner key is the name of a view (e.g. 'gene_expression', 'fingerprints') - - Each inner value is a feature vector or object representing that view for the identifier - """ - - _features: dict[str, dict[str, Any]] - _meta_info: dict[str, Any] - - @classmethod - def from_csv( - cls: type["FeatureDataset"], - path_to_csv: str | Path, - id_column: str, - view_name: str, - drop_columns: list[str] | None = None, - transpose: bool = False, - extract_meta_info: bool = True, - ): - """Load a one-view feature dataset from a csv file. - - Load a feature dataset from a csv file. The rows of the csv file represent the instances (cell lines or drugs), - the columns represent the features. A column named id_column contains the identifiers of the instances. - All unrelated columns (e.g. other id columns) should be provided as drop_columns, - that will be removed from the dataset. - - :param path_to_csv: path to the csv file containing the data to be loaded - :param view_name: name of the view (e.g. gene_expression) - :param id_column: name of the column containing the identifiers - :param drop_columns: list of columns to drop (e.g. other identifier columns) - :param transpose: if True, the csv is transposed, i.e. the rows become columns and vice versa - :param extract_meta_info: if True, extracts meta information from the dataset, e.g. gene names for gene expression - :returns: FeatureDataset object containing data from provided csv file. - """ - data = pd.read_csv(path_to_csv).T if transpose else pd.read_csv(path_to_csv) - data[id_column] = data[id_column].astype(str) - ids = data[id_column].values - data_features = data.drop(columns=(drop_columns or [])) - data_features = data_features.set_index(id_column) - data_features = data_features[~data_features.index.duplicated(keep="first")] - features = {} - - for identifier in ids: - features_for_instance = data_features.loc[identifier].values - features[identifier] = {view_name: features_for_instance} - - meta_info = {} - if extract_meta_info: - meta_info = {view_name: list(data_features.columns)} - - return cls(features=features, meta_info=meta_info) - - def to_csv(self, path: str | Path, id_column: str, view_name: str): - """ - Save the feature dataset to a CSV file. If meta_info is available for the view and valid, - it will be written as column names. - - :param path: Path to the CSV file. - :param id_column: Name of the column containing the identifiers. - :param view_name: Name of the view. - """ - data = [] - feature_names = None - - for identifier, feature_dict in self.features.items(): - vector = feature_dict.get(view_name) - if vector is None: - raise ValueError(f"View {view_name!r} not found for identifier {identifier!r}.") - - if feature_names is None: - meta_names = self.meta_info.get(view_name) - if isinstance(meta_names, list) and len(meta_names) == len(vector): - feature_names = meta_names - else: - feature_names = [f"feature_{i}" for i in range(len(vector))] - - row = {id_column: identifier} - row.update({name: value for name, value in zip(feature_names, vector)}) - data.append(row) - - df = pd.DataFrame(data) - df.to_csv(path, index=False) - - @property - def meta_info(self) -> dict[str, Any]: - """ - Returns the meta information. - - :returns: Meta information of this FeatureDataset - """ - return self._meta_info - - @property - def features(self) -> dict[str, dict[str, Any]]: - """ - Returns the features. - - :returns: features of this FeatureDataset - """ - return self._features - - @property - def identifiers(self) -> np.ndarray: - """ - Returns the identifiers of the features. - - Used in the pipeline. - - :returns: feature identifiers of this FeatureDataset - """ - return np.array(list(self.features.keys())) - - @property - def view_names(self) -> list[str]: - """ - Returns the view_names. - - :returns: view_names of this FeatureDataset - """ - return list(self.features[list(self.features.keys())[0]].keys()) # TODO whut?! - - def __init__( - self, - features: dict[str, dict[str, Any]], - meta_info: dict[str, Any] | None = None, - ): - """ - Initializes the feature dataset. - - :param features: dictionary of features, - key: drug ID/cell line ID, value: Dict of feature views, - key: feature name, value: feature vector - :param meta_info: additional information for the views, e.g. gene names for gene expression - :raises AssertionError: if meta_info keys are not in view names - """ - super().__init__() - self._features = features - self._meta_info = meta_info if meta_info is not None else {} - if meta_info is not None: - # assert that str of meta Dict[str, Any] is in view_names - if not all(meta_key in self.view_names for meta_key in meta_info.keys()): - raise AssertionError(f"Meta keys {meta_info.keys()} not in view names {self.view_names}") - self._meta_info = meta_info - - def randomize_features(self, views_to_randomize: str | list[str], randomization_type: str) -> None: - """ - Randomizes the feature vectors. - - Permutation permutes the feature vectors. - Invariant means that the randomization is done in a way that a key characteristic of the feature is - preserved. In case of matrices, this is the mean and standard deviation of the feature view for this - instance, for networks it is the degree distribution. - - :param views_to_randomize: name of feature view or list of names of multiple feature views - to randomize. The other views are not randomized. - :param randomization_type: randomization type ('permutation', 'invariant'). - :raises AssertionError: if randomization_type is not 'permutation' or 'invariant' - :raises ValueError: if no invariant randomization is available for the feature view type - """ - if randomization_type not in ["permutation", "invariant"]: - raise AssertionError( - f"Unknown randomization type {randomization_type!r}. Choose from 'permutation', 'invariant'." - ) - - if isinstance(views_to_randomize, str): - views_to_randomize = [views_to_randomize] - - if randomization_type == "permutation": - # Permute the specified views for each entity (= cell line or drug) - # E.g. each cell line gets the feature vector/graph/image... - # of another cell line. - # Drawn without replacement. - self._features = permute_features( - features=self.features, - views_to_permute=views_to_randomize, - identifiers=self.identifiers, - all_views=self.view_names, - ) - - elif randomization_type == "invariant": - # Invariant randomization: - # Randomize the specified views for each entity in a way that - # a key characteristic of the feature is preserved. - # For vectors this is the mean and standard deviation the feature view, - # for networks the degree distribution. - for view in views_to_randomize: - for identifier in self.identifiers: - if isinstance(self.features[identifier][view], np.ndarray): - new_features = np.random.normal( - self.features[identifier][view].mean(), - self.features[identifier][view].std(), - self.features[identifier][view].shape, - ) - elif isinstance(self.features[identifier][view], nx.classes.graph.Graph): - new_features = randomize_graph(self.features[identifier][view]) - - else: - raise ValueError( - f"No invariant randomization available for feature view " - f"type {type(self.features[identifier][view])!r}." - ) - self.features[identifier][view] = new_features - - def get_feature_matrix(self, view: str, identifiers: np.ndarray) -> np.ndarray: - """ - Returns the feature matrix for the given view. - - The feature view must be a vector or matrix. - - :param view: view name - :param identifiers: list of identifiers (cell lines oder drugs) - :returns: feature matrix - :raises AssertionError: if no identifiers are given - :raises AssertionError: if view is not in the FeatureDataset - :raises AssertionError: if identifiers are not in the FeatureDataset - :raises AssertionError: if feature vectors of view have different lengths - :raises AssertionError: if view is not a numpy array, i.e. not a vector or matrix - """ - if len(identifiers) == 0: - raise AssertionError("get_feature_matrix: No identifiers given.") - - if view not in self.view_names: - raise AssertionError(f"View {view!r} not in in the FeatureDataset.") - missing_identifiers = {id_ for id_ in identifiers if id_ not in self.identifiers} - if missing_identifiers: - raise AssertionError( - f"{len(missing_identifiers)} of {len(np.unique(identifiers))} ids are not in the " - f"FeatureDataset. Missing ids: {missing_identifiers}" - ) - - if not all(len(self.features[id_][view]) == len(self.features[identifiers[0]][view]) for id_ in identifiers): - raise AssertionError(f"Feature vectors of view {view} have different lengths.") - - if not all(isinstance(self.features[id_][view], np.ndarray) for id_ in identifiers): - raise AssertionError(f"get_feature_matrix only works for vectors or matrices. {view} is not a numpy array.") - out = np.array([self.features[id_][view] for id_ in identifiers]) - return out - - def copy(self): - """Returns a copy of the feature dataset. - - :returns: copy of the dataset - """ - return FeatureDataset(features=copy.deepcopy(self.features), meta_info=copy.deepcopy(self.meta_info)) - - def add_features(self, other: "FeatureDataset") -> None: - """ - Adds features views from another dataset. Inner join (only common identifiers are kept). - - :param other: other dataset - :raises AssertionError: if feature views overlap - """ - if len(set(self.view_names) & set(other.view_names)) != 0: - raise AssertionError( - "Trying to add features but feature views overlap. FeatureDatasets should be distinct." - ) - if other.meta_info: - self.add_meta_info(other) - - common_identifiers = set(self.identifiers).intersection(other.identifiers) - new_features = {} - for id_ in common_identifiers: - id_ = str(id_) - new_features[id_] = {view: self.features[id_][view] for view in self.view_names} - for view in other.view_names: - new_features[id_][view] = other.features[id_][view] - - self._features = new_features - - def add_meta_info(self, other: "FeatureDataset") -> None: - """ - Adds meta information to the feature dataset. - - :param other: other dataset - """ - other_meta = other.meta_info - if self.meta_info is None: - self.meta_info = other_meta - else: - if other_meta is not None: - self.meta_info.update(other_meta) - - def transform_features(self, ids: np.ndarray, transformer: TransformerMixin, view: str): - """ - Applies a transformation like standard scaling to features. - - :param ids: The IDs to transform - :param transformer: fitted sklearn transformer - :param view: the view to transform - :raises AssertionError: if view is not in the FeatureDataset - :raises AssertionError: if a cell line is missing - :raises AssertionError: if IDs are not unique - """ - if view not in self.view_names: - raise AssertionError(f"Transform view {view!r} not in in the FeatureDataset.") - if not all([clid in self.features for clid in ids]): - raise AssertionError("Trying to transform, but a cell line is missing.") - - if len(np.unique(ids)) != len(ids): - raise AssertionError("IDs should be unique.") - - for identifier in ids: - feature_vector = self.features[identifier][view] - scaled_feature_vector = transformer.transform([feature_vector])[0] - self.features[identifier][view] = scaled_feature_vector - - def fit_transform_features(self, train_ids: np.ndarray, transformer: TransformerMixin, view: str): - """ - Fits and applies a transformation. Fitting is done only on the train_ids. - - :param train_ids: The IDs corresponding to the training dataset. - :param transformer: sklearn transformer - :param view: the view to transform - :returns: The modified FeatureDataset with transformed gene expression features. - :raises AssertionError: if view is not in the FeatureDataset - :raises AssertionError: if train IDs are not unique - """ - if view not in self.view_names: - raise AssertionError(f"Transform view {view!r} not in in the FeatureDataset.") - - if len(np.unique(train_ids)) != len(train_ids): - print(f"Train IDs: {train_ids}") - - raise AssertionError("Train IDs should be unique.") - - train_features = np.vstack([self.features[identifier][view] for identifier in train_ids]) - transformer.fit(train_features) - - # Apply transformation and scaling to each feature vector - for identifier in self.features: - feature_vector = self.features[identifier][view] - transformed_vector = transformer.transform([feature_vector])[0] - self.features[identifier][view] = transformed_vector - return transformer - - def apply(self, function: Callable, view: str): - """Applies a function to the features of a view. - - :param function: function to apply - :param view: view to apply the function to - """ - for identifier in self.features: - self.features[identifier][view] = function(self.features[identifier][view]) diff --git a/drevalpy/datasets/featurizer/create_chemberta_drug_embeddings.py b/drevalpy/datasets/featurizer/create_chemberta_drug_embeddings.py deleted file mode 100644 index 3d19ff183..000000000 --- a/drevalpy/datasets/featurizer/create_chemberta_drug_embeddings.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Preprocesses drug SMILES strings into ChemBERTa embeddings.""" - -import argparse -from pathlib import Path - -import pandas as pd -import torch -from tqdm import tqdm - -try: - from transformers import AutoModel, AutoTokenizer -except ImportError: - raise ImportError( - "Please install transformers package for ChemBERTa embedding featurizer: pip install transformers" - ) -# Load ChemBERTa -tokenizer = AutoTokenizer.from_pretrained("seyonec/ChemBERTa-zinc-base-v1") -model = AutoModel.from_pretrained("seyonec/ChemBERTa-zinc-base-v1") -model.eval() - - -def _smiles_to_chemberta(smiles: str, device="cpu"): - inputs = tokenizer(smiles, return_tensors="pt", truncation=True) - inputs = {k: v.to(device) for k, v in inputs.items()} - - with torch.no_grad(): - outputs = model(**inputs) - hidden_states = outputs.last_hidden_state - - embedding = hidden_states.mean(dim=1).squeeze(0) - return embedding.cpu().numpy() - - -def main(): - """Process drug SMILES and save ChemBERTa embeddings. - - :raises Exception: If a drug fails to process. - """ - parser = argparse.ArgumentParser(description="Preprocess drug SMILES to ChemBERTa embeddings.") - parser.add_argument("dataset_name", type=str, help="The name of the dataset to process.") - parser.add_argument("--device", type=str, default="cpu", help="Torch device (cpu or cuda)") - parser.add_argument("--data_path", type=str, default="data", help="Path to the data folder") - args = parser.parse_args() - - dataset_name = args.dataset_name - device = args.device - data_dir = Path(args.data_path).resolve() - - smiles_file = data_dir / dataset_name / "drug_smiles.csv" - output_file = data_dir / dataset_name / "drug_chemberta_embeddings.csv" - - if not smiles_file.exists(): - print(f"Error: {smiles_file} not found.") - return - - smiles_df = pd.read_csv(smiles_file, dtype={"canonical_smiles": str, "pubchem_id": str}) - embeddings_list = [] - drug_ids = [] - - print(f"Processing {len(smiles_df)} drugs for dataset {dataset_name}...") - - for row in tqdm(smiles_df.itertuples(index=False), total=len(smiles_df)): - drug_id = row.pubchem_id - smiles = row.canonical_smiles - - try: - embedding = _smiles_to_chemberta(smiles, device=device) - embeddings_list.append(embedding) - drug_ids.append(drug_id) - except Exception as e: - print() - print(smiles) - print() - print(f"Failed to process {drug_id}") - raise e - - embeddings_array = pd.DataFrame(embeddings_list) - embeddings_array.insert(0, "pubchem_id", drug_ids) - embeddings_array.to_csv(output_file, index=False) - - print(f"Finished processing. Embeddings saved to {output_file}") - - -if __name__ == "__main__": - main() diff --git a/drevalpy/datasets/featurizer/create_drug_graphs.py b/drevalpy/datasets/featurizer/create_drug_graphs.py deleted file mode 100644 index c79e09ca2..000000000 --- a/drevalpy/datasets/featurizer/create_drug_graphs.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -Preprocesses drug SMILES strings into graph representations. - -This script takes a dataset name as input, reads the corresponding -drug_smiles.csv file, and converts each SMILES string into a -torch_geometric.data.Data object. The resulting graph objects are saved -to {data_path}/{dataset_name}/drug_graphs/{drug_name}.pt. -""" - -import argparse -import os -from pathlib import Path - -import pandas as pd -import torch -from torch_geometric.data import Data -from tqdm import tqdm - -try: - from rdkit import Chem -except ImportError: - raise ImportError("Please install rdkit package for drug graphs featurizer: pip install rdkit") - -# Atom feature configuration -ATOM_FEATURES = { - "atomic_num": list(range(1, 119)), - "degree": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - "formal_charge": [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5], - "num_hs": [0, 1, 2, 3, 4, 5, 6, 7, 8], - "hybridization": [ - Chem.rdchem.HybridizationType.SP, - Chem.rdchem.HybridizationType.SP2, - Chem.rdchem.HybridizationType.SP3, - Chem.rdchem.HybridizationType.SP3D, - Chem.rdchem.HybridizationType.SP3D2, - ], -} - -# Bond feature configuration -BOND_FEATURES = { - "bond_type": [ - Chem.rdchem.BondType.SINGLE, - Chem.rdchem.BondType.DOUBLE, - Chem.rdchem.BondType.TRIPLE, - Chem.rdchem.BondType.AROMATIC, - ] -} - - -def _one_hot_encode(value, choices): - """Create a one-hot encoding for a value in a list of choices. - - :param value: The value to be one-hot encoded. - :param choices: A list of possible choices for the value. - :return: A list representing the one-hot encoding. - """ - encoding = [0] * (len(choices) + 1) - index = choices.index(value) if value in choices else -1 - encoding[index] = 1 - return encoding - - -def _smiles_to_graph(smiles: str): - """ - Converts a SMILES string to a torch_geometric.data.Data object. - - :param smiles: The SMILES string for the drug. - :return: A Data object representing the molecular graph, or None if conversion fails. - """ - mol = Chem.MolFromSmiles(smiles) - if mol is None: - return None - - # Atom features - atom_features_list = [] - for atom in mol.GetAtoms(): - features = [] - features.extend(_one_hot_encode(atom.GetAtomicNum(), ATOM_FEATURES["atomic_num"])) - features.extend(_one_hot_encode(atom.GetDegree(), ATOM_FEATURES["degree"])) - features.extend(_one_hot_encode(atom.GetFormalCharge(), ATOM_FEATURES["formal_charge"])) - features.extend(_one_hot_encode(atom.GetTotalNumHs(), ATOM_FEATURES["num_hs"])) - features.extend(_one_hot_encode(atom.GetHybridization(), ATOM_FEATURES["hybridization"])) - features.append(atom.GetIsAromatic()) - features.append(atom.IsInRing()) - atom_features_list.append(features) - x = torch.tensor(atom_features_list, dtype=torch.float) - - # Edge index and edge features - edge_indices = [] - edge_features_list = [] - for bond in mol.GetBonds(): - i = bond.GetBeginAtomIdx() - j = bond.GetEndAtomIdx() - - # Edge features - features = [] - features.extend(_one_hot_encode(bond.GetBondType(), BOND_FEATURES["bond_type"])) - features.append(bond.GetIsConjugated()) - features.append(bond.IsInRing()) - - edge_indices.extend([[i, j], [j, i]]) - edge_features_list.extend([features, features]) # Same features for both directions - - edge_index = torch.tensor(edge_indices, dtype=torch.long).t().contiguous() - edge_attr = torch.tensor(edge_features_list, dtype=torch.float) - - return Data(x=x, edge_index=edge_index, edge_attr=edge_attr) - - -def main(): - """Main function to run the preprocessing.""" - parser = argparse.ArgumentParser(description="Preprocess drug SMILES to graphs.") - parser.add_argument("dataset_name", type=str, help="The name of the dataset to process.") - parser.add_argument("--data_path", type=str, default="data", help="Path to the data folder") - args = parser.parse_args() - - dataset_name = args.dataset_name - data_dir = Path(args.data_path).resolve() - smiles_file = data_dir / dataset_name / "drug_smiles.csv" - output_dir = data_dir / dataset_name / "drug_graphs" - - if not smiles_file.exists(): - print(f"Error: {smiles_file} not found.") - return - - os.makedirs(output_dir, exist_ok=True) - - smiles_df = pd.read_csv(smiles_file) - - print(f"Processing {len(smiles_df)} drugs for dataset {dataset_name}...") - - for _, row in tqdm(smiles_df.iterrows(), total=smiles_df.shape[0]): - drug_id = row["pubchem_id"] - smiles = row["canonical_smiles"] - - graph = _smiles_to_graph(smiles) - - if graph: - torch.save(graph, output_dir / f"{drug_id}.pt") - - print(f"Finished processing. Graphs saved to {output_dir}") - - -if __name__ == "__main__": - main() diff --git a/drevalpy/datasets/featurizer/create_molgnet_embeddings.py b/drevalpy/datasets/featurizer/create_molgnet_embeddings.py deleted file mode 100644 index 2d337e803..000000000 --- a/drevalpy/datasets/featurizer/create_molgnet_embeddings.py +++ /dev/null @@ -1,917 +0,0 @@ -#!/usr/bin/env python3 -"""MolGNet feature extraction utilities (needed for DIPK and adapted from the DIPK github). - -Creates MolGNet embeddings for molecules given their SMILES strings. This module needs torch_scatter. - python create_molgnet_embeddings.py dataset_name --checkpoint meta/MolGNet.pt --data_path data -""" - -import argparse -import math -import os -import pickle # noqa: S403 -from pathlib import Path -from typing import Any, Optional - -import numpy as np -import pandas as pd -import torch -import torch.nn.functional as torch_nn_f -from torch import nn -from torch.nn import Parameter -from torch_geometric.data import Data -from torch_geometric.utils import add_self_loops, softmax -from tqdm import tqdm - -try: - from rdkit import Chem - from rdkit.Chem.rdchem import Mol as RDMol -except ImportError: - raise ImportError("Please install rdkit package for MolGNet featurizer: pip install rdkit") - -# building graphs -allowable_features: dict[str, list[Any]] = { - "atomic_num": list(range(1, 122)), - "formal_charge": ["unk", -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5], - "chirality": [ - "unk", - Chem.rdchem.ChiralType.CHI_UNSPECIFIED, - Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CW, - Chem.rdchem.ChiralType.CHI_TETRAHEDRAL_CCW, - Chem.rdchem.ChiralType.CHI_OTHER, - ], - "hybridization": [ - "unk", - Chem.rdchem.HybridizationType.S, - Chem.rdchem.HybridizationType.SP, - Chem.rdchem.HybridizationType.SP2, - Chem.rdchem.HybridizationType.SP3, - Chem.rdchem.HybridizationType.SP3D, - Chem.rdchem.HybridizationType.SP3D2, - Chem.rdchem.HybridizationType.UNSPECIFIED, - ], - "numH": ["unk", 0, 1, 2, 3, 4, 5, 6, 7, 8], - "implicit_valence": ["unk", 0, 1, 2, 3, 4, 5, 6], - "degree": ["unk", 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], - "isaromatic": [False, True], - "bond_type": [ - "unk", - Chem.rdchem.BondType.SINGLE, - Chem.rdchem.BondType.DOUBLE, - Chem.rdchem.BondType.TRIPLE, - Chem.rdchem.BondType.AROMATIC, - ], - "bond_dirs": [ - Chem.rdchem.BondDir.NONE, - Chem.rdchem.BondDir.ENDUPRIGHT, - Chem.rdchem.BondDir.ENDDOWNRIGHT, - ], - "bond_isconjugated": [False, True], - "bond_inring": [False, True], - "bond_stereo": [ - "STEREONONE", - "STEREOANY", - "STEREOZ", - "STEREOE", - "STEREOCIS", - "STEREOTRANS", - ], -} - -atom_dic = [ - len(allowable_features["atomic_num"]), - len(allowable_features["formal_charge"]), - len(allowable_features["chirality"]), - len(allowable_features["hybridization"]), - len(allowable_features["numH"]), - len(allowable_features["implicit_valence"]), - len(allowable_features["degree"]), - len(allowable_features["isaromatic"]), -] -bond_dic = [ - len(allowable_features["bond_type"]), - len(allowable_features["bond_dirs"]), - len(allowable_features["bond_isconjugated"]), - len(allowable_features["bond_inring"]), - len(allowable_features["bond_stereo"]), -] -atom_cumsum = np.cumsum(atom_dic) -bond_cumsum = np.cumsum(bond_dic) - - -def mol_to_graph_data_obj_complex(mol: RDMol) -> Data: - """Convert an RDKit Mol into a torch_geometric ``Data`` object. - - The function encodes a fixed set of atom and bond categorical - features and returns a ``Data`` instance with ``x``, ``edge_index`` - and ``edge_attr`` fields. It mirrors the feature layout expected by - the MolGNet implementation used in this repository. - - :param mol: RDKit ``Mol`` instance. Must not be ``None``. - :return: A ``torch_geometric.data.Data`` object with node and edge fields. - :raises ValueError: If ``mol`` is ``None``. - """ - if mol is None: - raise ValueError("mol must not be None") - atom_features_list: list = [] - # Shortcuts for feature lists - fc_list = allowable_features["formal_charge"] - ch_list = allowable_features["chirality"] - hyb_list = allowable_features["hybridization"] - numh_list = allowable_features["numH"] - imp_list = allowable_features["implicit_valence"] - deg_list = allowable_features["degree"] - isa_list = allowable_features["isaromatic"] - bt_list = allowable_features["bond_type"] - bd_list = allowable_features["bond_dirs"] - bic_list = allowable_features["bond_isconjugated"] - bir_list = allowable_features["bond_inring"] - bs_list = allowable_features["bond_stereo"] - for atom in mol.GetAtoms(): - a_idx = allowable_features["atomic_num"].index(atom.GetAtomicNum()) - fc_idx = fc_list.index(atom.GetFormalCharge()) + atom_cumsum[0] - ch_idx = ch_list.index(atom.GetChiralTag()) + atom_cumsum[1] - hyb_idx = hyb_list.index(atom.GetHybridization()) + atom_cumsum[2] - numh_idx = numh_list.index(atom.GetTotalNumHs()) + atom_cumsum[3] - imp_idx = imp_list.index(atom.GetImplicitValence()) + atom_cumsum[4] - deg_idx = deg_list.index(atom.GetDegree()) + atom_cumsum[5] - isa_idx = isa_list.index(atom.GetIsAromatic()) + atom_cumsum[6] - - atom_feature = [ - a_idx, - fc_idx, - ch_idx, - hyb_idx, - numh_idx, - imp_idx, - deg_idx, - isa_idx, - ] - atom_features_list.append(atom_feature) - x = torch.tensor(np.array(atom_features_list), dtype=torch.long) - - # bonds - num_bond_features = 5 - if len(mol.GetBonds()) > 0: - edges_list = [] - edge_features_list = [] - for bond in mol.GetBonds(): - i = bond.GetBeginAtomIdx() - j = bond.GetEndAtomIdx() - bt = bt_list.index(bond.GetBondType()) - bd = bd_list.index(bond.GetBondDir()) + bond_cumsum[0] - bic = bic_list.index(bond.GetIsConjugated()) + bond_cumsum[1] - bir = bir_list.index(bond.IsInRing()) + bond_cumsum[2] - bs = bs_list.index(str(bond.GetStereo())) + bond_cumsum[3] - - edge_feature = [bt, bd, bic, bir, bs] - edges_list.append((i, j)) - edge_features_list.append(edge_feature) - edges_list.append((j, i)) - edge_features_list.append(edge_feature) - edge_index = torch.tensor(np.array(edges_list).T, dtype=torch.long) - edge_attr = torch.tensor(np.array(edge_features_list), dtype=torch.long) - else: - edge_index = torch.empty((2, 0), dtype=torch.long) - edge_attr = torch.empty((0, num_bond_features), dtype=torch.long) - - data = Data(x=x, edge_index=edge_index, edge_attr=edge_attr) - return data - - -class SelfLoop: - """Callable that appends self-loops and matching edge attributes. - - This helper mutates the provided ``Data`` object by adding self-loop - entries to ``edge_index`` and a corresponding edge attribute row for - every node. - """ - - def __call__(self, data: Data) -> Data: - """Modify ``data`` in-place by adding self-loop indices and corresponding edge attributes. - - :param data: ``torch_geometric.data.Data`` to modify. - :return: The modified ``Data`` object (same instance). - """ - num_nodes = data.num_nodes - data.edge_index, _ = add_self_loops(data.edge_index, num_nodes=num_nodes) - self_loop_attr = torch.LongTensor([0, 5, 8, 10, 12]).repeat(num_nodes, 1) - data.edge_attr = torch.cat((data.edge_attr, self_loop_attr), dim=0) - return data - - -class AddSegId: - """Attach zero-valued segment id tensors to nodes and edges. - - The created ``node_seg`` and ``edge_seg`` tensors are added to the - provided ``Data`` instance and used by the MolGNet embedding layers. - """ - - def __init__(self) -> None: - """Create an AddSegId callable (no parameters).""" - pass - - def __call__(self, data: Data) -> Data: - """Attach zero-filled ``node_seg`` and ``edge_seg`` tensors to ``data``. - - :param data: ``torch_geometric.data.Data`` to modify. - :return: The modified ``Data`` object (same instance). - """ - num_nodes = data.num_nodes - num_edges = data.num_edges - node_seg = [0 for _ in range(num_nodes)] - edge_seg = [0 for _ in range(num_edges)] - data.edge_seg = torch.LongTensor(edge_seg) - data.node_seg = torch.LongTensor(node_seg) - return data - - -# MolGNet model - - -class BertLayerNorm(nn.Module): - """Layer normalization compatible with BERT-style implementations. - - :param hidden_size: Dimension of the last axis to normalize. - :param eps: Small epsilon for numerical stability. - """ - - def __init__(self, hidden_size, eps=1e-12): - """Create a BertLayerNorm module. - - :param hidden_size: Dimension of the last axis to normalize. - :param eps: Small epsilon for numerical stability. - """ - super().__init__() - self.shape = torch.Size((hidden_size,)) - self.eps = eps - self.weight = nn.Parameter(torch.ones(hidden_size)) - self.bias = nn.Parameter(torch.zeros(hidden_size)) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - """Apply layer normalization to the last dimension of ``x``. - - :param x: Input tensor. - :return: Normalized tensor with same shape as ``x``. - """ - u = x.mean(-1, keepdim=True) - s = (x - u).pow(2).mean(-1, keepdim=True) - x = (x - u) / torch.sqrt(s + self.eps) - x = self.weight * x + self.bias - return x - - -def gelu(x: torch.Tensor) -> torch.Tensor: - """Gaussian Error Linear Unit activation (approximation). - - :param x: Input tensor. - :return: Activated tensor. - """ - return x * 0.5 * (1.0 + torch.erf(x / math.sqrt(2))) - - -def bias_gelu(bias: torch.Tensor, y: torch.Tensor) -> torch.Tensor: - """Apply GELU to ``bias + y``. - - :param bias: Bias tensor to add. - :param y: Linear output tensor. - :return: GELU applied to ``bias + y``. - """ - x = bias + y - return x * 0.5 * (1.0 + torch.erf(x / math.sqrt(2))) - - -class LinearActivation(nn.Module): - """Linear layer with optional bias-aware GELU activation. - - :param in_features: Input feature dimension. - :param out_features: Output feature dimension. - :param bias: Whether to use a bias parameter and the biased GELU. - """ - - def __init__(self, in_features: int, out_features: int, bias: bool = True) -> None: - """ - Create a LinearActivation module. - - :param in_features: Input feature dimension. - :param out_features: Output feature dimension. - :param bias: Whether to use a bias parameter and the biased GELU. - """ - super().__init__() - self.in_features = in_features - self.out_features = out_features - if bias: - self.biased_act_fn = bias_gelu - else: - self.act_fn = gelu - self.weight = Parameter(torch.Tensor(out_features, in_features)) - if bias: - self.bias = Parameter(torch.Tensor(out_features)) - else: - self.register_parameter("bias", None) - self.reset_parameters() - - def reset_parameters(self) -> None: - """Initialize the layer parameters.""" - nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5)) - if self.bias is not None: - fan_in, _ = nn.init._calculate_fan_in_and_fan_out(self.weight) - bound = 1 / math.sqrt(fan_in) - nn.init.uniform_(self.bias, -bound, bound) - - def forward(self, input: torch.Tensor) -> torch.Tensor: - """Apply the linear transformation and activation. - - :param input: Input tensor of shape [N, in_features]. - :return: Transformed tensor of shape [N, out_features]. - """ - if self.bias is not None: - linear_out = torch_nn_f.linear(input, self.weight, None) - return self.biased_act_fn(self.bias, linear_out) - else: - return self.act_fn(torch_nn_f.linear(input, self.weight, self.bias)) - - -class Intermediate(nn.Module): - """Intermediate feed-forward block used inside GT layers. - - :param hidden: Hidden dimension size. - """ - - def __init__(self, hidden: int) -> None: - """Create the intermediate dense activation block. - - :param hidden: Hidden dimension size. - """ - super().__init__() - self.dense_act = LinearActivation(hidden, 4 * hidden) - - def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: - """Apply the dense activation to the hidden states. - - :param hidden_states: Input tensor of shape [N, hidden]. - :return: Transformed tensor of shape [N, 4*hidden]. - """ - hidden_states = self.dense_act(hidden_states) - return hidden_states - - -class AttentionOut(nn.Module): - """Post-attention output block: projection, dropout and residual norm. - - :param hidden: Hidden dimension used for the linear projection. - :param dropout: Dropout probability. - """ - - def __init__(self, hidden: int, dropout: float) -> None: - """Create an AttentionOut block. - - :param hidden: Hidden dimension used for projection. - :param dropout: Dropout probability. - """ - super().__init__() - self.dense = nn.Linear(hidden, hidden) - self.LayerNorm = BertLayerNorm(hidden, eps=1e-12) - self.dropout = nn.Dropout(dropout) - - def forward(self, hidden_states: torch.Tensor, input_tensor: torch.Tensor) -> torch.Tensor: - """Project attention outputs and apply layer norm with residual. - - :param hidden_states: Attention output tensor. - :param input_tensor: Residual tensor to add before normalization. - :return: Normalized tensor with the same shape as ``input_tensor``. - """ - hidden_states = self.dense(hidden_states) - hidden_states = self.dropout(hidden_states) - hidden_states = self.LayerNorm(hidden_states + input_tensor) - return hidden_states - - -class GTOut(nn.Module): - """Output projection used in GT blocks. - - :param hidden: Hidden dimension. - :param dropout: Dropout probability. - """ - - def __init__(self, hidden: int, dropout: float) -> None: - """Create a GTOut projection block. - - :param hidden: Hidden dimension. - :param dropout: Dropout probability. - """ - super().__init__() - self.dense = nn.Linear(hidden * 4, hidden) - self.LayerNorm = BertLayerNorm(hidden, eps=1e-12) - self.dropout = nn.Dropout(dropout) - - def forward(self, hidden_states: torch.Tensor, input_tensor: torch.Tensor) -> torch.Tensor: - """Project intermediate states back to hidden dimension and normalize. - - :param hidden_states: Intermediate tensor of shape [N, 4*hidden]. - :param input_tensor: Residual tensor to add. - :return: Tensor of shape [N, hidden]. - """ - hidden_states = self.dense(hidden_states) - hidden_states = self.dropout(hidden_states) - hidden_states = self.LayerNorm(hidden_states + input_tensor) - return hidden_states - - -class MessagePassing(nn.Module): - """Minimal MessagePassing base class used by the MolGNet layers. - - This class provides a lightweight implementation of propagate/ - message/aggregate/update used in graph convolutions. - - :param aggr: Aggregation method (e.g., 'add', 'mean'). - :param flow: Message flow direction. - :param node_dim: Node dimension index (unused in this minimal impl). - """ - - def __init__(self, aggr: str = "add", flow: str = "source_to_target", node_dim: int = 0) -> None: - """Create a MessagePassing helper. - - :param aggr: Aggregation method (e.g., 'add' or 'mean'). - :param flow: Message flow direction. - :param node_dim: Node dimension index. - """ - super().__init__() - self.aggr = aggr - self.flow = flow - self.node_dim = node_dim - - def propagate(self, edge_index: torch.Tensor, size: Optional[tuple[int, int]] = None, **kwargs) -> torch.Tensor: - """Run full message-passing: message -> aggregate -> update. - - :param edge_index: Edge indices tensor of shape [2, E]. - :param size: Optional pair describing (num_nodes_source, num_nodes_target). - :param kwargs: Additional data (e.g., node features) needed for message computation. - :raises ValueError: If required inputs (e.g., 'x') are missing or indexing fails. - :return: Updated node tensor after aggregation. - """ - i = 1 if self.flow == "source_to_target" else 0 - j = 0 if i == 1 else 1 - x = kwargs.get("x") - if x is None: - raise ValueError("propagate requires node features passed as keyword 'x'") - try: - x_i = x[edge_index[i]] - x_j = x[edge_index[j]] - except Exception as exc: # defensive - raise ValueError("failed to index node features with edge_index") from exc - msg = self.message( - edge_index_i=edge_index[i], - edge_index_j=edge_index[j], - x_i=x_i, - x_j=x_j, - **kwargs, - ) - # determine number of destination nodes for aggregation - if hasattr(x, "size"): - dim_size = x.size(0) - else: - dim_size = len(x) - out = self.aggregate(msg, index=edge_index[i], dim_size=dim_size) - out = self.update(out) - return out - - def message(self, *args: Any, **kwargs: Any) -> torch.Tensor: - """Default message function returning neighbor features. - - Subclasses may provide richer signatures; this generic form allows - subclass overrides while keeping the base class typed. - - :param args: Positional arguments forwarded by propagate. - :param kwargs: Keyword arguments forwarded by propagate. - :raises ValueError: If required node features are not present. - :return: Message tensor. - """ - x_j = kwargs.get("x_j") if "x_j" in kwargs else (args[1] if len(args) > 1 else None) - if x_j is None: - raise ValueError("message requires node features 'x_j'") - return x_j - - def aggregate(self, inputs: torch.Tensor, index: torch.Tensor, dim_size: Optional[int] = None) -> torch.Tensor: - """Aggregate messages using ``torch_scatter.scatter``. - - :param inputs: Message tensor of shape [E, hidden]. - :param index: Indices to aggregate into nodes. - :param dim_size: Optional target size for the aggregation dimension. - :return: Aggregated node tensor. - """ - from torch_scatter import scatter # local dependency - - return scatter( - inputs, - index, - dim=0, - dim_size=dim_size, - reduce=self.aggr, - ) - - def update(self, inputs: torch.Tensor) -> torch.Tensor: - """Identity update by default. - - Override to apply post-aggregation transformations. - - :param inputs: Aggregated node tensor. - :return: Updated tensor. - """ - return inputs - - -class GraphAttentionConv(MessagePassing): - """Graph attention convolution used by MolGNet. - - :param hidden: Hidden feature dimension. - :param heads: Number of attention heads. - :param dropout: Attention dropout probability. - """ - - def __init__(self, hidden: int, heads: int = 3, dropout: float = 0.0) -> None: - """Create a GraphAttentionConv. - - :param hidden: Hidden feature dimension. - :param heads: Number of attention heads. - :param dropout: Dropout probability. - :raises ValueError: If hidden is not divisible by heads. - """ - super().__init__() - self.hidden = hidden - self.heads = heads - if hidden % heads != 0: - raise ValueError("hidden must be divisible by heads") - self.query = nn.Linear(hidden, heads * int(hidden / heads)) - self.key = nn.Linear(hidden, heads * int(hidden / heads)) - self.value = nn.Linear(hidden, heads * int(hidden / heads)) - self.attn_drop = nn.Dropout(dropout) - - def forward( - self, - x: torch.Tensor, - edge_index: torch.Tensor, - edge_attr: torch.Tensor, - size: Optional[tuple[int, int]] = None, - ) -> torch.Tensor: - """Execute the graph attention conv over the provided inputs. - - :param x: Node feature tensor. - :param edge_index: Edge indices tensor. - :param edge_attr: Edge attribute tensor. - :param size: Optional size tuple. - :return: Updated node tensor after attention. - """ - pseudo = edge_attr.unsqueeze(-1) if edge_attr.dim() == 1 else edge_attr - return self.propagate(edge_index=edge_index, x=x, pseudo=pseudo) - - def message( - self, - edge_index_i: torch.Tensor, - x_i: torch.Tensor, - x_j: torch.Tensor, - pseudo: torch.Tensor, - size_i: Optional[int] = None, - **kwargs, - ) -> torch.Tensor: - """Compute messages using multi-head attention between nodes. - - :param edge_index_i: Source indices for edges. - :param x_i: Node features for source nodes. - :param x_j: Node features for target nodes. - :param pseudo: Edge pseudo-features (edge attributes). - :param size_i: Optional number of destination nodes. - :param kwargs: Additional keyword arguments (ignored). - :return: Message tensor shaped for aggregation. - """ - query = self.query(x_i).view( - -1, - self.heads, - int(self.hidden / self.heads), - ) - key = self.key(x_j + pseudo).view( - -1, - self.heads, - int(self.hidden / self.heads), - ) - value = self.value(x_j + pseudo).view( - -1, - self.heads, - int(self.hidden / self.heads), - ) - denom = math.sqrt(int(self.hidden / self.heads)) - alpha = (query * key).sum(dim=-1) / denom - alpha = softmax(src=alpha, index=edge_index_i, num_nodes=size_i) - alpha = self.attn_drop(alpha.view(-1, self.heads, 1)) - return alpha * value - - def update(self, aggr_out: torch.Tensor) -> torch.Tensor: - """Reshape aggregated outputs from multi-head to flat hidden dim. - - :param aggr_out: Aggregated output tensor of shape [N*heads, head_dim]. - :return: Reshaped tensor of shape [N, hidden]. - """ - aggr_out = aggr_out.view(-1, self.heads * int(self.hidden / self.heads)) - return aggr_out - - -class GTLayer(nn.Module): - """Graph Transformer layer composed from attention and feed-forward blocks. - - :param hidden: Hidden dimension size. - :param heads: Number of attention heads. - :param dropout: Dropout probability. - :param num_message_passing: Number of internal message passing steps. - """ - - def __init__(self, hidden: int, heads: int, dropout: float, num_message_passing: int) -> None: - """Create a GTLayer composed of attention and feed-forward blocks. - - :param hidden: Hidden dimension size. - :param heads: Number of attention heads. - :param dropout: Dropout probability. - :param num_message_passing: Number of internal message passing steps. - """ - super().__init__() - self.attention = GraphAttentionConv(hidden, heads, dropout) - self.att_out = AttentionOut(hidden, dropout) - self.intermediate = Intermediate(hidden) - self.output = GTOut(hidden, dropout) - self.gru = nn.GRU(hidden, hidden) - self.LayerNorm = BertLayerNorm(hidden, eps=1e-12) - self.time_step = num_message_passing - - def forward(self, x: torch.Tensor, edge_index: torch.Tensor, edge_attr: torch.Tensor) -> torch.Tensor: - """Run the GT layer for the configured number of message-passing steps. - - :param x: Node feature tensor of shape [N, hidden]. - :param edge_index: Edge index tensor. - :param edge_attr: Edge attribute tensor. - :return: Updated node tensor of shape [N, hidden]. - """ - h = x.unsqueeze(0) - for _ in range(self.time_step): - attention_output = self.attention.forward(x, edge_index, edge_attr) - attention_output = self.att_out.forward(attention_output, x) - intermediate_output = self.intermediate.forward(attention_output) - m = self.output.forward(intermediate_output, attention_output) - x, h = self.gru(m.unsqueeze(0), h) - x = self.LayerNorm.forward(x.squeeze(0)) - return x - - -class MolGNet(torch.nn.Module): - """MolGNet model implementation used for node embeddings. - - This implementation is intentionally minimal and only includes the - components required to run a checkpoint and produce per-node - embeddings saved by the featurizer script. - - :param num_layer: Number of GT layers. - :param emb_dim: Embedding dimensionality per node. - :param heads: Number of attention heads. - :param num_message_passing: Message passing steps per layer. - :param drop_ratio: Dropout probability. - """ - - def __init__( - self, - num_layer: int, - emb_dim: int, - heads: int, - num_message_passing: int, - drop_ratio: float = 0, - ) -> None: - """Create a MolGNet instance. - - :param num_layer: Number of GT layers. - :param emb_dim: Embedding dimensionality per node. - :param heads: Number of attention heads. - :param num_message_passing: Message passing steps per layer. - :param drop_ratio: Dropout probability. - """ - super().__init__() - self.num_layer = num_layer - self.drop_ratio = drop_ratio - self.x_embedding = torch.nn.Embedding(178, emb_dim) - self.x_seg_embed = torch.nn.Embedding(3, emb_dim) - self.edge_embedding = torch.nn.Embedding(18, emb_dim) - self.edge_seg_embed = torch.nn.Embedding(3, emb_dim) - self.reset_parameters() - self.gnns = torch.nn.ModuleList( - [GTLayer(emb_dim, heads, drop_ratio, num_message_passing) for _ in range(num_layer)] - ) - - def reset_parameters(self) -> None: - """Re-initialize embedding parameters with Xavier uniform. - - This mirrors common initialization used for transformer-style - embeddings. - """ - torch.nn.init.xavier_uniform_(self.x_embedding.weight.data) - torch.nn.init.xavier_uniform_(self.x_seg_embed.weight.data) - torch.nn.init.xavier_uniform_(self.edge_embedding.weight.data) - torch.nn.init.xavier_uniform_(self.edge_seg_embed.weight.data) - - def forward(self, *argv: Any) -> torch.Tensor: - """Forward pass supporting two calling conventions. - - Accepts either explicit tensors (x, edge_index, edge_attr, node_seg, - edge_seg) or a single ``Data`` object containing those attributes. - - :param argv: Positional arguments as described above. - :raises ValueError: If an unsupported number of arguments is provided. - :return: Node embeddings tensor of shape [N, emb_dim]. - """ - if len(argv) == 5: - x, edge_index, edge_attr, node_seg, edge_seg = (argv[0], argv[1], argv[2], argv[3], argv[4]) - elif len(argv) == 1: - data = argv[0] - x, edge_index, edge_attr, node_seg, edge_seg = ( - data.x, - data.edge_index, - data.edge_attr, - data.node_seg, - data.edge_seg, - ) - else: - raise ValueError("unmatched number of arguments.") - x = self.x_embedding(x).sum(1) + self.x_seg_embed(node_seg) - edge_attr = self.edge_embedding(edge_attr).sum(1) - edge_attr = edge_attr + self.edge_seg_embed(edge_seg) - for gnn in self.gnns: - x = gnn(x, edge_index, edge_attr) - return x - - -def tensor_to_csv_friendly(tensor: Any) -> np.ndarray: - """Convert a tensor-like object into a NumPy array safe for CSV output. - - :param tensor: Input tensor or array-like object. - :return: NumPy array on CPU. - """ - if isinstance(tensor, torch.Tensor): - return tensor.cpu().detach().numpy() - return np.array(tensor) - - -def run(args: argparse.Namespace) -> None: - """Execute the featurization pipeline for a given dataset. - - The function builds graphs from SMILES, runs the MolGNet checkpoint - to extract node embeddings, and writes per-drug CSVs and pickles in - the dataset folder. - - :param args: Parsed CLI arguments. - :raises FileNotFoundError: If expected files or directories are missing. - :raises ValueError: If expected columns are missing in the input CSV. - :raises Exception: For various failures during graph building or inference. - """ - # Use dataset-oriented paths: {data_path}/{dataset_name}/... - # Expand user (~) and resolve to an absolute path. - data_dir = Path(args.data_path).expanduser().resolve() - dataset_dir = data_dir / args.dataset_name - if not dataset_dir.exists(): - raise FileNotFoundError(f"Dataset directory not found: {dataset_dir}") - - out_graphs = str(dataset_dir / "GRAPH_dict.pkl") - out_molg = str(dataset_dir / "MolGNet_dict.pkl") - - # read input csv (expected at {data_path}/{dataset_name}/drug_smiles.csv) - smiles_csv = dataset_dir / "drug_smiles.csv" - if not smiles_csv.exists(): - raise FileNotFoundError(f"Expected SMILES CSV at: {smiles_csv}") - df = pd.read_csv(smiles_csv) - if args.smiles_col not in df.columns or args.id_col not in df.columns: - msg = f"Provided columns not in CSV: {args.smiles_col}, " f"{args.id_col}" - raise ValueError(msg) - df = df.dropna(subset=[args.smiles_col]) - smiles_map = dict(zip(df[args.id_col], df[args.smiles_col])) - - # Build graphs - graph_dict: dict[Any, Data] = {} - failed_conversions = [] - for idx, smi in tqdm(smiles_map.items(), desc="building graphs"): - mol = Chem.MolFromSmiles(smi) - if mol is None: - failed_conversions.append((idx, smi, "MolFromSmiles returned None")) - continue - try: - graph_dict[idx] = mol_to_graph_data_obj_complex(mol) - except Exception as e: - failed_conversions.append((idx, smi, str(e))) - if failed_conversions: - print(f"\n{len(failed_conversions)} molecules failed to convert to graphs.") - for idx, smi, err in failed_conversions: - print(f"Failed to convert {idx} (SMILES: {smi}): {err}") - else: - print("\nAll molecules converted to graphs successfully.") - # save graphs to dataset folder - with open(out_graphs, "wb") as f: - pickle.dump(graph_dict, f) - # load model - if args.device: - device = torch.device(args.device) - else: - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - - num_layer = 5 - emb_dim = 768 - heads = 12 - msg_pass = 3 - drop = 0.0 - model = MolGNet( - num_layer=num_layer, - emb_dim=emb_dim, - heads=heads, - num_message_passing=msg_pass, - drop_ratio=drop, - ) - # Prefer pathlib operations when working with Path objects - checkpoint_path = data_dir / args.checkpoint - ckpt = torch.load(checkpoint_path, map_location=device) # noqa S614 - try: - model.load_state_dict(ckpt) - except Exception: - if isinstance(ckpt, dict) and "state_dict" in ckpt: - model.load_state_dict(ckpt["state_dict"]) - else: - raise - model = model.to(device) - model.eval() - - self_loop = SelfLoop() - add_seg = AddSegId() - - molgnet_dict: dict[Any, torch.Tensor] = {} - with torch.no_grad(): - for idx, graph in tqdm(graph_dict.items(), desc="running model"): - try: - g = self_loop(graph) - g = add_seg(g) - g = g.to(device) - emb = model(g) - molgnet_dict[idx] = emb.cpu() - except Exception as e: - print(f"Inference failed for {idx}: {e}") - - with open(out_molg, "wb") as f: - pickle.dump(molgnet_dict, f) - - # write per-drug CSVs to {dataset_dir}/DIPK_features/Drugs - out_drugs_dir = dataset_dir / "DIPK_features/Drugs" - os.makedirs(out_drugs_dir, exist_ok=True) - for idx, emb in tqdm(molgnet_dict.items(), desc="writing csvs"): - arr = tensor_to_csv_friendly(emb) - df_emb = pd.DataFrame(arr) - out_path = out_drugs_dir / f"MolGNet_{idx}.csv" - df_emb.to_csv(out_path, sep="\t", index=False) - - print("Done.") - print("Graphs saved to:", out_graphs) - print("Node embeddings saved to:", out_molg) - print("Per-drug CSVs in:", out_drugs_dir) - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments. - - :return: Parsed arguments namespace. - """ - p = argparse.ArgumentParser(description=("Standalone MolGNet extractor " "(dataset-oriented)")) - p.add_argument( - "dataset_name", - help="Name of the dataset (folder under data_path)", - ) - p.add_argument( - "--data_path", - default="data", - help="Top-level data folder path", - ) - p.add_argument( - "--smiles-col", - dest="smiles_col", - default="canonical_smiles", - help="Column name for SMILES in input CSV", - ) - p.add_argument( - "--id-col", - dest="id_col", - default="pubchem_id", - help="Column name for unique ID in input CSV", - ) - p.add_argument( - "--checkpoint", - default="MolGNet.pt", - help="MolGNet checkpoint (state_dict), can be obtained from Zenodo: https://doi.org/10.5281/zenodo.12633909", - ) - p.add_argument( - "--device", - default=None, - help="torch device string, e.g. cpu or cuda:0", - ) - return p.parse_args() - - -if __name__ == "__main__": - args = parse_args() - run(args) diff --git a/drevalpy/datasets/featurizer/create_pharmaformer_drug_embeddings.py b/drevalpy/datasets/featurizer/create_pharmaformer_drug_embeddings.py deleted file mode 100644 index 3100affa9..000000000 --- a/drevalpy/datasets/featurizer/create_pharmaformer_drug_embeddings.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Preprocesses drug SMILES strings into BPE-encoded embeddings. - -WARNING: This featurizer produces problematic embeddings and should ONLY be used -with the PharmaFormer model. It replicates the original PharmaFormer implementation -for compatibility, but the embeddings have known issues and should not be used -for any other models. - -Details about the issues are explained in: -https://github.com/daisybio/drevalpy/pull/336#discussion_r2682718948 -""" - -import argparse -import codecs -import os -import tempfile -from pathlib import Path - -import numpy as np -import pandas as pd -from tqdm import tqdm - -try: - from subword_nmt.apply_bpe import BPE - from subword_nmt.learn_bpe import learn_bpe -except ImportError: - raise ImportError("Please install subword-nmt package for BPE SMILES featurizer: pip install subword-nmt") - - -def create_pharmaformer_drug_embeddings( - data_path: str, - dataset_name: str, - num_symbols: int = 10000, - max_length: int = 128, -) -> None: - """ - Create BPE-encoded SMILES embeddings for drugs. - - WARNING: This featurizer produces problematic embeddings and should ONLY be used - with the PharmaFormer model. It replicates the original PharmaFormer implementation - for compatibility purposes, but the embeddings have known issues and should NOT - be used for any other models. - - Details about the issues are explained in: - https://github.com/daisybio/drevalpy/pull/336#discussion_r2682718948 - - Process: - 1. Read drug_smiles.csv - 2. Learn BPE codes from all SMILES strings - 3. Apply BPE to each SMILES - 4. Convert to character ordinals - 5. Pad/truncate to max_length - 6. Save to drug_bpe_smiles.csv - - :param data_path: Path to the data folder - :param dataset_name: Name of the dataset to process - :param num_symbols: Number of BPE symbols to learn - :param max_length: Maximum length of encoded SMILES (padding/truncation) - :raises FileNotFoundError: If drug_smiles.csv is not found - :raises Exception: If a drug fails to process - """ - data_dir = Path(data_path).resolve() - dataset_dir = data_dir / dataset_name - - smiles_file = dataset_dir / "drug_smiles.csv" - bpe_codes_path = dataset_dir / "bpe.codes" - output_file = dataset_dir / "drug_bpe_smiles.csv" - - if not smiles_file.exists(): - raise FileNotFoundError(f"Error: {smiles_file} not found.") - - # Read SMILES data - smiles_df = pd.read_csv(smiles_file, dtype={"canonical_smiles": str, "pubchem_id": str}) - smiles_df = smiles_df.dropna(subset=["canonical_smiles"]) - - print(f"Learning BPE codes from {len(smiles_df)} SMILES strings...") - - # Create temporary file with SMILES strings for BPE learning - # learn_bpe expects one item per line - with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", delete=False, suffix=".txt") as tmp_file: - tmp_smiles_file = tmp_file.name - for smiles in smiles_df["canonical_smiles"]: - tmp_file.write(f"{smiles}\n") - - # Learn BPE codes from SMILES corpus - try: - with codecs.open(tmp_smiles_file, encoding="utf-8") as f_in: - with codecs.open(str(bpe_codes_path), "w", encoding="utf-8") as f_out: - learn_bpe(f_in, f_out, num_symbols=num_symbols) - finally: - # Clean up temporary file - if os.path.exists(tmp_smiles_file): - os.remove(tmp_smiles_file) - - print(f"BPE codes saved to {bpe_codes_path}") - - # Load BPE encoder - with codecs.open(str(bpe_codes_path), encoding="utf-8") as f_in: - bpe = BPE(f_in) - - # Encode each SMILES string - embeddings_list = [] - drug_ids = [] - - print(f"Encoding {len(smiles_df)} SMILES strings...") - - for row in tqdm(smiles_df.itertuples(index=False), total=len(smiles_df)): - drug_id = row.pubchem_id - smiles = row.canonical_smiles - - try: - # Apply BPE - bpe_processed = bpe.process_line(smiles) - # Convert to character ordinals - encoded = [ord(char) for char in bpe_processed] - # Pad/truncate to max_length - if len(encoded) > max_length: - encoded = encoded[:max_length] - else: - encoded = np.pad(encoded, (0, max_length - len(encoded)), "constant").tolist() - - embeddings_list.append(encoded) - drug_ids.append(drug_id) - except Exception as e: - print(f"\nFailed to process drug {drug_id} with SMILES: {smiles}") - print(f"Error: {e}") - raise e - - # Create DataFrame with pubchem_id and encoded features - embeddings_df = pd.DataFrame(embeddings_list) - embeddings_df.columns = [f"feature_{i}" for i in range(max_length)] - embeddings_df.insert(0, "pubchem_id", drug_ids) - embeddings_df.to_csv(output_file, index=False) - - print(f"Finished processing. BPE-encoded SMILES saved to {output_file}") - - -def main(): - """Process drug SMILES and save BPE-encoded embeddings. - - WARNING: This featurizer produces problematic embeddings and should ONLY be used - with the PharmaFormer model. It replicates the original PharmaFormer implementation - for compatibility purposes, but the embeddings have known issues and should NOT - be used for any other models. - - Details about the issues are explained in: - https://github.com/daisybio/drevalpy/pull/336#discussion_r2682718948 - """ - parser = argparse.ArgumentParser(description="Preprocess drug SMILES to BPE-encoded embeddings.") - parser.add_argument("dataset_name", type=str, help="The name of the dataset to process.") - parser.add_argument("--data_path", type=str, default="data", help="Path to the data folder") - parser.add_argument("--num-symbols", type=int, default=10000, help="Number of BPE symbols to learn") - parser.add_argument("--max-length", type=int, default=128, help="Maximum length of encoded SMILES") - args = parser.parse_args() - - create_pharmaformer_drug_embeddings( - data_path=args.data_path, - dataset_name=args.dataset_name, - num_symbols=args.num_symbols, - max_length=args.max_length, - ) - - -if __name__ == "__main__": - main() diff --git a/drevalpy/datasets/featurizer/create_precily_drug_embeddings.py b/drevalpy/datasets/featurizer/create_precily_drug_embeddings.py deleted file mode 100644 index e28b489cf..000000000 --- a/drevalpy/datasets/featurizer/create_precily_drug_embeddings.py +++ /dev/null @@ -1,104 +0,0 @@ -r""" -Drug featurizer for the Precily model using SMILESVec embeddings. - -Reads the SMILES that DrEvalPy already ships for a dataset and writes a CSV -of drug features keyed by pubchem_id, in the format Precily's -load_drug_features expects. -""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -import numpy as np -import pandas as pd -from gensim.models import KeyedVectors - - -def _load_smiles(data_path: str, dataset_name: str) -> pd.DataFrame: - """ - Load drug SMILES from DrEvalPy's expected file structure. - - :param data_path: Root directory containing dataset subfolders. - :param dataset_name: Name of the dataset (subfolder). - :return: DataFrame with columns 'pubchem_id' and 'canonical_smiles'. - :raises FileNotFoundError: If the SMILES file does not exist. - :raises ValueError: If required columns are missing. - """ - smiles_file = Path(data_path) / dataset_name / "drug_smiles.csv" - if not smiles_file.exists(): - raise FileNotFoundError(f"SMILES file not found: {smiles_file}") - df = pd.read_csv(smiles_file, dtype=str) - required_cols = {"pubchem_id", "canonical_smiles"} - if not required_cols.issubset(df.columns): - raise ValueError(f"Expected columns {required_cols} in {smiles_file}, got {df.columns.tolist()}") - return df - - -def _smilesvec(smiles: str, kv: KeyedVectors, k: int = 8, dim: int = 100) -> np.ndarray: - """ - Convert a SMILES string to a vector using SMILESVec (word2vec on substrings). - - :param smiles: Input SMILES string. - :param kv: Gensim KeyedVectors model. - :param k: Length of substrings (chemical words). - :param dim: Dimensionality of the vectors. - :return: Mean vector of found substrings, or zero vector if none found. - """ - if len(smiles) < k: - words = [smiles] - else: - words = [smiles[i : i + k] for i in range(len(smiles) - k + 1)] # noqa: E203 - - vecs = [kv[w] for w in words if w in kv.key_to_index] - if not vecs: - return np.zeros(dim, dtype=np.float32) - return np.mean(vecs, axis=0).astype(np.float32) - - -def main() -> None: - """ - Command-line entry point: generate drug features using SMILESVec. - - Reads drug SMILES from a dataset, loads a pre‑trained SMILESVec model, - computes substring embeddings, and writes a CSV file - 'precily_drug_features.csv' inside the dataset folder. - """ - parser = argparse.ArgumentParser() - parser.add_argument("dataset_name") - parser.add_argument("--data_path", default="data") - parser.add_argument("--smilesvec_model", required=True, help="path to pretrained SMILESVec word2vec model") - parser.add_argument("--k", type=int, default=8, help="length of substring (chemical word)") - args = parser.parse_args() - - # Load SMILES - smiles_df = _load_smiles(args.data_path, args.dataset_name) - - # Load SMILESVec model - kv = KeyedVectors.load_word2vec_format(args.smilesvec_model, binary=False) - - # Generate features - rows = [] - n_oov = 0 - for _, row in smiles_df.iterrows(): - vec = _smilesvec(row["canonical_smiles"], kv, k=args.k, dim=100) - if not np.any(vec): - n_oov += 1 - rows.append([row["pubchem_id"], *vec.tolist()]) - - n_features = 100 - columns = ["pubchem_id"] + [f"smv_{i}" for i in range(n_features)] - out_df = pd.DataFrame(rows, columns=columns) - - # Write output - out_path = Path(args.data_path) / args.dataset_name / "drug_smilesvec.csv" - out_path.parent.mkdir(parents=True, exist_ok=True) - out_df.to_csv(out_path, index=False) - print(f"Wrote {len(out_df)} drugs x {n_features} features -> {out_path}") - if n_oov: - print(f"WARNING: {n_oov} drugs produced all-zero (unparsable/OOV) vectors.") - - -if __name__ == "__main__": - main() diff --git a/drevalpy/datasets/featurizer/create_precily_pathway_features.py b/drevalpy/datasets/featurizer/create_precily_pathway_features.py deleted file mode 100644 index 4fdbcbb2c..000000000 --- a/drevalpy/datasets/featurizer/create_precily_pathway_features.py +++ /dev/null @@ -1,175 +0,0 @@ -r""" -GSVA pathway-score featurizer for Precily. - -Computes GSVA pathway-activity scores for cell lines of a dataset in a -single pass and writes them to a CSV - -Input : data//gene_expression.csv (cell lines x genes) -Output: data//precily_pathways.csv (cell lines x pathways) - - python -m drevalpy.datasets.featurizer.create_precily_pathway_features GDSC2 \\ - --gene_sets data/msigdb/c2.cp.v6.1.symbols.gmt -""" - -from __future__ import annotations - -import argparse -import os - -import numpy as np -import pandas as pd - - -def _load_gene_expression(data_path: str, dataset_name: str) -> pd.DataFrame: - """ - Load the dataset's gene-expression matrix. - - :param data_path: root data path - :param dataset_name: dataset name - :return: DataFrame, cell lines in rows, genes in columns - :raises FileNotFoundError: if the expression file is not found - """ - expr_file = os.path.join(data_path, dataset_name, "gene_expression.csv") - if not os.path.exists(expr_file): - raise FileNotFoundError(f"{expr_file} not found.") - df = pd.read_csv(expr_file, index_col=0) - df = df.select_dtypes(include="number") - return df - - -def _run_gsva( - expr_genes_by_samples: pd.DataFrame, - gene_sets: str, - min_size: int, - max_size: int, - kcdf: str, - mx_diff: bool, - threads: int, - seed: int, -) -> pd.DataFrame: - """ - Run gseapy GSVA and return a [samples x pathways] DataFrame. - - :param expr_genes_by_samples: genes in rows, samples in columns - :param gene_sets: path to .gmt (MSigDB C2 CP v6.1) - :param min_size: minimum gene-set size - :param max_size: maximum gene-set size - :param kcdf: "Gaussian" for log2(TPM+1) - :param mx_diff: GSVA mx_diff option - :param threads: parallelism - :param seed: random seed - :return: cell lines in rows, pathways in columns - """ - import gseapy as gp - - gv = gp.gsva( - data=expr_genes_by_samples, - gene_sets=gene_sets, - kcdf=kcdf, - min_size=min_size, - max_size=max_size, - mx_diff=mx_diff, - threads=threads, - seed=seed, - outdir=None, - verbose=False, - ) - # gseapy returns long format; column names vary across versions. - long = gv.res2d.copy() - cols = {c.lower(): c for c in long.columns} - term_col = cols.get("term", "Term") - name_col = cols.get("name", "Name") - es_col = cols.get("es", cols.get("nes", "ES")) - - wide = long.pivot(index=term_col, columns=name_col, values=es_col) # [pathways x samples] - return wide.T.astype(np.float32) # [samples x pathways] - - -def create_precily_pathway_features( - data_path: str, - dataset_name: str, - gene_sets: str, - min_size: int = 5, - max_size: int = 2000, - kcdf: str = "Gaussian", - mx_diff: bool = True, - threads: int = 4, - seed: int = 42, -) -> None: - """ - Compute GSVA pathway scores for all cell lines and write precily_pathways.csv. - - :param data_path: root data path - :param dataset_name: dataset name - :param gene_sets: path to MSigDB C2 CP v6.1 .gmt file - :param min_size: minimum gene-set size - :param max_size: maximum gene-set size - :param kcdf: kernel for the CDF ("Gaussian" for log2(TPM+1)) - :param mx_diff: GSVA mx_diff option - :param threads: parallelism - :param seed: random seed - """ - expr = _load_gene_expression(data_path, dataset_name) # [cell lines x genes] - - before = len(expr) - expr = expr.loc[~expr.index.duplicated(keep="first")] - print(f"Removed {before - len(expr)} duplicated cell lines") - - # gseapy expects genes in rows, samples in columns. - expr_genes_by_samples = expr.T - - scores = _run_gsva( - expr_genes_by_samples, - gene_sets=gene_sets, - min_size=min_size, - max_size=max_size, - kcdf=kcdf, - mx_diff=mx_diff, - threads=threads, - seed=seed, - ) # [cell lines x pathways] - - names_file = os.path.join(data_path, dataset_name, "cell_line_names.csv") - - if os.path.exists(names_file): - names_df = pd.read_csv(names_file) - - if {"cellosaurus_id", "cell_line_name"}.issubset(names_df.columns): - names = names_df.set_index("cellosaurus_id")["cell_line_name"] - scores = scores.join(names, how="inner") - scores = scores.set_index("cell_line_name") - scores.index.name = "cell_line_name" - out_path = os.path.join(data_path, dataset_name, "pathway_features.csv") - scores.to_csv(out_path) - print(f"Wrote {scores.shape[0]} cell lines x {scores.shape[1]} pathways -> {out_path}") - - -def main() -> None: - """Compute and save GSVA pathway features for a dataset.""" - parser = argparse.ArgumentParser(description="GSVA pathway featurizer for Precily.") - parser.add_argument("dataset_name") - parser.add_argument("--data_path", default="data") - parser.add_argument( - "--gene_sets", - required=True, - help="path to MSigDB C2 CP v6.1 .gmt file (c2.cp.v6.1.symbols.gmt)", - ) - parser.add_argument("--min_size", type=int, default=5) - parser.add_argument("--max_size", type=int, default=2000) - parser.add_argument("--kcdf", default="Gaussian") - parser.add_argument("--seed", type=int, default=42) - args = parser.parse_args() - - create_precily_pathway_features( - data_path=args.data_path, - dataset_name=args.dataset_name, - gene_sets=args.gene_sets, - min_size=args.min_size, - max_size=args.max_size, - kcdf=args.kcdf, - seed=args.seed, - ) - - -if __name__ == "__main__": - main() diff --git a/drevalpy/datasets/featurizer/create_sparsego_features.py b/drevalpy/datasets/featurizer/create_sparsego_features.py deleted file mode 100644 index 19dd11185..000000000 --- a/drevalpy/datasets/featurizer/create_sparsego_features.py +++ /dev/null @@ -1,382 +0,0 @@ -"""SparseGO input file featurizer. - -Generates the two files required by SparseGOModel that are not part of the -standard drevalpy dataset: - -- gene2ind.txt: tab-delimited index and gene_symbol -- sparseGO_ont.txt: tab-delimited parent, child, and type - -Requirements: pip install mygene obonet networkx -""" - -from __future__ import annotations - -import argparse -import itertools -import math -import os -import sys - -import networkx as nx -import networkx.algorithms.components.connected as nxacc -import numpy as np -import pandas as pd - - -def _remove_node(g: nx.DiGraph, node: str) -> nx.DiGraph: - """Remove a node and reconnect its parents directly to its children. - - This keeps the graph connected: grandparent -> grandchild edges are added - before the node is deleted, so no paths are broken. - - :param g: directed graph (parent -> child direction after .reverse()) - :param node: GO term ID to remove - :return: modified graph (same object) - """ - parents = [src for src, _ in g.in_edges(node)] - children = [dst for _, dst in g.out_edges(node)] - new_edges = [(src, dst) for src, dst in itertools.product(parents, children) if src != dst] - g.add_edges_from(new_edges) - g.remove_node(node) - return g - - -def _build_level_list(g: nx.DiGraph) -> list[list[str]]: - """Iteratively peel leaves to get nodes organised by level. - - Level 0 = leaves (genes or bottom-most GO terms with no children). - Each successive level contains the new leaves after removing the previous. - - :param g: directed graph - :return: list of node lists, one per level bottom-up - """ - g_copy = g.copy() - level_list: list[list[str]] = [] - while True: - leaves = [n for n in g_copy.nodes() if g_copy.out_degree(n) == 0] - if not leaves: - break - level_list.append(leaves) - g_copy.remove_nodes_from(leaves) - return level_list - - -def _download_obo(url: str, dest: str) -> None: - """Download a file with a browser-like User-Agent to avoid 403 errors. - - :param url: URL to download - :param dest: local destination path - :raises RuntimeError: if requests is not installed and download fails - """ - try: - import requests - - headers = {"User-Agent": "Mozilla/5.0 (compatible; drevalpy-sparsego/1.0)"} - response = requests.get(url, headers=headers, timeout=120, stream=True) - response.raise_for_status() - with open(dest, "wb") as fh: - for chunk in response.iter_content(chunk_size=1024 * 64): - fh.write(chunk) - print(f" Saved to {dest} ({os.path.getsize(dest) // 1024} KB)") - except ImportError as exc: - raise RuntimeError("requests is required to download go-basic.obo: pip install requests") from exc - - -def _load_gene_list(data_path: str, dataset_name: str) -> list[str]: - """Return gene symbols from the columns of gene_expression.csv. - - :param data_path: root data directory - :param dataset_name: dataset sub-directory - :return: list of gene symbols in original column order - :raises FileNotFoundError: if gene_expression.csv is not found - """ - expr_file = os.path.join(data_path, dataset_name, "gene_expression.csv") - if not os.path.exists(expr_file): - raise FileNotFoundError( - f"gene_expression.csv not found at {expr_file}. Run standard drevalpy data download first." - ) - genes = pd.read_csv(expr_file, index_col=0, nrows=0).columns.tolist() - print(f"Found {len(genes)} genes in gene_expression.csv") - return genes - - -def _fetch_gene_go_annotations(genes: list[str]) -> pd.DataFrame: - """Query MyGene.info and return a DataFrame of (go_term, gene_symbol) pairs. - - Uses a two-step approach: symbol -> entrezgene ID, then entrezgene -> GO BP. - The query is split in two halves to avoid Bad Gateway errors on large sets. - - :param genes: list of gene symbols - :return: DataFrame with columns [0=go_term_id, 1=gene_symbol], no duplicates - """ - try: - import mygene - except ImportError: - print("ERROR: mygene is required. Run: pip install mygene", file=sys.stderr) - sys.exit(1) - - mg = mygene.MyGeneInfo() - - print(f"Querying MyGene.info: {len(genes)} symbols -> entrezgene IDs ...") - genes_ids: pd.DataFrame = mg.querymany( - genes, - scopes="symbol", - species="human", - fields="entrezgene", - as_dataframe=True, - ) - genes_ids.reset_index(level=0, inplace=True) - genes_ids.dropna(subset=["entrezgene"], inplace=True) - genes_ids.drop_duplicates(subset=["query"], inplace=True) - print(f" Mapped {len(genes_ids)} / {len(genes)} genes to entrezgene IDs") - - total = len(genes_ids["entrezgene"]) - split = math.ceil(total / 2) - first_half = genes_ids["entrezgene"].iloc[:split] - second_half = genes_ids["entrezgene"].iloc[split:] - - print("Querying MyGene.info: entrezgene -> GO BP annotations (2 batches) ...") - ann1: pd.DataFrame = mg.getgenes(first_half, fields="symbol,go.BP.id", as_dataframe=True) - ann2: pd.DataFrame = mg.getgenes(second_half, fields="symbol,go.BP.id", as_dataframe=True) - genes_annotations = pd.concat([ann1, ann2]) - genes_annotations["symbol_ori"] = genes_ids["query"].values - - gene_go: list[tuple[str, str]] = [] - for _, row in genes_annotations.iterrows(): - annotations = row.get("go.BP") - symbol = row["symbol_ori"] - - if isinstance(annotations, list): - terms = [item for sublist in annotations for item in sublist.values()] - pairs = [ - (term, symbol) for term in terms if term != symbol and isinstance(term, str) and term.startswith("GO:") - ] - gene_go.extend(pairs) - elif isinstance(row.get("go.BP.id"), str): - gene_go.append((row["go.BP.id"], symbol)) - - gene_go_df = pd.DataFrame(gene_go).drop_duplicates() - print(f" Gene-GO pairs collected: {len(gene_go_df)}") - return gene_go_df - - -def _build_pruned_graph( - gene_go_df: pd.DataFrame, - obo_file: str | None, - n: int, - m: int, - p: int, -) -> nx.DiGraph: - """Build the GO hierarchy and prune it according to conditions n, m, p. - - Mirrors the original get_gene_hierarchy.py logic exactly. - - :param gene_go_df: DataFrame with columns [0=go_term, 1=gene_symbol] - :param obo_file: path to go-basic.obo (downloaded if None) - :param n: minimum directly-annotated genes per term - :param m: minimum extra genes a parent must have over each child - :param p: max levels above the bottom layer to keep - :return: pruned directed graph (parent -> child, genes as leaf nodes) - """ - try: - import obonet - except ImportError: - print("ERROR: obonet is required. Run: pip install obonet", file=sys.stderr) - sys.exit(1) - - if obo_file is None: - obo_url = "https://current.geneontology.org/ontology/go-basic.obo" - obo_file = "go-basic.obo" - if not os.path.exists(obo_file): - print(f"Downloading go-basic.obo from {obo_url} ...") - _download_obo(obo_url, obo_file) - else: - print(f"Using cached {obo_file}") - - print(f"Parsing {obo_file} ...") - full_graph: nx.MultiDiGraph = obonet.read_obo(obo_file) - full_graph = full_graph.reverse() - roots = [n_id for n_id in full_graph.nodes if full_graph.in_degree(n_id) == 0] - print(f"OBO graph loaded: {len(full_graph.nodes)} nodes, roots: {roots[:5]}") - - all_nodes: set[str] = set(full_graph.nodes()) - keep_nodes: set[str] = set(gene_go_df.iloc[:, 0]) - - for term in keep_nodes.copy(): - if full_graph.in_degree(term) == 0: - keep_nodes.discard(term) - - keep_nodes.add("GO:0008150") - - unwanted = all_nodes - keep_nodes - our_graph: nx.DiGraph = full_graph.copy() - print(f"Removing {len(unwanted)} non-annotated terms ...") - for term in unwanted: - if term in our_graph: - _remove_node(our_graph, term) - - gene_go_list = list(gene_go_df.itertuples(index=False, name=None)) - our_graph.add_edges_from(gene_go_list) - - for node in list(our_graph.nodes): - if our_graph.in_degree(node) == 0 and node != "GO:0008150": - _remove_node(our_graph, node) - - roots_now = [n_id for n_id in our_graph.nodes if our_graph.in_degree(n_id) == 0] - print(f"After adding genes: {len(our_graph.nodes)} nodes, roots: {roots_now[:3]}") - - level_list = _build_level_list(our_graph) - print(f"Graph depth: {len(level_list)} levels. Applying conditions n={n}, m={m} ...") - - for terms_to_check in level_list[1:]: - for node in list(terms_to_check): - if node not in our_graph: - continue - - genes: list[str] = [] - children: list[str] = [] - for _, child in our_graph.out_edges(node): - if child.startswith("GO:"): - children.append(child) - else: - genes.append(child) - genes = list(set(genes)) - children = list(set(children)) - - if len(genes) < n and node != "GO:0008150": - _remove_node(our_graph, node) - continue - - if children: - for child_node in children: - child_genes = [c for _, c in our_graph.out_edges(child_node) if not c.startswith("GO:")] - if len(set(genes) - set(child_genes)) < m and node != "GO:0008150": - _remove_node(our_graph, node) - break - - roots_after_nm = [n_id for n_id in our_graph.nodes if our_graph.in_degree(n_id) == 0] - print(f"After n/m pruning: {len(our_graph.nodes)} nodes, roots: {roots_after_nm[:3]}") - - level_list_pruned = _build_level_list(our_graph) - print(f"Depth after n/m: {len(level_list_pruned)} levels. Applying p={p} ...") - for level in level_list_pruned[p + 1 : len(level_list_pruned) - 1]: # noqa: E203 - for term in level: - if term in our_graph: - _remove_node(our_graph, term) - - uG = our_graph.to_undirected() - components = list(nxacc.connected_components(uG)) - final_roots = [n_id for n_id in our_graph.nodes if our_graph.in_degree(n_id) == 0] - print(f"Final graph: {len(our_graph.nodes)} nodes, {len(components)} component(s), roots: {final_roots[:3]}") - if len(components) > 1: - print("WARNING: more than one connected component. load_ontology will fail. Consider adjusting n/m/p.") - - return our_graph - - -def _write_outputs(our_graph: nx.DiGraph, data_path: str, dataset_name: str) -> None: - """Write gene2ind.txt and sparseGO_ont.txt. - - :param our_graph: final pruned graph (parent -> child edges) - :param data_path: root data directory - :param dataset_name: dataset sub-directory - """ - out_dir = os.path.join(data_path, dataset_name) - - edges = np.array(list(our_graph.edges())) - edges = np.unique(edges, axis=0) - type_col = np.where( - np.char.startswith(edges[:, 1].astype(str), "GO:"), - "default", - "gene", - ) - edges_with_type = np.column_stack([edges, type_col]) - - ont_path = os.path.join(out_dir, "sparseGO_ont.txt") - pd.DataFrame(edges_with_type).to_csv(ont_path, sep="\t", index=False, header=False) - n_default = (type_col == "default").sum() - n_gene = (type_col == "gene").sum() - print(f"Wrote {n_default} term-term + {n_gene} gene-term edges -> {ont_path}") - - gene_edges = edges_with_type[edges_with_type[:, 2] == "gene"] - keep_genes = sorted(set(gene_edges[:, 1])) - print(f"Genes in ontology: {len(keep_genes)}") - - gene2id = {gene: idx for idx, gene in enumerate(keep_genes)} - gene2ind_path = os.path.join(out_dir, "gene2ind.txt") - with open(gene2ind_path, "w") as fh: - for gene, idx in gene2id.items(): - fh.write(f"{idx}\t{gene}\n") - print(f"Wrote {len(gene2id)} genes -> {gene2ind_path}") - - expr_path = os.path.join(out_dir, "gene_expression.csv") - expr_cols = set(pd.read_csv(expr_path, index_col=0, nrows=0).columns.tolist()) - missing = [g for g in keep_genes if g not in expr_cols] - if missing: - print(f" WARNING: {len(missing)} ontology genes missing from gene_expression.csv: {missing}") - else: - print(f" OK: all {len(keep_genes)} ontology genes present in gene_expression.csv") - - -def create_sparsego_files( - data_path: str, - dataset_name: str, - obo_file: str | None = None, - n: int = 5, - m: int = 10, - p: int = 8, -) -> None: - """Generate gene2ind.txt and sparseGO_ont.txt for a drevalpy dataset. - - Mirrors the logic of the original SparseGO get_gene_hierarchy.py exactly, - including the three pruning conditions n, m, p and the two-step MyGene - query via entrezgene IDs. - - Does NOT modify gene_expression.csv: drevalpy selects genes by column - name at runtime via get_feature_matrix. - - :param data_path: root data directory - :param dataset_name: dataset name (sub-directory under data_path) - :param obo_file: path to go-basic.obo (auto-downloaded if None) - :param n: minimum directly-annotated genes per GO term (default 5) - :param m: minimum extra genes parent must have over each child (default 10) - :param p: max parent-child levels above bottom layer (default 8) - """ - out_dir = os.path.join(data_path, dataset_name) - os.makedirs(out_dir, exist_ok=True) - - genes = _load_gene_list(data_path, dataset_name) - gene_go_df = _fetch_gene_go_annotations(genes) - our_graph = _build_pruned_graph(gene_go_df, obo_file, n=n, m=m, p=p) - _write_outputs(our_graph, data_path, dataset_name) - - print("\nDone.") - print(f" {os.path.join(out_dir, 'gene2ind.txt')}") - print(f" {os.path.join(out_dir, 'sparseGO_ont.txt')}") - - -def main() -> None: - """CLI entry point.""" - parser = argparse.ArgumentParser(description="Generate gene2ind.txt and sparseGO_ont.txt for a drevalpy dataset.") - parser.add_argument("dataset_name", help="Dataset name, e.g. CTRPv2") - parser.add_argument("--data_path", default="data", help="Root data directory") - parser.add_argument("--obo_file", default=None, help="Path to go-basic.obo (auto-downloaded if not provided)") - parser.add_argument("--n", type=int, default=5, help="Min directly-annotated genes per GO term (default: 5)") - parser.add_argument( - "--m", type=int, default=10, help="Min extra genes parent must have over each child (default: 10)" - ) - parser.add_argument("--p", type=int, default=8, help="Max levels above bottom GO layer (default: 8)") - args = parser.parse_args() - - create_sparsego_files( - data_path=args.data_path, - dataset_name=args.dataset_name, - obo_file=args.obo_file, - n=args.n, - m=args.m, - p=args.p, - ) - - -if __name__ == "__main__": - main() diff --git a/drevalpy/datasets/loader.py b/drevalpy/datasets/loader.py deleted file mode 100644 index 497ac03ec..000000000 --- a/drevalpy/datasets/loader.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Contains functions to load the GDSC1, GDSC2, CCLE, and Toy datasets.""" - -import os -from pathlib import Path -from typing import Callable - -import pandas as pd - -from .curvecurator import fit_curves -from .dataset import DrugResponseDataset -from .utils import ( - ALLOWED_MEASURES, - CELL_LINE_IDENTIFIER, - DRUG_IDENTIFIER, - TISSUE_IDENTIFIER, - download_dataset, - download_from_url, - unzip_data, -) - - -def check_measure(measure_queried: str, measures_data: list[str], dataset_name: str) -> None: - """ - Check if the queried measure is in the dataset. - - :param measure_queried: The measure to check. - :param measures_data: The measures in the dataset. - :param dataset_name: The name of the dataset. - :raises ValueError: If the measure is not found in the dataset. - """ - measures_available = set(ALLOWED_MEASURES).intersection(set(measures_data)) - if measure_queried not in measures_data: - raise ValueError( - f"Measure '{measure_queried}' not found in dataset {dataset_name}." - f"Available measures are: {', '.join(measures_available)}." - ) - - -def _load_zenodo_dataset( - path_data: str = "data", - measure: str = "LN_IC50_curvecurator", - file_name: str = "dataset_name.csv", - dataset_name: str = "dataset_name", -) -> DrugResponseDataset: - """ - Parent function to load_gdsc1, load_gdsc2, ... - - :param path_data: Path to the dataset. - :param file_name: File name of the dataset, e.g., GDSC1.csv - :param measure: File name of the dataset, default = "LN_IC50_curvecurator". - :param dataset_name: Name of the dataset, e.g., GDSC1. - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs. - """ - path = os.path.join(path_data, dataset_name, file_name) - if not os.path.exists(path): - download_dataset(dataset_name, path_data, redownload=True) - # tissue mapping is not in TOY play dataset - meta_path = os.path.join(path_data, "meta", "tissue_mapping.csv") - if not os.path.exists(meta_path): - download_dataset("meta", path_data, redownload=True) - - response_data = pd.read_csv(path, dtype={"pubchem_id": str, "cell_line_name": str}) - response_data[DRUG_IDENTIFIER] = response_data[DRUG_IDENTIFIER].str.replace(",", "") - check_measure(measure, list(response_data.columns), dataset_name) - if dataset_name == "BeatAML2": - # only has AML patients = blood - response_data[TISSUE_IDENTIFIER] = "Blood" - elif dataset_name == "PDX_Bruna": - # only has breast cancer patients - response_data[TISSUE_IDENTIFIER] = "Breast" - return DrugResponseDataset( - response=response_data[measure].values, - cell_line_ids=response_data[CELL_LINE_IDENTIFIER].values, - drug_ids=response_data[DRUG_IDENTIFIER].values, - tissues=response_data[TISSUE_IDENTIFIER].values, - dataset_name=dataset_name, - ) - - -def load_gdsc1( - path_data: str = "data", - measure: str = "LN_IC50_curvecurator", -) -> DrugResponseDataset: - """ - Loads the GDSC1 dataset. - - :param path_data: Path to the dataset. - :param measure: The name of the column containing the measure to predict, default = "LN_IC50_curvecurator" - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs. - """ - return _load_zenodo_dataset(path_data=path_data, measure=measure, file_name="GDSC1.csv", dataset_name="GDSC1") - - -def load_gdsc2( - path_data: str = "data", - measure: str = "LN_IC50_curvecurator", -): - """ - Loads the GDSC2 dataset. - - :param path_data: Path to the dataset. - :param measure: The name of the column containing the measure to predict, default = "LN_IC50_curvecurator" - - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs. - """ - return _load_zenodo_dataset(path_data=path_data, measure=measure, file_name="GDSC2.csv", dataset_name="GDSC2") - - -def load_ccle( - path_data: str = "data", - measure: str = "LN_IC50_curvecurator", -) -> DrugResponseDataset: - """ - Loads the CCLE dataset. - - :param path_data: Path to the dataset. - :param measure: The name of the column containing the measure to predict, default = "LN_IC50_curvecurator" - - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs. - """ - return _load_zenodo_dataset(path_data=path_data, measure=measure, file_name="CCLE.csv", dataset_name="CCLE") - - -def _load_test_data( - path_data: str = "data", measure: str = "LN_IC50_curvecurator", dataset_name: str = "TOYv1" -) -> DrugResponseDataset: - # ensure that path_data exists - Path(path_data).mkdir(parents=True, exist_ok=True) - test_data_path = "https://github.com/nf-core/test-datasets/raw/refs/heads/drugresponseeval/test_data" - # first get meta - meta_path = os.path.join(path_data, "meta") - if not os.path.exists(meta_path): - file_url = f"{test_data_path}/meta.zip" - file_path = Path(path_data) / "meta.zip" - response_meta = download_from_url(dataset_name="meta", file_url=file_url) - unzip_data(path_to_zip=file_path, response=response_meta, data_path=path_data) - # get raw test data - raw_data_path = os.path.join(path_data, "CTRPv2_sample_test") - if not os.path.exists(raw_data_path): - file_url = f"{test_data_path}/CTRPv2_sample_test.zip" - file_path = Path(path_data) / "CTRPv2_sample_test.zip" - response_raw = download_from_url(dataset_name="CTRPv2_sample_test", file_url=file_url) - unzip_data(path_to_zip=file_path, response=response_raw, data_path=path_data) - file_url = f"{test_data_path}/{dataset_name}.zip" - file_path = Path(path_data) / f"{dataset_name}.zip" - response = download_from_url(dataset_name=dataset_name, file_url=file_url) - unzip_data(path_to_zip=file_path, response=response, data_path=path_data) - - file_name = Path(path_data) / dataset_name / f"{dataset_name}.csv" - response_data = pd.read_csv(file_name, dtype={"pubchem_id": str, "cell_line_name": str}) - response_data[DRUG_IDENTIFIER] = response_data[DRUG_IDENTIFIER].str.replace(",", "") - check_measure(measure, list(response_data.columns), dataset_name) - return DrugResponseDataset( - response=response_data[measure].values, - cell_line_ids=response_data[CELL_LINE_IDENTIFIER].values, - drug_ids=response_data[DRUG_IDENTIFIER].values, - tissues=response_data[TISSUE_IDENTIFIER].values, - dataset_name=dataset_name, - ) - - -def load_toyv1(path_data: str = "data", measure: str = "LN_IC50_curvecurator") -> DrugResponseDataset: - """ - Loads small Toy dataset, subsampled from CTRPv2. - - :param path_data: Path to the dataset. - :param measure: The name of the column containing the measure to predict, default = "LN_IC50_curvecurator" - - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs. - """ - return _load_test_data(path_data=path_data, measure=measure, dataset_name="TOYv1") - - -def load_toyv2(path_data: str = "data", measure: str = "LN_IC50_curvecurator") -> DrugResponseDataset: - """ - Loads small Toy dataset, subsampled from GDSC2. Can be used to test cross study prediction. - - :param path_data: Path to the dataset. - :param measure: The name of the column containing the measure to predict, default = "LN_IC50_curvecurator" - - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs. - """ - return _load_test_data(path_data=path_data, measure=measure, dataset_name="TOYv2") - - -def load_ctrpv1(path_data: str = "data", measure: str = "LN_IC50_curvecurator") -> DrugResponseDataset: - """ - Load CTRPv1 dataset. - - :param path_data: Path to the location of CTRPv1 dataset - :param measure: The name of the column containing the measure to predict, default = "LN_IC50_curvecurator" - - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs - """ - return _load_zenodo_dataset(path_data=path_data, measure=measure, file_name="CTRPv1.csv", dataset_name="CTRPv1") - - -def load_ctrpv2(path_data: str = "data", measure: str = "LN_IC50_curvecurator") -> DrugResponseDataset: - """ - Load CTRPv2 dataset. - - :param path_data: Path to the location of CTRPv2 dataset - :param measure: The name of the column containing the measure to predict, default: LN_IC50_curvecurator - - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs - """ - return _load_zenodo_dataset(path_data=path_data, measure=measure, file_name="CTRPv2.csv", dataset_name="CTRPv2") - - -def load_beataml2( - path_data: str = "data", - measure: str = "LN_IC50_curvecurator", -) -> DrugResponseDataset: - """ - Loads the BeatAML2 dataset. - - :param path_data: Path to the dataset. - :param measure: The name of the column containing the measure to predict, default: LN_IC50_curvecurator - - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs. - """ - return _load_zenodo_dataset(path_data=path_data, measure=measure, file_name="BeatAML2.csv", dataset_name="BeatAML2") - - -def load_pdx_bruna( - path_data: str = "data", - measure: str = "LN_IC50_curvecurator", -) -> DrugResponseDataset: - """ - Loads the PDX_Bruna dataset. - - :param path_data: Path to the dataset. - :param measure: The name of the column containing the measure to predict, default: LN_IC50_curvecurator - - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs. - """ - return _load_zenodo_dataset( - path_data=path_data, measure=measure, file_name="PDX_Bruna.csv", dataset_name="PDX_Bruna" - ) - - -def load_custom( - path_data: str | Path, dataset_name: str = "custom", measure: str = "response", tissue_column: str | None = None -) -> DrugResponseDataset: - """ - Load custom dataset. - - :param path_data: Path to location of custom dataset - :param dataset_name: Name of the dataset. - :param measure: The name of the column containing the measure to predict, default = "response" - :param tissue_column: The name of the column containing the tissue type. If None, no tissue information is loaded. - - :return: DrugResponseDataset containing response, cell line IDs, and drug IDs - """ - return DrugResponseDataset.from_csv( - input_file=path_data, dataset_name=dataset_name, measure=measure, tissue_column=tissue_column - ) - - -# Used in pipeline -AVAILABLE_DATASETS: dict[str, Callable] = { - "GDSC1": load_gdsc1, - "GDSC2": load_gdsc2, - "CCLE": load_ccle, - "TOYv1": load_toyv1, - "TOYv2": load_toyv2, - "CTRPv1": load_ctrpv1, - "CTRPv2": load_ctrpv2, - "BeatAML2": load_beataml2, - "PDX_Bruna": load_pdx_bruna, -} - - -def load_dataset( - dataset_name: str, - path_data: str = "data", - measure: str = "response", - curve_curator: bool = False, - cores: int = 1, - tissue_column: str | None = None, - normalize: bool = False, -) -> DrugResponseDataset: - """ - Load a dataset based on the dataset name. - - :param dataset_name: The name of the dataset to load. Can be one of ('GDSC1', 'GDSC2', 'CCLE', 'TOYv1', or 'TOYv2') - to download provided datasets, or any other name to allow for custom datasets. - :param path_data: The parent path in which custom or downloaded datasets should be located, or in which raw - viability data is to be found for fitting with CurveCurator (see param curve_curator for details). - The location of the datasets are resolved by //.csv. - :param measure: The name of the column containing the measure to predict, default = "response". - If curve_curator is True, this measure is appended with "_curvecurator", e.g. "response_curvecurator" to - distinguish between measures provided by the original source of a dataset, or the measures fit by - CurveCurator. - :param curve_curator: If True, the measure is appended with "_curvecurator". - If a custom dataset_name was provided, this will invoke the fitting procedure of raw viability data, - which is expected to exist at //_raw.csv. The fitted dataset will - be stored in the same folder, in a file called .csv - :param cores: Number of cores to use for CurveCurator fitting. Only used when curve_curator is True, default = 1 - :param tissue_column: The name of the column containing the tissue type. If None, no tissue information is loaded. - This is only used when loading a custom dataset. Default = None. - :param normalize: Whether to normalize the response values to [0, 1] for curvecurator. Default = False. - Only used for custom datasets when curve_curator is True. - :return: A DrugResponseDataset containing response, cell line IDs, drug IDs, and dataset name. - :raises FileNotFoundError: If the custom dataset or raw viability data could not be found at the given path. - """ - if curve_curator: - measure += "_curvecurator" - input_file = Path(path_data).resolve() / dataset_name / f"{dataset_name}_raw.csv" - else: - input_file = Path(path_data).resolve() / dataset_name / f"{dataset_name}.csv" - - if dataset_name in AVAILABLE_DATASETS: - return AVAILABLE_DATASETS[dataset_name](path_data, measure=measure) - - if input_file.is_file(): - if curve_curator: - fit_curves( - input_file=str(input_file), - output_dir=str(input_file.parent), - dataset_name=dataset_name, - cores=cores, - normalize=normalize, - ) - return load_custom( - path_data=Path(path_data) / dataset_name / f"{dataset_name}.csv", - dataset_name=dataset_name, - measure=measure, - tissue_column=tissue_column, - ) - raise FileNotFoundError(f"Custom dataset does not exist at given path: {input_file}") diff --git a/drevalpy/datasets/map_tissues.py b/drevalpy/datasets/map_tissues.py deleted file mode 100644 index b7a7a7aa3..000000000 --- a/drevalpy/datasets/map_tissues.py +++ /dev/null @@ -1,431 +0,0 @@ -""" -Command-line tool to generate and update harmonized tissue annotations for cancer cell lines. - -Uses Cellosaurus and DepMap metadata (and some errors in these datasets are fixed manually) -and adds it to the response datasets e.g. for LTO splits. -Use it for reference or to create the tissue annotations for custom datasets - -This tool performs the following steps: -1. Loads all unique Cellosaurus IDs from a specified set of drug response datasets (e.g., CCLE, GDSC1). -2. Downloads and parses the latest Cellosaurus reference file to extract metadata -(name, site of derivation, and disease). -3. Loads DepMap sample information to merge and normalize disease labels. -4. Applies a curated tissue synonym dictionary to map specific -diseases to broader, biologically meaningful tissue categories. -5. Manually overrides mappings for key misclassified or ambiguous cell lines -based on verified external sources (e.g., ATCC, NCI). -6. Saves the final tissue mapping to a central CSV file. -7. Propagates the harmonized tissue column back into each of the original datasets. - -Arguments: -- `data_path` (str): Path to the directory containing all datasets and metadata files. -- `dataset` (str): One of {"CCLE", "GDSC1", "GDSC2", "CTRPv1", "CTRPv2", "all"} -- save_tissue_mapping (bool): If True, saves the tissue mapping to a CSV file. -or a custom dataset name. When "all" is specified, the tissue mapping is applied across all non-custom datasets - -Notes: -- This script assumes each dataset is stored as a CSV file under `//.csv`. -- Tissue mapping is derived from a curated synonym dictionary and adjusted -for special cases based on literature or database references. -- The output `tissue_mapping.csv` is saved in `/meta/`. - -Example usage: - python -m mymodule.add_tissue_mapping data/ all -""" - -import argparse -import os -import urllib.request -from pathlib import Path - -import pandas as pd - -from . import AVAILABLE_DATASETS -from .loader import download_dataset - -_tissue_synonyms = { - "Lung": [ - "lung", - "small cell lung cancer", - "lung adenocarcinoma", - "minimally invasive lung adenocarcinoma", - "lung squamous cell carcinoma", - "lung carcinoid tumor", - "lung mucoepidermoid carcinoma", - "lung giant cell carcinoma", - "lung adenosquamous carcinoma", - "mesothelioma", - "pleural mesothelioma", - ], - "Colon": [ - "colorectal", - "colon adenocarcinoma", - "cecum adenocarcinoma", - "gardner syndrome", - "intestine", - "rectal adenocarcinoma", - ], - "Small Intestine": [ - "small_intestine", - "small intestine adenocarcinoma", - "small intestine carcinoid tumor", - "small intestine neuroendocrine tumor", - "small intestine neuroendocrine carcinoma", - ], - "Stomach": [ - "gastric", - "gastric adenocarcinoma", - "gastric tubular adenocarcinoma", - "gastric signet ring cell adenocarcinoma", - "gastric choriocarcinoma", - ], - "Esophagus": ["esophagus", "squamous cell carcinoma of the esophagus", "adenocarcinoma of the esophagus"], - "Head and neck": [ - "upper_aerodigestive", - "head and neck squamous cell carcinoma", - "squamous cell carcinoma of the larynx", - "squamous cell carcinoma of the hypopharynx", - "squamous cell carcinoma of the oral cavity", - "squamous cell carcinoma of the oral tongue", - "oral epithelial dysplasia", - "parotid gland mucoepidermoid carcinoma", - ], - "Skin": [ - "skin", - "melanoma", - "cutaneous melanoma", - "amelanotic melanoma", - "skin squamous cell carcinoma", - "vulvar melanoma", - ], - "Blood": [ - "leukemia", - "acute myeloid leukemia", - "chronic myeloid leukemia", - "precursor b-cell acute lymphoblastic leukemia", - "precursor t-cell acute lymphoblastic leukemia", - "classic hairy cell leukemia", - "mixed phenotype acute leukemia", - "acute erythroid leukemia", - "acute myelomonocytic leukemia", - "acute monoblastic/monocytic leukemia", - "hereditary spherocytosis", - "multiple myeloma", - "multiple_myeloma", - "bone marrow", - "natural killer cell lymphoblastic leukemia/lymphoma", - "b-lymphoblastic leukemia/lymphoma with t(v", - "b-lymphoblastic leukemia/lymphoma with t(17", - ], - "Lymph": [ - "lymphoma", - "hodgkin lymphoma", - "diffuse large b-cell lymphoma", - "burkitt lymphoma", - "primary mediastinal large b-cell lymphoma", - "b-cell non-hodgkin lymphoma", - "sezary syndrome", - "follicular lymphoma", - "alk-positive anaplastic large cell lymphoma", - "primary effusion lymphoma", - "splenic marginal zone lymphoma", - "primary cutaneous t-cell lymphoma", - ], - "Bone": ["bone", "osteosarcoma", "ewing sarcoma", "chondrosarcoma"], - "Muscle": ["rhabdomyosarcoma", "rhabdoid", "embryonal rhabdomyosarcoma", "rhabdoid tumor"], - "Soft Tissue": [ - "soft_tissue", - "fibrosarcoma", - "undifferentiated pleomorphic sarcoma", - "liposarcoma", - "fibroblast", - "synovial sarcoma", - ], - "Thyroid": ["thyroid", "anaplastic thyroid carcinoma", "differentiated thyroid carcinoma"], - "Brain": [ - "central_nervous_system", - "glioblastoma", - "gliosarcoma", - "astrocytoma", - "anaplastic astrocytoma", - "diffuse astrocytoma", - ], - "Nervous system": [ - "neuroblastoma", - "peripheral primitive neuroectodermal tumor", - "peripheral_nervous_system", - "nervous system", - ], - "Breast": [ - "breast", - "breast carcinoma", - "breast ductal carcinoma", - "invasive breast carcinoma of no special type", - ], - "Ovary": [ - "ovary", - "high grade ovarian serous adenocarcinoma", - "ovarian serous adenocarcinoma", - "maligant granulosa cell tumor of the ovary", - ], - "Uterus": ["uterus", "high-grade neuroendocrine carcinoma of the cervix uteri"], - "Cervix": [ - "cervix", - "vagina", - "vulvar carcinoma", - "vulvar squamous cell carcinoma", - "squamous cell carcinoma of the cervix uteri", - ], - "Prostate": ["prostate"], - "Kidney": ["kidney", "renal cell carcinoma", "clear cell renal carcinoma"], - "Bladder": ["urinary_tract", "bladder carcinoma"], - "Liver": [ - "liver", - "cholangiocarcinoma", - "bile_duct", - "hepatoblastoma", - "carcinoma of gallbladder and extrahepatic biliary tract", - ], - "Pancreas": ["pancreas", "pancreatic ductal adenocarcinoma"], - "Adrenal Gland": ["adrenal_cortex"], - "Embryonic": ["embryo", "non-central nervous system-localized embryonal carcinoma", "embryonal carcinoma"], - "Unknown": ["unknown", "other", "carcinoid syndrome"], -} - - -def _parse_cellosaurus(cellosaurus_path: str | Path) -> tuple[dict, dict, dict]: - """ - Parse Cellosaurus file and return mappings from cellosaurus ID to name, site, and disease. - - :param cellosaurus_path: Path to the Cellosaurus text file - :return: Tuple of dictionaries (id_to_name, id_to_site, id_to_disease) - """ - id_to_name, id_to_site, id_to_disease = {}, {}, {} - cellosaurus_path = str(cellosaurus_path) - with open(cellosaurus_path, encoding="utf-8") as f: - current_ids, current_name, site, disease = [], None, None, None - for line in f: - if line.startswith("ID "): - current_name = line.strip().split(" ")[1] - elif line.startswith("AC "): - current_ids = [s.strip() for s in line[5:].split(";") if s.strip()] - elif line.startswith("CC Derived from site:"): - parts = line.strip().split(":", 1)[1].split(";") - if len(parts) >= 2: - site = parts[1].strip() - elif line.startswith("DI ") and current_ids: - parts = line[5:].split(";") - if len(parts) >= 3: - disease = parts[2].strip() - elif line.strip() == "//": - for cid in current_ids: - if current_name: - id_to_name[cid] = current_name - if site: - id_to_site[cid] = site - if disease: - id_to_disease[cid] = disease - current_ids, current_name, site, disease = [], None, None, None - - return id_to_name, id_to_site, id_to_disease - - -def _apply_manual_cell_line_corrections(tissue_map: pd.Series) -> pd.Series: - """Apply manual tissue corrections for misclassified or ambiguous cell lines with documented sources. - - :param tissue_map: Series mapping Cellosaurus IDs to tissues - :return: Updated tissue mapping Series - """ - manual_entries = [ - # CVCL_0977: Hs 888.Lu - # Source: Cell Model Passports - SIDM01745 - # https://cellmodelpassports.sanger.ac.uk/passports/SIDM01745 - ("CVCL_0977", "Lung"), - # CVCL_1072: ARH-77 - # Source: Culture Collections - ARH-77 - # https://www.culturecollections.org.uk/products/celllines/detail.jsp?refId=88121201 - ("CVCL_1072", "Blood"), - # CVCL_1305: IM-9 - # Source: ATCC - CCL-159 - # https://www.atcc.org/products/ccl-159 - ("CVCL_1305", "Blood"), - # CVCL_1665: RPMI-6666 - # Source: https://scicrunch.org/resolver/RRID:CVCL_1665?q=&i=rrid:cvcl_1665-kclb-10113 - # Derived from leukemic cells of a myeloma patient, EBV-transformed B lymphoblastoid line - ("CVCL_1665", "Blood"), - # CVCL_0807: Hs 578Bst - # Source: https://scicrunch.org/resolver/CVCL_0807/ - ("CVCL_0807", "Breast"), - # CVCL_L296: H-STS - # Source: https://www.nature.com/articles/s41588-019-0490-z - ("CVCL_L296", "Blood"), - # CVCL_ZA06: WT2-iPS - # Source: https://discover.nci.nih.gov/rsconnect/cellminercdb/cell_lines/wt2ips_cellminercdb.html - # NOTE: Excluded on purpose — cl doesn't exist, was wrong mapping before - # ("CVCL_ZA06", "Skin"), - # CVCL_L298: P-STS - # Source: Pfragner R, Behmel A, Höger H, Beham A, - # Ingolic E, Stelzer I, Svejda B, Moser VA, Obenauf AC, Siegl V, et al. (2009). - # Establishment and characterization of three novel cell lines - # – P-STS, L-STS, H-STS – derived from a human metastatic midgut carcinoid. - # Anticancer Research, 29(6), 1951–1961. - ("CVCL_L298", "Small Intestine"), - # CVCL_3386 was misclassified. - # source: https://www.cellosaurus.org/CVCL_3386 - ("CVCL_3386", "Blood"), - ] - - for cellosaurus_id, tissue in manual_entries: - tissue_map[cellosaurus_id] = tissue - - return tissue_map - - -def _harmonize_disease_annotations(df_cellosaurus: pd.DataFrame, sample_info: pd.DataFrame) -> pd.DataFrame: - """Merge Cellosaurus and DepMap data, normalize names, and harmonize disease annotations. - - :param df_cellosaurus: DataFrame containing Cellosaurus data - :param sample_info: DataFrame containing DepMap sample information - :return: Merged DataFrame with harmonized disease annotations - - """ - df_cellosaurus["name_norm"] = df_cellosaurus["cell_line_name"].str.lower().str.replace(r"[^a-z0-9]", "", regex=True) - sample_info["name_norm"] = ( - sample_info["stripped_cell_line_name"].str.lower().str.replace(r"[^a-z0-9]", "", regex=True) - ) - - merged = pd.merge(df_cellosaurus, sample_info, on="name_norm", how="left") - merged["disease"] = merged["disease"].replace(r"^\s*$", "unknown", regex=True) - - merged["disease_combined"] = ( - merged.apply( - lambda row: ( - row["cellosaurus_disease"] - if (pd.isna(row["disease"]) or row["disease"].strip().lower() == "unknown") - and pd.notna(row["cellosaurus_disease"]) - else row["disease"] - ), - axis=1, - ) - .str.strip() - .str.lower() - ) - - return merged - - -def main(): - """Main function to add tissue mapping to datasets.""" - parser = argparse.ArgumentParser(description="Add tissue mapping to datasets") - parser.add_argument("data_path", help="Path to dataset root directory", default="data") - parser.add_argument("dataset", help="Dataset name (e.g., CCLE) or 'all'", default="all") - parser.add_argument( - "--save_tissue_mapping", - action="store_true", - help="Save the tissue mapping to a CSV file", - ) - args = parser.parse_args() - data_path = args.data_path - dataset = args.dataset - save_tissue_mapping = args.save_tissue_mapping - if dataset != "all": - datasets = [dataset] - else: - datasets = AVAILABLE_DATASETS.keys() - - cell_lines = [] - - # Load all unique Cellosaurus IDs from available datasets - for ds in datasets: - csv_path = os.path.join(data_path, ds, f"{ds}.csv") - try: - df = pd.read_csv(csv_path, dtype=str, low_memory=False) - cell_lines.extend(df["cellosaurus_id"].dropna().unique()) - except FileNotFoundError: - continue - - cellosaurus_ids = pd.Series(cell_lines).drop_duplicates().reset_index(drop=True) - - cellosaurus_path = Path(data_path) / "meta" / "cellosaurus.txt" - cellosaurus_path.parent.mkdir(parents=True, exist_ok=True) - - if not cellosaurus_path.exists(): - url = "https://ftp.expasy.org/databases/cellosaurus/cellosaurus.txt" - urllib.request.urlretrieve(url, cellosaurus_path) # noqa-S310 - - # Parse Cellosaurus - id_to_name, id_to_site, id_to_disease = _parse_cellosaurus(cellosaurus_path) - - # Build Cellosaurus DataFrame - df_cellosaurus = pd.DataFrame( - { - "cellosaurus_id": cellosaurus_ids, - "cell_line_name": cellosaurus_ids.map(id_to_name), - "cellosaurus_derived_from_site": cellosaurus_ids.map(id_to_site), - "cellosaurus_disease": cellosaurus_ids.map(id_to_disease), - } - ).dropna(subset=["cell_line_name"]) - - # Load DepMap sample_info - depmap_path = os.path.join(data_path, "meta", "DepMap_sample_info.csv") - if not os.path.exists(depmap_path): - download_dataset(dataset_name="meta", data_path=data_path, redownload=True) - - sample_info = pd.read_csv(depmap_path, dtype=str, low_memory=False) - - merged = _harmonize_disease_annotations(df_cellosaurus, sample_info) - - # Synonym mapping - - tissue_lookup = {syn.lower(): tissue for tissue, syns in _tissue_synonyms.items() for syn in syns} - - # Map tissues - merged["disease_cleaned"] = merged["disease_combined"].map(tissue_lookup).fillna("Unknown").str.title() - - # Final tissue map - final = merged[ - [ - "cellosaurus_id", - "cell_line_name", - "DepMap_ID", - "disease", - "disease_combined", - "disease_cleaned", - "disease_sutype", - "disease_sub_subtype", - "culture_type", - "culture_medium", - "gender", - "source", - "cellosaurus_derived_from_site", - "cellosaurus_disease", - ] - ] - - # Make sure the mapping has unique index entries - tissue_map = final.drop_duplicates(subset="cellosaurus_id").set_index("cellosaurus_id")["disease_cleaned"] - - tissue_map = _apply_manual_cell_line_corrections(tissue_map) - - final = final.assign(tissue=final["cellosaurus_id"].map(tissue_map)) - if save_tissue_mapping: - final.drop_duplicates(subset="cellosaurus_id", inplace=True) - tissue_mapping_path = os.path.join(data_path, "meta", "tissue_mapping.csv") - final.to_csv(tissue_mapping_path, index=False) - - # Add tissue column to each dataset - for ds in datasets: - path = os.path.join(data_path, ds, f"{ds}.csv") - if not os.path.exists(path): - print(f"Dataset {path} not found, skipping.") - continue - - df = pd.read_csv(path, low_memory=False) - - df["tissue"] = df["cellosaurus_id"].map(tissue_map) - - df.to_csv(path, index=False) - - -if __name__ == "__main__": - main() diff --git a/drevalpy/datasets/splits/__init__.py b/drevalpy/datasets/splits/__init__.py deleted file mode 100644 index b03c55cb2..000000000 --- a/drevalpy/datasets/splits/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Test-mode split providers for drevalpy.""" - -from .manifest import ( - MANIFEST_FILENAME, - build_split_manifest, - read_manifest_test_mode, - read_split_manifest, - validate_split_label, - write_split_manifest, -) -from .providers import ( - create_and_record_splits, - create_splits, - load_external_splitter, - run_builtin_splitter, - run_external_splitter, -) -from .types import ( - OPTIONAL_ROLES, - REQUIRED_ROLES, - TEST_MODES, - ExternalSplitCreator, - SplitCreator, - SplitError, - SplitFold, - SplitParams, - SplitResult, - make_split_params, -) -from .validation import ensure_early_stopping_splits, validate_splits - -__all__ = [ - "MANIFEST_FILENAME", - "OPTIONAL_ROLES", - "REQUIRED_ROLES", - "TEST_MODES", - "ExternalSplitCreator", - "SplitCreator", - "SplitError", - "SplitFold", - "SplitParams", - "SplitResult", - "build_split_manifest", - "create_and_record_splits", - "create_splits", - "ensure_early_stopping_splits", - "load_external_splitter", - "make_split_params", - "read_manifest_test_mode", - "read_split_manifest", - "run_builtin_splitter", - "run_external_splitter", - "validate_split_label", - "validate_splits", - "write_split_manifest", -] diff --git a/drevalpy/datasets/splits/manifest.py b/drevalpy/datasets/splits/manifest.py deleted file mode 100644 index 6e6b95074..000000000 --- a/drevalpy/datasets/splits/manifest.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Split manifest read/write helpers.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -from .types import SplitError, SplitParams - -MANIFEST_FILENAME = "split_manifest.json" - - -def validate_split_label(label: str) -> str: - """ - Ensure a result-directory label is safe for paths and report parsing. - - :param label: directory name used under the dataset results folder - :returns: the validated label unchanged - :raises SplitError: if the label is empty or contains path separators - """ - if not label or label.strip() != label: - msg = "split label must be a non-empty string without leading or trailing whitespace" - raise SplitError(msg) - if "/" in label or "\\" in label: - msg = f"split label must not contain path separators: {label!r}" - raise SplitError(msg) - return label - - -def build_split_manifest( - params: SplitParams, - split_label: str, - splits: list[dict[str, Any]], -) -> dict[str, Any]: - """ - Build the JSON-serializable split manifest payload. - - Run-level settings live at the top level; per-fold metadata stays under ``splits``. - - :param params: pipeline split settings - :param split_label: directory label used under the dataset results folder - :param splits: per-split metadata rows - :returns: manifest payload ready for JSON encoding - """ - return { - "split_label": split_label, - "test_mode": params.test_mode, - "n_cv_splits": len(splits), - "validation_ratio": params.validation_ratio, - "random_state": params.random_state, - "split_early_stopping": params.split_early_stopping, - "splits": splits, - } - - -def write_split_manifest( - path: Path | str, - *, - params: SplitParams, - split_label: str, - splits: list[dict[str, Any]], -) -> None: - """ - Write split metadata next to persisted split files. - - :param path: directory where split CSV files are stored - :param params: pipeline split settings recorded in the manifest - :param split_label: directory label used under the dataset results folder - :param splits: per-split metadata collected during validation - """ - out = Path(path) - out.mkdir(parents=True, exist_ok=True) - payload = build_split_manifest(params, split_label, splits) - manifest_path = out / MANIFEST_FILENAME - manifest_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def read_split_manifest(manifest_path: Path | str) -> dict[str, Any] | None: - """ - Read a split manifest file. - - :param manifest_path: path to ``split_manifest.json`` - :returns: parsed manifest payload, or ``None`` when absent or invalid - """ - path = Path(manifest_path) - if not path.is_file(): - return None - payload = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(payload, dict): - return None - return payload - - -def read_manifest_test_mode(manifest_path: Path | str) -> str | None: - """ - Read the semantic ``test_mode`` from a split manifest file. - - :param manifest_path: path to ``split_manifest.json`` - :returns: top-level ``test_mode`` value, or ``None`` when absent - """ - payload = read_split_manifest(manifest_path) - if payload is None: - return None - value = payload.get("test_mode") - if isinstance(value, str) and value.strip(): - return value.strip() - return None diff --git a/drevalpy/datasets/splits/providers.py b/drevalpy/datasets/splits/providers.py deleted file mode 100644 index 85cc8b6b4..000000000 --- a/drevalpy/datasets/splits/providers.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Built-in and external split providers.""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path -from typing import Any - -from ...pipeline_function import pipeline_function -from ..dataset import DrugResponseDataset -from .manifest import write_split_manifest -from .types import ( - TEST_MODES, - ExternalSplitCreator, - SplitError, - SplitParams, - SplitResult, - make_split_params, -) -from .validation import ensure_early_stopping_splits, validate_splits - - -def load_external_splitter(path: Path | str) -> ExternalSplitCreator: - """ - Load a module-level ``create_splits`` function from a Python script. - - :param path: path to a Python file defining ``create_splits(response_data, params)`` - :returns: the loaded splitter callable - :raises FileNotFoundError: if the script path does not exist - :raises ImportError: if the script cannot be imported - :raises AttributeError: if ``create_splits`` is missing - :raises TypeError: if ``create_splits`` is not callable - """ - script_path = Path(path).expanduser().resolve() - if not script_path.is_file(): - msg = f"External split script not found: {script_path}" - raise FileNotFoundError(msg) - - spec = importlib.util.spec_from_file_location("_drevalpy_external_split_", script_path) - if spec is None or spec.loader is None: - msg = f"Could not load external split script: {script_path}" - raise ImportError(msg) - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - fn = getattr(module, "create_splits", None) - if fn is None: - msg = f"{script_path} must define a module-level function create_splits(response_data, params)" - raise AttributeError(msg) - if not callable(fn): - msg = "create_splits must be callable" - raise TypeError(msg) - return fn # type: ignore[return-value] - - -def _raw_builtin_splits(response_data: DrugResponseDataset, params: SplitParams) -> list[dict[str, Any]]: - """ - Generate raw fold dictionaries from built-in splitting logic. - - :param response_data: full response dataset to split - :param params: pipeline split settings - :returns: raw split dicts before shared validation - :raises SplitError: if ``test_mode`` is unknown - """ - if params.test_mode not in TEST_MODES: - msg = f"Unknown test_mode {params.test_mode!r}; choose from {sorted(TEST_MODES)}" - raise SplitError(msg) - - working = response_data.copy() - return working.split_dataset( - n_cv_splits=params.n_cv_splits, - mode=params.test_mode, - split_validation=True, - validation_ratio=params.validation_ratio, - random_state=params.random_state, - split_early_stopping=False, - ) - - -def _finalize_splits(raw_splits: list[dict[str, Any]], params: SplitParams) -> SplitResult: - """ - Normalize and validate split output from any provider. - - :param raw_splits: fold dictionaries returned by a provider - :param params: pipeline split settings - :returns: validated splits and per-split metadata rows - """ - validated, metadata_rows = validate_splits(raw_splits, params.test_mode) - if params.split_early_stopping: - ensure_early_stopping_splits(validated, params.test_mode) - return validated, metadata_rows - - -@pipeline_function -def run_builtin_splitter(response_data: DrugResponseDataset, params: SplitParams) -> SplitResult: - """ - Create built-in CV splits using the shared validation path. - - :param response_data: full response dataset to split - :param params: pipeline split settings - :returns: validated splits and per-split metadata rows - """ - validated = _raw_builtin_splits(response_data, params) - metadata_rows = [{"split_index": split_index} for split_index in range(len(validated))] - if params.split_early_stopping: - ensure_early_stopping_splits(validated, params.test_mode) - return validated, metadata_rows - - -@pipeline_function -def run_external_splitter( - response_data: DrugResponseDataset, - splitter: ExternalSplitCreator | str | Path, - params: SplitParams, -) -> SplitResult: - """ - Execute an external splitter and validate its output. - - :param response_data: full response dataset passed to the splitter - :param splitter: callable or path to a script defining ``create_splits`` - :param params: pipeline split settings - :returns: validated splits and per-split metadata rows - """ - if isinstance(splitter, (str, Path)): - splitter = load_external_splitter(splitter) - raw_splits = splitter(response_data.copy(), params) - return _finalize_splits(raw_splits, params) - - -@pipeline_function -def create_splits( - response_data: DrugResponseDataset, - *, - test_mode: str | None = None, - external_splitter: ExternalSplitCreator | str | Path | None = None, - n_cv_splits: int = 5, - validation_ratio: float = 0.1, - random_state: int = 42, - split_early_stopping: bool = True, - params: SplitParams | None = None, -) -> SplitResult: - """ - Create CV splits for a required ``test_mode`` via built-in or external providers. - - :param response_data: full response dataset passed to the splitter - :param test_mode: one of ``LPO``, ``LCO``, ``LDO``, or ``LTO``; required when ``params`` is omitted - :param external_splitter: optional callable or script path defining ``create_splits`` - :param n_cv_splits: requested number of CV splits from the pipeline - :param validation_ratio: validation fraction from the pipeline - :param random_state: random seed from the pipeline - :param split_early_stopping: whether to derive early-stopping roles when absent - :param params: optional pre-built split settings; overrides individual keyword args - :returns: validated splits and per-split metadata rows - :raises ValueError: if neither ``params`` nor ``test_mode`` is provided - """ - if params is None and test_mode is None: - msg = "Either params or test_mode must be provided" - raise ValueError(msg) - split_params = params or make_split_params( - test_mode=test_mode, # type: ignore[arg-type] - n_cv_splits=n_cv_splits, - validation_ratio=validation_ratio, - random_state=random_state, - split_early_stopping=split_early_stopping, - ) - if external_splitter is not None: - return run_external_splitter(response_data, external_splitter, split_params) - return run_builtin_splitter(response_data, split_params) - - -@pipeline_function -def create_and_record_splits( - response_data: DrugResponseDataset, - *, - split_path: Path | str, - split_label: str, - external_splitter: ExternalSplitCreator | str | Path | None = None, - test_mode: str | None = None, - n_cv_splits: int = 5, - validation_ratio: float = 0.1, - random_state: int = 42, - split_early_stopping: bool = True, - params: SplitParams | None = None, -) -> SplitResult: - """ - Create CV splits, attach them to the dataset, and write the split manifest. - - :param response_data: full response dataset to split - :param split_path: directory where the split manifest is written - :param split_label: result-directory label recorded in the manifest - :param external_splitter: optional callable or script path defining ``create_splits`` - :param test_mode: one of ``LPO``, ``LCO``, ``LDO``, or ``LTO``; required when ``params`` is omitted - :param n_cv_splits: requested number of CV splits from the pipeline - :param validation_ratio: validation fraction from the pipeline - :param random_state: random seed from the pipeline - :param split_early_stopping: whether to derive early-stopping roles when absent - :param params: optional pre-built split settings; overrides individual keyword args - :returns: validated splits and per-split metadata rows - """ - response_data.remove_nan_responses() - split_params = params or make_split_params( - test_mode=test_mode, # type: ignore[arg-type] - n_cv_splits=n_cv_splits, - validation_ratio=validation_ratio, - random_state=random_state, - split_early_stopping=split_early_stopping, - ) - cv_splits, metadata_rows = create_splits( - response_data, - params=split_params, - external_splitter=external_splitter, - ) - response_data._cv_splits = cv_splits - write_split_manifest( - split_path, - params=split_params, - split_label=split_label, - splits=metadata_rows, - ) - return cv_splits, metadata_rows diff --git a/drevalpy/datasets/splits/types.py b/drevalpy/datasets/splits/types.py deleted file mode 100644 index e140ece6d..000000000 --- a/drevalpy/datasets/splits/types.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Shared types for test-mode split providers.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from ..dataset import DrugResponseDataset - -TEST_MODES: frozenset[str] = frozenset({"LPO", "LCO", "LDO", "LTO"}) -REQUIRED_ROLES: tuple[str, ...] = ("train", "validation", "test") -OPTIONAL_ROLES: tuple[str, ...] = ("validation_es", "early_stopping") - -SplitFold = dict[str, DrugResponseDataset] -SplitResult = tuple[list[SplitFold], list[dict[str, Any]]] - - -class SplitError(ValueError): - """Raised when split settings or provider output are invalid.""" - - -@dataclass(frozen=True) -class SplitParams: - """Pipeline split settings passed to built-in and external split providers.""" - - test_mode: str - n_cv_splits: int - validation_ratio: float - random_state: int - split_early_stopping: bool - - -ExternalSplitCreator = Callable[[DrugResponseDataset, SplitParams], list[dict[str, Any]]] -SplitCreator = ExternalSplitCreator - - -def make_split_params( - *, - test_mode: str, - n_cv_splits: int = 5, - validation_ratio: float = 0.1, - random_state: int = 42, - split_early_stopping: bool = True, -) -> SplitParams: - """ - Build ``SplitParams`` from pipeline keyword arguments. - - :param test_mode: one of ``LPO``, ``LCO``, ``LDO``, or ``LTO`` - :param n_cv_splits: requested number of CV splits - :param validation_ratio: validation fraction of the training set - :param random_state: random seed for splitting - :param split_early_stopping: whether to derive early-stopping roles - :returns: frozen split settings for providers - """ - return SplitParams( - test_mode=test_mode, - n_cv_splits=n_cv_splits, - validation_ratio=validation_ratio, - random_state=random_state, - split_early_stopping=split_early_stopping, - ) diff --git a/drevalpy/datasets/splits/validation.py b/drevalpy/datasets/splits/validation.py deleted file mode 100644 index aaff9d84f..000000000 --- a/drevalpy/datasets/splits/validation.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Validation helpers for built-in and external split providers.""" - -from __future__ import annotations - -from typing import Any - -import numpy as np - -from ...pipeline_function import pipeline_function -from ..dataset import DrugResponseDataset, split_early_stopping_data -from .types import OPTIONAL_ROLES, REQUIRED_ROLES, TEST_MODES, SplitError - - -def _row_keys(dataset: DrugResponseDataset) -> set[tuple[str, str, float]]: - """ - Build exact row identifiers for overlap checks. - - :param dataset: response dataset whose rows are keyed - :returns: set of ``(cell_line_id, drug_id, response)`` tuples - """ - return { - (str(cl), str(drug), float(resp)) - for cl, drug, resp in zip(dataset.cell_line_ids, dataset.drug_ids, dataset.response, strict=True) - } - - -def _group_ids(dataset: DrugResponseDataset, test_mode: str) -> set[Any]: - """ - Extract leave-out group identifiers for a ``test_mode``. - - :param dataset: response dataset whose groups are extracted - :param test_mode: one of ``LPO``, ``LCO``, ``LDO``, or ``LTO`` - :returns: cell-line, drug, tissue, or pair identifiers depending on ``test_mode`` - :raises SplitError: if ``test_mode`` is unknown or LTO tissue is missing - """ - if test_mode == "LCO": - return set(map(str, dataset.cell_line_ids)) - if test_mode == "LDO": - return set(map(str, dataset.drug_ids)) - if test_mode == "LTO": - if dataset.tissue is None: - msg = "LTO validation requires tissue annotations on all role datasets" - raise SplitError(msg) - return set(map(str, dataset.tissue)) - if test_mode == "LPO": - return {(str(cl), str(drug)) for cl, drug in zip(dataset.cell_line_ids, dataset.drug_ids, strict=True)} - msg = f"Unknown test_mode {test_mode!r}; choose from {sorted(TEST_MODES)}" - raise SplitError(msg) - - -def _assert_disjoint_groups( - roles: dict[str, DrugResponseDataset], - test_mode: str, - *, - split_index: int, -) -> None: - """ - Ensure train, validation, and test do not share leave-out groups. - - :param roles: split role datasets to check - :param test_mode: one of ``LPO``, ``LCO``, ``LDO``, or ``LTO`` - :param split_index: index of the split within the returned list - :raises SplitError: if the same group appears in multiple roles - """ - seen: dict[Any, str] = {} - for role in REQUIRED_ROLES: - for group in _group_ids(roles[role], test_mode): - if group in seen: - msg = ( - f"Split {split_index}: {test_mode} leakage — " f"{seen[group]!r} and {role!r} share group {group!r}" - ) - raise SplitError(msg) - seen[group] = role - - -def _assert_disjoint_rows( - roles: dict[str, DrugResponseDataset], - *, - split_index: int, -) -> None: - """ - Ensure train, validation, and test do not share exact response rows. - - :param roles: split role datasets to check - :param split_index: index of the split within the returned list - :raises SplitError: if the same row appears in multiple roles - """ - seen: dict[tuple[str, str, float], str] = {} - for role in REQUIRED_ROLES: - for row in _row_keys(roles[role]): - if row in seen: - msg = f"Split {split_index}: exact row overlap between " f"{seen[row]!r} and {role!r}: {row[:2]}" - raise SplitError(msg) - seen[row] = role - - -def _normalize_split_dict( - raw: dict[str, Any], *, split_index: int -) -> tuple[dict[str, DrugResponseDataset], dict[str, Any]]: - """ - Parse and validate one raw split dict from a split provider. - - :param raw: split dict returned by a provider - :param split_index: index of the split within the returned list - :returns: validated role datasets and optional metadata dict - :raises SplitError: if roles are missing, empty, or have wrong types - """ - metadata = raw.get("metadata") - if metadata is not None and not isinstance(metadata, dict): - msg = f"Split {split_index}: metadata must be a dict when provided" - raise SplitError(msg) - - roles: dict[str, DrugResponseDataset] = {} - for role in REQUIRED_ROLES + OPTIONAL_ROLES: - if role not in raw: - continue - value = raw[role] - if not isinstance(value, DrugResponseDataset): - msg = f"Split {split_index}: {role!r} must be a DrugResponseDataset, got {type(value).__name__}" - raise SplitError(msg) - roles[role] = value - - for role in REQUIRED_ROLES: - if role not in roles: - msg = f"Split {split_index}: missing required role {role!r}" - raise SplitError(msg) - if len(roles[role]) == 0: - msg = f"Split {split_index}: role {role!r} must not be empty" - raise SplitError(msg) - - dataset_names = {roles[role].dataset_name for role in REQUIRED_ROLES} - if len(dataset_names) != 1: - msg = f"Split {split_index}: inconsistent dataset_name across roles: {dataset_names}" - raise SplitError(msg) - - return roles, metadata if isinstance(metadata, dict) else {} - - -@pipeline_function -def validate_splits( - splits: list[dict[str, Any]], - test_mode: str, -) -> tuple[list[dict[str, DrugResponseDataset]], list[dict[str, Any]]]: - """ - Validate split output according to ``test_mode`` semantics. - - :param splits: raw split dicts returned by a built-in or external provider - :param test_mode: one of ``LPO``, ``LCO``, ``LDO``, or ``LTO`` - :returns: validated role datasets and optional per-split metadata rows - :raises SplitError: if splits are missing roles or leak across groups/rows - """ - if test_mode not in TEST_MODES: - msg = f"Unknown test_mode {test_mode!r}; choose from {sorted(TEST_MODES)}" - raise SplitError(msg) - if not splits: - msg = "Split provider returned no splits" - raise SplitError(msg) - - validated: list[dict[str, DrugResponseDataset]] = [] - metadata_rows: list[dict[str, Any]] = [] - for split_index, raw in enumerate(splits): - if not isinstance(raw, dict): - msg = f"Split {split_index}: expected dict, got {type(raw).__name__}" - raise SplitError(msg) - roles, metadata = _normalize_split_dict(raw, split_index=split_index) - _assert_disjoint_rows(roles, split_index=split_index) - _assert_disjoint_groups(roles, test_mode, split_index=split_index) - validated.append(roles) - metadata_rows.append({"split_index": split_index, **metadata}) - - return validated, metadata_rows - - -@pipeline_function -def ensure_early_stopping_splits( - splits: list[dict[str, DrugResponseDataset]], - test_mode: str, -) -> None: - """ - Fill ``validation_es`` and ``early_stopping`` when absent. - - :param splits: validated split dicts to mutate in place - :param test_mode: one of ``LPO``, ``LCO``, ``LDO``, or ``LTO`` - """ - for split in splits: - if "validation_es" in split and "early_stopping" in split: - continue - validation = split["validation"] - n_groups = len(_group_ids(validation, test_mode)) - if n_groups < 2: - split["validation_es"] = validation.copy() - split["early_stopping"] = DrugResponseDataset( - response=np.array([]), - cell_line_ids=np.array([]), - drug_ids=np.array([]), - tissues=np.array([]) if validation.tissue is not None else None, - dataset_name=validation.dataset_name, - ) - continue - validation_es, early_stopping = split_early_stopping_data(validation, test_mode=test_mode) - split["validation_es"] = validation_es - split["early_stopping"] = early_stopping diff --git a/drevalpy/datasets/utils.py b/drevalpy/datasets/utils.py deleted file mode 100644 index 17c871d93..000000000 --- a/drevalpy/datasets/utils.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Utility functions for datasets.""" - -import os -import zipfile -from pathlib import Path -from typing import Any - -import networkx as nx -import numpy as np -import requests -from requests import Response - -# DRUG_IDENTIFIER, CELL_LINE_IDENTIFIER, and TISSUE_IDENTIFIER are used in pipeline -DRUG_IDENTIFIER = "pubchem_id" -CELL_LINE_IDENTIFIER = "cell_line_name" -TISSUE_IDENTIFIER = "tissue" -ALLOWED_MEASURES = ["LN_IC50", "EC50", "IC50", "pEC50", "AUC", "response"] -ALLOWED_MEASURES.extend([f"{m}_curvecurator" for m in ALLOWED_MEASURES]) - - -def unzip_data(path_to_zip: Path, response: Response, data_path: str): - """ - Unzips the downloaded data. - - :param path_to_zip: Path to the zip file to be unzipped. - :param response: HTML response containing response.content - :param data_path: Where the unzipped directory should be stored - """ - with open(path_to_zip, "wb") as f: - f.write(response.content) - - with zipfile.ZipFile(path_to_zip, "r") as z: - for member in z.infolist(): - if not member.filename.startswith("__MACOSX/"): - z.extract(member, os.path.join(data_path)) - path_to_zip.unlink() # Remove zip file after extraction - - -def download_from_url(dataset_name: str, file_url: str) -> Response: - """ - Download a file from a given URL. - - :param dataset_name: how the dataset is called - :param file_url: exact URL to the zip file - :return: HTML response containing response.content - :raises HTTPError: if the download fails - """ - print(f"Downloading {dataset_name} from {file_url}...") - response = requests.get(file_url, timeout=120) - if response.status_code != 200: - raise requests.exceptions.HTTPError(f"Error downloading file: " f"{response.status_code}") - return response - - -def download_dataset( - dataset_name: str, - data_path: str = "data", - redownload: bool = False, -): - """ - Download the latets dataset from Zenodo. - - :param dataset_name: dataset name, from "GDSC1", "GDSC2", "CCLE", "CTRPv1", "CTRPv2", "TOYv1", "TOYv2", "meta" - :param data_path: where to save the data - :param redownload: whether to redownload the data - :raises HTTPError: if the download fails - """ - file_name = f"{dataset_name}.zip" - file_path = Path(data_path) / file_name - extracted_folder_path = file_path.with_suffix("") - timeout = 120 - # Check if the extracted data exists and skip download if not redownloading - if extracted_folder_path.exists() and not redownload: - print(f"{dataset_name} is already extracted, skipping download.") - else: - url = "https://zenodo.org/doi/10.5281/zenodo.12633909" - # Fetch the latest record - response = requests.get(url, timeout=timeout) - if response.status_code != 200: - raise requests.exceptions.HTTPError(f"Error fetching record: {response.status_code}") - latest_url = response.links["linkset"]["url"] - response = requests.get(latest_url, timeout=timeout) - if response.status_code != 200: - raise requests.exceptions.HTTPError(f"Error fetching record: {response.status_code}") - data = response.json() - - # Ensure the save path exists - extracted_folder_path.parent.mkdir(exist_ok=True, parents=True) - - # Download each file - name_to_url = {file["key"]: file["links"]["self"] for file in data["files"]} - file_url = name_to_url[file_name] - - response = download_from_url(dataset_name=dataset_name, file_url=file_url) - unzip_data(path_to_zip=file_path, response=response, data_path=data_path) - - print(f"{dataset_name} data downloaded and extracted to {data_path}") - - -def randomize_graph(original_graph: nx.Graph) -> nx.Graph: - """ - Randomizes the graph by shuffling the edges while preserving the degree sequence. - - :param original_graph: The original graph - :return: Randomized graph with the same degree sequence and node attributes - """ - # Get the degree sequence from the original graph - degree_sequence = [degree for node, degree in original_graph.degree()] - - # Generate a new graph with the expected degree sequence - new_graph = nx.expected_degree_graph(degree_sequence, seed=1234) - - # Remap nodes to the original labels - mapping = dict(zip(new_graph.nodes(), original_graph.nodes(), strict=True)) - new_graph = nx.relabel_nodes(new_graph, mapping) - - # Copy node attributes from the original graph to the new graph - for node, data in original_graph.nodes(data=True): - new_graph.nodes[node].update(data) - - # Get the edge attributes from the original graph - edge_attributes = list(original_graph.edges(data=True)) - - # Assign random edge attributes to the new edges - for edge in new_graph.edges(): - random_idx = int(np.random.randint(len(edge_attributes))) - _, _, attr = edge_attributes[random_idx] - new_graph[edge[0]][edge[1]].update(attr) - - return new_graph - - -def permute_features( - features: dict[str, dict[str, Any]], - identifiers: np.ndarray, - views_to_permute: list[str], - all_views: list[str], -) -> dict: - """ - Permute the specified views for each entity (= cell line or drug). - - E.g. each cell line gets the feature vector/graph/image... of another cell line. - Drawn without replacement. - - :param features: dictionary of features - :param identifiers: array of identifiers - :param views_to_permute: list of views to permute - :param all_views: list of all views - :return: permuted features - """ - return { - entity: { - view: (features[entity][view] if view not in views_to_permute else features[other_entity][view]) - for view in all_views - } - for entity, other_entity in zip(identifiers, np.random.permutation(identifiers), strict=True) - } diff --git a/drevalpy/evaluation.py b/drevalpy/evaluation.py index 8d239e5eb..891702a77 100644 --- a/drevalpy/evaluation.py +++ b/drevalpy/evaluation.py @@ -1,22 +1,32 @@ -"""Functions for evaluating model performance.""" +"""Functions for evaluating model performance. + +``scipy.stats`` and ``sklearn.metrics`` are imported inside the metric functions +rather than at module scope. This module is on the critical path of +``import drevalpy`` - :mod:`drevalpy.types.results.experiment` needs +:data:`AVAILABLE_METRICS` - and those two libraries cost ~0.3s between them, +which every CLI invocation would otherwise pay before computing a single metric. +See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING import numpy as np -from scipy.stats import kendalltau, pearsonr, spearmanr -from sklearn import metrics -from .datasets.dataset import DrugResponseDataset -from .pipeline_function import pipeline_function +if TYPE_CHECKING: + import pandas as pd warning_shown = False constant_prediction_warning_shown = False def _check_constant_prediction(y_pred: np.ndarray) -> bool: - """ - Check if predictions are constant. + """Check if predictions are constant. - :param y_pred: predictions - :return: bool whether predictions are constant + :param y_pred: Predicted values. + + :returns: Whether all predictions are equal within tolerance. """ tol = 1e-6 # no variation in predictions @@ -24,11 +34,11 @@ def _check_constant_prediction(y_pred: np.ndarray) -> bool: def _check_constant_target_or_small_sample(y_true: np.ndarray) -> bool: - """ - Check if target is constant or sample size is too small. + """Check if target is constant or sample size is too small. + + :param y_true: Observed response values. - :param y_true: true response - :returns: bool whether target is constant or sample size is too small + :returns: Whether the sample is too small or the target has no variation. """ tol = 1e-6 # Check for insufficient sample size or no variation in target @@ -36,13 +46,14 @@ def _check_constant_target_or_small_sample(y_true: np.ndarray) -> bool: def pearson(y_pred: np.ndarray, y_true: np.ndarray) -> float: - """ - Computes the pearson correlation between predictions and response. + """Compute Pearson correlation between predictions and response. + + :param y_pred: Predicted response values. + :param y_true: Observed response values. + + :returns: Pearson correlation, or ``0.0`` / ``nan`` for degenerate inputs. - :param y_pred: predictions - :param y_true: response - :return: pearson correlation float - :raises AssertionError: if predictions and response do not have the same length + :raises AssertionError: If ``y_pred`` and ``y_true`` differ in length. """ if len(y_pred) != len(y_true): raise AssertionError("predictions, response must have the same length") @@ -52,17 +63,20 @@ def pearson(y_pred: np.ndarray, y_true: np.ndarray) -> float: if _check_constant_target_or_small_sample(y_true): return np.nan + from scipy.stats import pearsonr + return pearsonr(y_pred, y_true)[0] def spearman(y_pred: np.ndarray, y_true: np.ndarray) -> float: - """ - Computes the spearman correlation between predictions and response. + """Compute Spearman correlation between predictions and response. + + :param y_pred: Predicted response values. + :param y_true: Observed response values. - :param y_pred: predictions - :param y_true: response - :return: spearman correlation float - :raises AssertionError: if predictions and response do not have the same length + :returns: Spearman correlation, or ``0.0`` / ``nan`` for degenerate inputs. + + :raises AssertionError: If ``y_pred`` and ``y_true`` differ in length. """ # we can use scipy.stats.spearmanr if len(y_pred) != len(y_true): @@ -72,17 +86,20 @@ def spearman(y_pred: np.ndarray, y_true: np.ndarray) -> float: if _check_constant_target_or_small_sample(y_true): return np.nan + from scipy.stats import spearmanr + return spearmanr(y_pred, y_true)[0] def kendall(y_pred: np.ndarray, y_true: np.ndarray) -> float: - """ - Computes the kendall tau correlation between predictions and response. + """Compute Kendall tau correlation between predictions and response. + + :param y_pred: Predicted response values. + :param y_true: Observed response values. - :param y_pred: predictions - :param y_true: response - :return: kendall tau correlation float - :raises AssertionError: if predictions and response do not have the same length + :returns: Kendall tau, or ``0.0`` / ``nan`` for degenerate inputs. + + :raises AssertionError: If ``y_pred`` and ``y_true`` differ in length. """ # we can use scipy.stats.spearmanr if len(y_pred) != len(y_true): @@ -92,14 +109,64 @@ def kendall(y_pred: np.ndarray, y_true: np.ndarray) -> float: if _check_constant_target_or_small_sample(y_true): return np.nan + from scipy.stats import kendalltau + return kendalltau(y_pred, y_true)[0] +def _mean_squared_error(y_pred: np.ndarray, y_true: np.ndarray) -> float: + """Mean squared error, deferring the ``sklearn`` import to call time. + + :param y_pred: Predicted response values. + :param y_true: Observed response values. + :returns: Mean squared error. + """ + from sklearn.metrics import mean_squared_error + + return float(mean_squared_error(y_true=y_true, y_pred=y_pred)) + + +def _root_mean_squared_error(y_pred: np.ndarray, y_true: np.ndarray) -> float: + """Root mean squared error, deferring the ``sklearn`` import to call time. + + :param y_pred: Predicted response values. + :param y_true: Observed response values. + :returns: Root mean squared error. + """ + from sklearn.metrics import root_mean_squared_error + + return float(root_mean_squared_error(y_true=y_true, y_pred=y_pred)) + + +def _mean_absolute_error(y_pred: np.ndarray, y_true: np.ndarray) -> float: + """Mean absolute error, deferring the ``sklearn`` import to call time. + + :param y_pred: Predicted response values. + :param y_true: Observed response values. + :returns: Mean absolute error. + """ + from sklearn.metrics import mean_absolute_error + + return float(mean_absolute_error(y_true=y_true, y_pred=y_pred)) + + +def _r2_score(y_pred: np.ndarray, y_true: np.ndarray) -> float: + """Coefficient of determination, deferring the ``sklearn`` import to call time. + + :param y_pred: Predicted response values. + :param y_true: Observed response values. + :returns: R^2 score. + """ + from sklearn.metrics import r2_score + + return float(r2_score(y_true=y_true, y_pred=y_pred)) + + AVAILABLE_METRICS = { - "MSE": metrics.mean_squared_error, - "RMSE": metrics.root_mean_squared_error, - "MAE": metrics.mean_absolute_error, - "R^2": metrics.r2_score, + "MSE": _mean_squared_error, + "RMSE": _root_mean_squared_error, + "MAE": _mean_absolute_error, + "R^2": _r2_score, "Pearson": pearson, "Spearman": spearman, "Kendall": kendall, @@ -110,12 +177,13 @@ def kendall(y_pred: np.ndarray, y_true: np.ndarray) -> float: def get_mode(metric: str): - """ - Get whether the optimum value of the metric is the minimum or maximum. + """Return whether lower or higher metric values are better. + + :param metric: Metric name (for example ``"RMSE"`` or ``"Pearson"``). - :param metric: metric, e.g., RMSE - :returns: whether the optimum value of the metric is the minimum or maximum - :raises ValueError: if the metric is not in MINIMIZATION_METRICS or MAXIMIZATION_METRICS + :returns: ``"min"`` for error metrics or ``"max"`` for correlation metrics. + + :raises ValueError: If ``metric`` is not a known minimization or maximization metric. """ if metric in MINIMIZATION_METRICS: mode = "min" @@ -123,46 +191,72 @@ def get_mode(metric: str): mode = "max" else: raise ValueError( - f"Invalid metric: {metric}. Need to add metric to MINIMIZATION_METRICS or " f"MAXIMIZATION_METRICS?" + f"Invalid metric: {metric}. Need to add metric to MINIMIZATION_METRICS or MAXIMIZATION_METRICS?" ) return mode -@pipeline_function -def evaluate(dataset: DrugResponseDataset, metric: list[str] | str): - """ - Evaluates the model on the given dataset. +def _should_return_nan_global(response: np.ndarray, predictions: np.ndarray) -> bool: + return bool(len(response) < 2 or np.all(np.isnan(response)) or np.all(np.isnan(predictions))) + + +def _masked_metric_inputs(predictions: np.ndarray, response: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None: + if not np.any(np.isnan(predictions)): + return predictions, response + if np.all(np.isnan(predictions)): + return None + mask = ~np.isnan(predictions) + return predictions[mask], response[mask] + - :param dataset: dataset to evaluate on - :param metric: evaluation metric(s) (one or a list of "MSE", "RMSE", "MAE", "R^2", "Pearson", - "spearman", "kendall") - :return: evaluation metric - :raises AssertionError: if metric is not in AVAILABLE +def _compute_metric_value(metric_name: str, predictions: np.ndarray, response: np.ndarray) -> float: + if _should_return_nan_global(response, predictions): + return float(np.nan) + masked = _masked_metric_inputs(predictions, response) + if masked is None: + return float(np.nan) + y_pred, y_true = masked + return float(AVAILABLE_METRICS[metric_name](y_pred=y_pred, y_true=y_true)) + + +def evaluate( + predictions_or_dataset=None, + response: np.ndarray | pd.Series | None = None, + metric: list[str] | str = "Pearson", + *, + predictions: np.ndarray | pd.Series | None = None, +) -> dict[str, float]: + """Compute evaluation metrics from predictions and observed response. + + Accepts either (predictions_array, response_array) or a single + object that carries both .predictions and .response. + + :param predictions_or_dataset: Predicted values or a dataset object. + :param response: Observed (true) response values (omit if dataset passed). + :param metric: One metric name or a list of names from ``AVAILABLE_METRICS``. + :param predictions: Keyword-only alias for predictions_or_dataset. + :returns: Mapping from metric name to scalar score. + :raises AssertionError: If predictions are missing or a metric name is unknown. """ if isinstance(metric, str): metric = [metric] - predictions = dataset.predictions - if predictions is None: - raise AssertionError("No predictions found in the dataset") - response = dataset.response + + # Handle keyword-only predictions= argument + if predictions is not None: + preds_arr = np.asarray(predictions) + response_arr = np.asarray(response) + elif response is None and predictions_or_dataset is not None: + dataset = predictions_or_dataset + preds_arr = np.asarray(dataset.predictions) + response_arr = np.asarray(dataset.response) + else: + preds_arr = np.asarray(predictions_or_dataset) + response_arr = np.asarray(response) results = {} for m in metric: if m not in AVAILABLE_METRICS: raise AssertionError(f"invalid metric {m}. Available: {list(AVAILABLE_METRICS.keys())}") - if len(response) < 2 or np.all(np.isnan(response)) or np.all(np.isnan(predictions)): - results[m] = float(np.nan) - else: - # check whether the predictions contain NaNs - if np.any(np.isnan(predictions)): - # if there are only NaNs in the predictions, the metric is NaN - if np.all(np.isnan(predictions)): - results[m] = float(np.nan) - else: - # remove the rows with NaNs in the predictions and response - mask = ~np.isnan(predictions) - results[m] = float(AVAILABLE_METRICS[m](y_pred=predictions[mask], y_true=response[mask])) - else: - results[m] = float(AVAILABLE_METRICS[m](y_pred=predictions, y_true=response)) + results[m] = _compute_metric_value(m, preds_arr, response_arr) return results diff --git a/drevalpy/experiment.py b/drevalpy/experiment.py deleted file mode 100644 index c376b5f62..000000000 --- a/drevalpy/experiment.py +++ /dev/null @@ -1,1688 +0,0 @@ -"""Main module for running the drug response prediction experiment.""" - -import importlib -import json -import os -import shutil -import tempfile -import warnings -from pathlib import Path -from typing import Any - -import numpy as np -import pandas as pd -import torch -from sklearn.base import TransformerMixin - -try: - import wandb -except ImportError: - wandb = None # type: ignore[assignment] - -from .datasets.dataset import DrugResponseDataset, FeatureDataset, split_early_stopping_data -from .datasets.splits import ExternalSplitCreator, create_and_record_splits -from .evaluation import get_mode -from .models import MODEL_FACTORY, MULTI_DRUG_MODEL_FACTORY, SINGLE_DRUG_MODEL_FACTORY -from .models.drp_model import DRPModel -from .pipeline_function import pipeline_function - - -def seed_everything(seed: int = 42) -> None: - """ - Seed python ``random``, numpy, torch (CPU + CUDA), and ``PYTHONHASHSEED``. - - Call once at the top of a run. The dataset/model code uses local - ``np.random.default_rng`` instances for its own randomness, so this exists to lock - down everything else (torch op order, sklearn fallbacks, library-internal RNG, - hash randomization). - - :param seed: base seed value - """ - import random - - os.environ["PYTHONHASHSEED"] = str(seed) - random.seed(seed) - np.random.seed(seed) - torch.manual_seed(seed) - if torch.cuda.is_available(): - torch.cuda.manual_seed_all(seed) - - -if importlib.util.find_spec("ray"): - import ray -else: - ray = None # type: ignore[assignment] - - -@pipeline_function -def prepare_response_splits( - response_data: DrugResponseDataset, - *, - split_path: str, - result_path: str, - split_label: str, - test_mode: str, - n_cv_splits: int, - overwrite: bool, - result_folder_exists: bool, - custom_splitter: ExternalSplitCreator | str | Path | None = None, - validation_ratio: float = 0.1, - random_state: int = 42, - split_early_stopping: bool = True, -) -> int: - """ - Create, load, or reuse CV splits for an experiment run. - - :param response_data: dataset whose splits are created or loaded - :param split_path: directory for persisted split CSV files - :param result_path: experiment result directory - :param split_label: directory label under the dataset results folder - :param test_mode: built-in split mode or validation mode for custom splits - :param n_cv_splits: number of CV splits for built-in splitting - :param overwrite: whether to replace existing results and splits - :param result_folder_exists: whether ``result_path`` already exists - :param custom_splitter: optional script path or callable for custom splits - :param validation_ratio: validation fraction for built-in splitting - :param random_state: random seed for built-in splitting - :param split_early_stopping: whether to derive early-stopping roles - :returns: number of splits available after preparation - """ - if result_folder_exists and overwrite: - print(f"Overwriting existing results at {result_path}") - shutil.rmtree(result_path) - - if result_folder_exists and os.path.exists(split_path) and not overwrite: - print(f"Loading existing cv splits from {split_path}") - response_data.load_splits(path=split_path) - else: - print(f"Creating cv splits at {split_path}") - os.makedirs(result_path, exist_ok=True) - create_and_record_splits( - response_data, - split_path=split_path, - split_label=split_label, - external_splitter=custom_splitter, - test_mode=test_mode, - n_cv_splits=n_cv_splits, - validation_ratio=validation_ratio, - random_state=random_state, - split_early_stopping=split_early_stopping, - ) - response_data.save_splits(path=split_path) - - return len(response_data.cv_splits) - - -def drug_response_experiment( - models: list[type[DRPModel]], - response_data: DrugResponseDataset, - baselines: list[type[DRPModel]] | None = None, - response_transformation: TransformerMixin | None = None, - run_id: str = "", - test_mode: str = "LPO", - hpam_optimization_metric: str = "RMSE", - n_cv_splits: int = 5, - multiprocessing: bool = False, - randomization_mode: list[str] | None = None, - randomization_type: str = "permutation", - cross_study_datasets: list[DrugResponseDataset] | None = None, - n_trials_robustness: int = 0, - path_out: str = "results/", - overwrite: bool = False, - path_data: str = "data", - model_checkpoint_dir: str = "TEMPORARY", - hyperparameter_tuning=True, - final_model_on_full_data: bool = False, - wandb_project: str | None = None, - custom_splitter: ExternalSplitCreator | str | Path | None = None, - custom_split_name: str | None = None, -) -> None: - """ - Run the drug response prediction experiment. Save results to disc. - - :param models: list of model classes to compare - :param baselines: list of baseline models. No randomization or robustness tests are run for the baseline models. - :param response_data: drug response dataset - :param response_transformation: normalizer to use for the response data - :param hpam_optimization_metric: metric to use for hyperparameter optimization - (i.e., for selecting the best model on the validation set) - :param n_cv_splits: number of cross-validation splits - :param multiprocessing: whether to use multiprocessing. This requires Ray to be installed. - :param randomization_mode: list of randomization modes to do. Modes: SVCC, SVRC, SVCD, SVRD Can be a list of - randomization tests e.g. 'SVCC SVCD'. Default is None, which means no randomization tests are run. - - * SVCC: Single View Constant for Cell Lines: in this mode, one experiment is done for every cell line view - the model uses (e.g. gene expression, mutation, ...). For each experiment one cell line view is held - constant while the others are randomized. - * SVRC Single View Random for Cell Lines: in this mode, one experiment is done for every cell line view the - model uses (e.g. gene expression, mutation, ...). For each experiment one cell line view is randomized while - the others are held constant. - * SVCD: Single View Constant for Drugs: in this mode, one experiment is done for every drug view the model - uses (e.g. fingerprints, target_information, ...). For each experiment one drug view is held constant - while the others are randomized. - * SVRD: Single View Random for Drugs: in this mode, one experiment is done for every drug view the model uses - (e.g. gene expression, target_information, ...). For each experiment one drug view is randomized while - the others are held constant. - - :param randomization_type: type of randomization to use. Choose from "permutation" and "invariant". - Default is "permutation". - - * "permutation": permute the features over the instances, keeping the distribution of the features the same - but dissolving the relationship to the target - * "invariant": the features are permuted in a way that a key characteristic of the feature is kept. In case of - matrices, this is the mean and standard deviation of the feature view for this instance, for networks it - is the degree distribution. - - :param cross_study_datasets: list of datasets for the cross-study prediction. The trained model is assessed for - its generalization to these datasets. Default is None, which means no cross-study prediction is run. - :param n_trials_robustness: number of trials to run for the robustness test. The robustness test is a test where - models are retrained multiple times with varying seeds. Default is 0, which means no robustness test is run. - :param path_out: path to the output directory - :param run_id: identifier to save the results - :param test_mode: test mode one of "LPO", "LCO", "LTO", "LDO" - (leave-pair-out, leave-cell-line-out, leave-tissue-out, leave-drug-out) - :param overwrite: whether to overwrite existing results - :param path_data: path to the data directory, usually data/ - :param model_checkpoint_dir: directory to save model checkpoints. If "TEMPORARY", a temporary directory is created. - :param hyperparameter_tuning: whether to run in debug mode - if False, only select first hyperparameter set - :param final_model_on_full_data: if True, a final/production model is saved in the results directory. - If hyperparameter_tuning is true, the final model is produced according to the hyperparameter tuning procedure - which was evaluated in the nested cross validation. - :param wandb_project: if provided, enables wandb logging for all DRPModel instances throughout training. - All hyperparameters and metrics will be logged to the specified wandb project. - :param custom_splitter: optional path to a Python script or callable implementing ``create_splits``. - When provided, built-in ``split_dataset`` is skipped and ``test_mode`` selects validation checks. - :param custom_split_name: optional result-directory label when using a custom splitter. - Defaults to ``test_mode`` when omitted. - :raises ValueError: if no cv splits are found - """ - seed_everything(42) - # Default baseline model, needed for normalization - nme = MODEL_FACTORY["NaiveMeanEffectsPredictor"] - if baselines is None: - baselines = [nme] - elif nme not in baselines: - baselines.append(nme) - - cross_study_datasets = cross_study_datasets or [] - split_label = custom_split_name if custom_split_name is not None else test_mode - result_path = os.path.join(path_out, run_id, response_data._name, split_label) - split_path = os.path.join(result_path, "splits") - result_folder_exists = os.path.exists(result_path) - actual_n_cv_splits = prepare_response_splits( - response_data, - split_path=split_path, - result_path=result_path, - split_label=split_label, - test_mode=test_mode, - n_cv_splits=n_cv_splits, - overwrite=overwrite, - result_folder_exists=result_folder_exists, - custom_splitter=custom_splitter, - ) - - # Build the list of models to run (done regardless of whether splits were newly created or loaded) - model_list = make_model_list(models + baselines, response_data) - for model_name in model_list.keys(): - print(f"Running {model_name}") - model_name, drug_id = get_model_name_and_drug_id(model_name) - - model_class = MODEL_FACTORY[model_name] - if model_class in baselines: - print("- Only Baseline Tests -") - is_baseline = True - else: - print("- Full Test -") - is_baseline = False - - predictions_path = generate_data_saving_path( - model_name=model_name, - drug_id=drug_id, - result_path=result_path, - suffix="predictions", - ) - hpam_path = generate_data_saving_path( - model_name=model_name, - drug_id=drug_id, - result_path=result_path, - suffix="best_hpams", - ) - parent_dir = os.path.dirname(predictions_path) - - model_hpam_set = model_class.get_hyperparameter_set() - if not hyperparameter_tuning: - model_hpam_set = [model_hpam_set[0]] - - if response_data.cv_splits is None: - raise ValueError("No cv splits found.") - - for split_index, split in enumerate(response_data.cv_splits): - print() - print(f"################# FOLD {split_index + 1}/{len(response_data.cv_splits)} " f"#################") - print() - - prediction_file = os.path.join(predictions_path, f"predictions_split_{split_index}.csv") - - hpam_filename = f"best_hpams_split_{split_index}.json" - hpam_save_path = os.path.join(hpam_path, hpam_filename) - - ( - train_dataset, - validation_dataset, - early_stopping_dataset, - test_dataset, - ) = get_datasets_from_cv_split(split, model_class, model_name, drug_id) - - model = model_class() - # Base wandb configuration for this split (used when training actually happens) - base_wandb_config = { - "model_name": model_name, - "drug_id": drug_id, - "split_index": split_index, - "test_mode": test_mode, - "dataset": response_data.dataset_name, - "n_cv_splits": actual_n_cv_splits, - "hyperparameter_tuning": hyperparameter_tuning, - } - - if not os.path.isfile( - prediction_file - ): # if this split has not been run yet (or for a single drug model, this drug_id) - tuning_inputs = { - "model": model, - "train_dataset": train_dataset, - "validation_dataset": validation_dataset, - "early_stopping_dataset": early_stopping_dataset, - "hpam_set": model_hpam_set, - "response_transformation": response_transformation, - "metric": hpam_optimization_metric, - "path_data": path_data, - "model_checkpoint_dir": model_checkpoint_dir, - } - - # During hyperparameter tuning, create separate wandb runs per trial if enabled - if wandb_project is not None: - tuning_inputs["wandb_project"] = wandb_project - tuning_inputs["split_index"] = split_index - tuning_inputs["wandb_base_config"] = base_wandb_config - - if multiprocessing: - tuning_inputs["ray_path"] = os.path.abspath(os.path.join(result_path, "raytune")) - best_hpams = hpam_tune_raytune(**tuning_inputs) - else: - best_hpams = hpam_tune(**tuning_inputs) - - print(f"Best hyperparameters: {best_hpams}") - print("Training model on full train and validation set to predict test set") - - # Log best hyperparameters to wandb (they will be logged when build_model is called) - # The best hyperparameters will be logged via build_model -> log_hyperparameters - # save best hyperparameters as json - with open( - hpam_save_path, - "w", - encoding="utf-8", - ) as f: - json.dump(best_hpams, f) - - train_dataset.add_rows(validation_dataset) # use full train val set data for final training - train_dataset.shuffle(random_state=42) - - # Initialize wandb for the final training on the full train+validation set - # This happens regardless of whether hyperparameter tuning was performed - if wandb_project is not None: - final_run_name = f"{model_name}" - if drug_id is not None: - final_run_name += f"_{drug_id}" - final_run_name += f"_split_{split_index}_final" - - final_config = { - **base_wandb_config, - "phase": "final_training", - } - model.init_wandb( - project=wandb_project, - config=final_config, - name=final_run_name, - tags=[model_name, test_mode, response_data.dataset_name or "unknown", "final"], - ) - - test_dataset = train_and_predict( - model=model, - hpams=best_hpams, - path_data=path_data, - train_dataset=train_dataset, - prediction_dataset=test_dataset, - early_stopping_dataset=(early_stopping_dataset if model.early_stopping else None), - response_transformation=response_transformation, - model_checkpoint_dir=model_checkpoint_dir, - ) - - # Log final metrics on test set for all models - # Metrics will be logged as test_RMSE, test_R^2, test_Pearson, etc. - # This happens regardless of whether hyperparameter tuning was performed - if ( - wandb_project is not None - and wandb is not None - and len(test_dataset) > 0 - and test_dataset.predictions is not None - and len(test_dataset.predictions) > 0 - ): - # Ensure wandb run is active before logging metrics - if wandb.run is not None: - model.compute_and_log_final_metrics( - test_dataset, - additional_metrics=[hpam_optimization_metric], - prefix="test_", - ) - - for cross_study_dataset in cross_study_datasets: - print(f"Cross study prediction on {cross_study_dataset.dataset_name}") - cross_study_dataset.remove_nan_responses() - cross_study_prediction( - dataset=cross_study_dataset, - model=model, - test_mode=test_mode, - train_dataset=train_dataset, - path_data=path_data, - early_stopping_dataset=(early_stopping_dataset if model.early_stopping else None), - response_transformation=response_transformation, - path_out=parent_dir, - split_index=split_index, - single_drug_id=(drug_id if model_name in SINGLE_DRUG_MODEL_FACTORY else None), - ) - - test_dataset.to_csv(prediction_file) - else: - print(f"Split {split_index} already exists. Skipping.") - with open( - hpam_save_path, - encoding="utf-8", - ) as f: - best_hpams = json.load(f) - - # Finish wandb run for this split - if wandb_project is not None: - model.finish_wandb() - if not is_baseline: - if randomization_mode is not None: - print(f"Randomization tests for {model_class.get_model_name()}") - # if this line changes, it also needs to be changed in pipeline: - # randomization_split.py - randomization_test_views = get_randomization_test_views( - model=model, randomization_mode=randomization_mode - ) - randomization_test( - randomization_test_views=randomization_test_views, - model=model, - hpam_set=best_hpams, - path_data=path_data, - train_dataset=train_dataset, - test_dataset=test_dataset, - early_stopping_dataset=(early_stopping_dataset if model.early_stopping else None), - path_out=parent_dir, - split_index=split_index, - randomization_type=randomization_type, - response_transformation=response_transformation, - model_checkpoint_dir=model_checkpoint_dir, - ) - if n_trials_robustness > 0: - print(f"Robustness test for {model_class.get_model_name()}") - robustness_test( - n_trials=n_trials_robustness, - model=model, - hpam_set=best_hpams, - path_data=path_data, - train_dataset=train_dataset, - test_dataset=test_dataset, - early_stopping_dataset=(early_stopping_dataset if model.early_stopping else None), - path_out=parent_dir, - split_index=split_index, - response_transformation=response_transformation, - ) - - if final_model_on_full_data and (model_class not in baselines): - final_model_path = generate_data_saving_path( - model_name=model_name, - drug_id=drug_id, - result_path=result_path, - suffix="final_model", - ) - train_final_model( - model_class=model_class, - full_dataset=response_data.copy(), - response_transformation=response_transformation, - path_data=path_data, - model_checkpoint_dir=model_checkpoint_dir, - metric=hpam_optimization_metric, - final_model_path=final_model_path, - test_mode=test_mode, - val_ratio=0.1, - hyperparameter_tuning=hyperparameter_tuning, - ) - - consolidate_single_drug_model_predictions( - models=models, - n_cv_splits=actual_n_cv_splits, - results_path=result_path, - cross_study_datasets=[cs.dataset_name for cs in cross_study_datasets], - randomization_mode=randomization_mode, - n_trials_robustness=n_trials_robustness, - out_path=result_path, - ) - print("Done!") - - -@pipeline_function -def consolidate_single_drug_model_predictions( - models: list[type[DRPModel]], - n_cv_splits: int, - results_path: str, - cross_study_datasets: list[str], - randomization_mode: list[str] | None = None, - n_trials_robustness: int = 0, - out_path: str = "", -) -> None: - """ - Consolidate single drug model predictions into a single file. - - :param models: list of model classes to compare, e.g., [SimpleNeuralNetwork, RandomForest] - :param n_cv_splits: number of cross-validation splits, e.g., 5 - :param results_path: path to the results directory, e.g., results/ - :param cross_study_datasets: list of cross-study datasets, e.g., [CCLE, GDSC1] - :param randomization_mode: list of randomization modes, e.g., ["SVCC", "SVRC"] - :param n_trials_robustness: number of robustness trials, e.g., 10 - :param out_path: for the package, this is the same as results_path. For the pipeline, this is empty because it - will be stored in the work directory. - """ - for model in models: - if model.get_model_name() in SINGLE_DRUG_MODEL_FACTORY: - model_instance = MODEL_FACTORY[model.get_model_name()]() - model_path = os.path.join(results_path, model.get_model_name()) - out_path = os.path.join(out_path, model.get_model_name()) - os.makedirs(os.path.join(out_path, "predictions"), exist_ok=True) - if cross_study_datasets: - os.makedirs(os.path.join(out_path, "cross_study"), exist_ok=True) - if randomization_mode: - os.makedirs(os.path.join(out_path, "randomization"), exist_ok=True) - if n_trials_robustness: - os.makedirs(os.path.join(out_path, "robustness"), exist_ok=True) - - for split in range(n_cv_splits): - # Collect predictions for drugs across all scenarios (main, cross_study, robustness, randomization) - predictions: Any = { - "main": [], - "cross_study": {}, - "robustness": {}, - "randomization": {}, - } - # list all dirs in model_path/drugs - drugs = [ - d - for d in os.listdir(os.path.join(model_path, "drugs")) - if os.path.isdir(os.path.join(model_path, "drugs", d)) - ] - for drug in drugs: - single_drug_prediction_path = os.path.join(model_path, "drugs", drug) - - # Main predictions - predictions["main"].append( - pd.read_csv( - os.path.join( - single_drug_prediction_path, - "predictions", - f"predictions_split_{split}.csv", - ), - index_col=0, - ) - ) - - # Cross study predictions - for cross_study_dataset in cross_study_datasets: - cross_study_prediction_path = os.path.join(single_drug_prediction_path, "cross_study") - f = f"cross_study_{cross_study_dataset}_split_{split}.csv" - if cross_study_dataset not in predictions["cross_study"]: - predictions["cross_study"][cross_study_dataset] = [] - predictions["cross_study"][cross_study_dataset].append( - pd.read_csv( - os.path.join(cross_study_prediction_path, f), - index_col=0, - ) - ) - - # Robustness predictions - for trial in range(n_trials_robustness): - robustness_path = os.path.join(single_drug_prediction_path, "robustness") - f = f"robustness_{trial + 1}_split_{split}.csv" - if trial not in predictions["robustness"]: - predictions["robustness"][trial] = [] - predictions["robustness"][trial].append( - pd.read_csv(os.path.join(robustness_path, f), index_col=0) - ) - - # Randomization predictions - if randomization_mode is not None: - randomization_test_views = get_randomization_test_views( - model=model_instance, - randomization_mode=randomization_mode, - ) - for view in randomization_test_views: - randomization_path = os.path.join(single_drug_prediction_path, "randomization") - f = f"randomization_{view}_split_{split}.csv" - if view not in predictions["randomization"]: - predictions["randomization"][view] = [] - predictions["randomization"][view].append( - pd.read_csv( - os.path.join(randomization_path, f), - index_col=0, - ) - ) - - # Save the consolidated predictions - pd.concat(predictions["main"], axis=0).to_csv( - os.path.join( - out_path, - "predictions", - f"predictions_split_{split}.csv", - ) - ) - - for dataset_name, dataset_predictions in predictions["cross_study"].items(): - pd.concat(dataset_predictions, axis=0).to_csv( - os.path.join( - out_path, - "cross_study", - f"cross_study_{dataset_name}_split_{split}.csv", - ) - ) - - for trial, trial_predictions in predictions["robustness"].items(): - pd.concat(trial_predictions, axis=0).to_csv( - os.path.join( - out_path, - "robustness", - f"robustness_{trial + 1}_split_{split}.csv", - ) - ) - - for view, view_predictions in predictions["randomization"].items(): - pd.concat(view_predictions, axis=0).to_csv( - os.path.join( - out_path, - "randomization", - f"randomization_{view}_split_{split}.csv", - ) - ) - - -def load_features( - model: DRPModel, path_data: str, dataset: DrugResponseDataset -) -> tuple[FeatureDataset, FeatureDataset | None]: - """ - Load and reduce cell line and drug features for a given dataset. - - :param model: model to use, e.g., SimpleNeuralNetwork - :param path_data: path to the data directory, e.g., data/ - :param dataset: dataset to load features for, e.g., GDSC2 - :returns: tuple of cell line and, potentially, drug features - """ - cl_features = model.load_cell_line_features(data_path=path_data, dataset_name=dataset.dataset_name) - drug_features = model.load_drug_features(data_path=path_data, dataset_name=dataset.dataset_name) - return cl_features, drug_features - - -@pipeline_function -def cross_study_prediction( - dataset: DrugResponseDataset, - model: DRPModel, - test_mode: str, - train_dataset: DrugResponseDataset, - path_data: str, - early_stopping_dataset: DrugResponseDataset | None, - response_transformation: TransformerMixin | None, - path_out: str, - split_index: int, - single_drug_id: str | None = None, -) -> None: - """ - Run the drug response prediction experiment on a cross-study dataset to assess the generalizability of the model. - - :param dataset: cross-study dataset, e.g., GDSC1 if trained on GDSC2 - :param model: model to use, e.g, SimpleNeuralNetwork - :param test_mode: test mode one of "LPO", "LCO", "LDO" (leave-pair-out, leave-cell-line-out, - leave-drug-out) - :param train_dataset: training dataset, e.g., GDSC2 - :param path_data: path to the data directory, e.g., data/ - :param early_stopping_dataset: early stopping dataset - :param response_transformation: normalizer to use for the response data, e.g., StandardScaler - :param path_out: path to the output directory, e.g., results/ - :param split_index: index of the split - :param single_drug_id: drug id to use for single drug models None for global models - :raises ValueError: if feature loading fails, if the test mode is invalid, or if LTO and no tissues are supplied. - """ - dataset = dataset.copy() - os.makedirs(os.path.join(path_out, "cross_study"), exist_ok=True) - if response_transformation: - dataset.transform(response_transformation) - - # load features - try: - cl_features, drug_features = load_features(model, path_data, dataset) - except ValueError as e: - warnings.warn(str(e), stacklevel=2) - return - - cell_lines_to_keep = cl_features.identifiers if cl_features is not None else None - - drugs_to_keep: np.ndarray | None = None - if single_drug_id is not None: - drugs_to_keep = np.array([single_drug_id]) - elif drug_features is not None: - drugs_to_keep = drug_features.identifiers - - print( - f"Reducing cross study dataset ... feature data available for " - f'{len(cell_lines_to_keep) if cell_lines_to_keep is not None else "all"} cell lines ' - f'and {len(drugs_to_keep)if drugs_to_keep is not None else "all"} drugs.' - ) - - # making sure there are no missing features. Only keep cell lines and drugs for which we have - # a feature representation - dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - if early_stopping_dataset is not None: - train_dataset.add_rows(early_stopping_dataset) - # remove rows which overlap in the training. depends on the test mode - if test_mode == "LPO": - train_pairs = { - f"{cl}_{drug}" for cl, drug in zip(train_dataset.cell_line_ids, train_dataset.drug_ids, strict=True) - } - dataset_pairs = [f"{cl}_{drug}" for cl, drug in zip(dataset.cell_line_ids, dataset.drug_ids, strict=True)] - - dataset.remove_rows(np.array([i for i, pair in enumerate(dataset_pairs) if pair in train_pairs])) - elif test_mode == "LCO": - train_cell_lines = train_dataset.cell_line_ids - dataset.reduce_to( - cell_line_ids=np.setdiff1d(dataset.cell_line_ids, train_cell_lines), - drug_ids=None, - ) - elif test_mode == "LDO": - train_drugs = train_dataset.drug_ids - dataset.reduce_to( - cell_line_ids=None, - drug_ids=np.setdiff1d(dataset.drug_ids, train_drugs), - ) - elif test_mode == "LTO": - if train_dataset.tissue is None or dataset.tissue is None: - raise ValueError("Tissue information not available.") - # get tissues occurring in train - train_tissues = set(train_dataset.tissue) - # get indices of tissues in dataset not occurring in train_tissues - indices = np.array([i for i, t in enumerate(dataset.tissue) if t not in train_tissues]) - if len(indices) > 0: - cell_lines_to_keep = np.unique(dataset.cell_line_ids[indices]) - else: - cell_lines_to_keep = np.array([]) - dataset.reduce_to( - cell_line_ids=cell_lines_to_keep, - drug_ids=None, - ) - else: - raise ValueError(f"Invalid test mode: {test_mode}. Choose from LPO, LCO, LDO, LTO") - if len(dataset) > 0: - drug_input = drug_features.copy() if drug_features is not None else None - dataset.shuffle(random_state=42) - dataset._predictions = model.predict( - cell_line_ids=dataset.cell_line_ids, - drug_ids=dataset.drug_ids, - cell_line_input=cl_features.copy(), - drug_input=drug_input, - ) - if response_transformation: - dataset.inverse_transform(response_transformation) - else: - dataset._predictions = np.array([]) - dataset.to_csv( - os.path.join( - path_out, - "cross_study", - f"cross_study_{dataset.dataset_name}_split_{split_index}.csv", - ) - ) - - -@pipeline_function -def get_randomization_test_views(model: DRPModel, randomization_mode: list[str]) -> dict[str, list[str]]: - """ - Get the views to use for the randomization tests. - - * For SVCC, a single cell line view (e.g., gene expression) is held constant while the others are randomized. - * For SVCD, a single drug view (e.g., fingerprints) is held constant while the others are randomized. - * For SVRC, a single cell line view is randomized while the others are held constant. - * For SVRD, a single drug view is randomized while the others are held constant. - - :param model: model to use, e.g., SimpleNeuralNetwork - :param randomization_mode: list of randomization modes to do, e.g., ["SVCC", "SVRC"] - :returns: dictionary of randomization test views - """ - cell_line_views = model.cell_line_views - drug_views = model.drug_views - randomization_test_views = {} - if "SVCC" in randomization_mode: - for view in cell_line_views: - randomization_test_views[f"SVCC_{view}"] = [v for v in cell_line_views if v != view] - if "SVCD" in randomization_mode: - for view in drug_views: - randomization_test_views[f"SVCD_{view}"] = [v for v in drug_views if v != view] - if "SVRC" in randomization_mode: - for view in cell_line_views: - randomization_test_views[f"SVRC_{view}"] = [view] - if "SVRD" in randomization_mode: - for view in drug_views: - randomization_test_views[f"SVRD_{view}"] = [view] - - return randomization_test_views - - -def robustness_test( - n_trials: int, - model: DRPModel, - hpam_set: dict, - path_data: str, - train_dataset: DrugResponseDataset, - test_dataset: DrugResponseDataset, - early_stopping_dataset: DrugResponseDataset | None, - path_out: str, - split_index: int, - response_transformation: TransformerMixin | None = None, - model_checkpoint_dir: str = "TEMPORARY", -): - """ - Run robustness tests for the given model and dataset. - - This will run the model n times with different random seeds to get a distribution of the results. - - :param n_trials: number of trials to run - :param model: model to evaluate - :param hpam_set: hyperparameters to use - :param path_data: path to the data directory - :param train_dataset: training dataset - :param test_dataset: test dataset - :param early_stopping_dataset: early stopping dataset - :param path_out: path to the output directory - :param split_index: index of the split - :param response_transformation: sklearn.preprocessing scaler like StandardScaler or MinMaxScaler to use to scale - the target - :param model_checkpoint_dir: directory to save model checkpoints, if "TEMPORARY": temporary directory is used - """ - robustness_test_path = os.path.join(path_out, "robustness") - os.makedirs(robustness_test_path, exist_ok=True) - for trial in range(n_trials): - print(f"Running robustness test trial {trial + 1}/{n_trials}") - trial_file = os.path.join( - robustness_test_path, - f"robustness_{trial + 1}_split_{split_index}.csv", - ) - if not os.path.isfile(trial_file): - robustness_train_predict( - trial=trial, - trial_file=trial_file, - train_dataset=train_dataset, - test_dataset=test_dataset, - early_stopping_dataset=early_stopping_dataset, - model=model, - hpam_set=hpam_set, - path_data=path_data, - response_transformation=response_transformation, - model_checkpoint_dir=model_checkpoint_dir, - ) - - -@pipeline_function -def robustness_train_predict( - trial: int, - trial_file: str, - train_dataset: DrugResponseDataset, - test_dataset: DrugResponseDataset, - early_stopping_dataset: DrugResponseDataset | None, - model: DRPModel, - hpam_set: dict, - path_data: str, - response_transformation: TransformerMixin | None = None, - model_checkpoint_dir: str = "TEMPORARY", -) -> None: - """ - Train and predict for the robustness test. - - :param trial: trial number - :param trial_file: file to save the results to - :param train_dataset: training dataset - :param test_dataset: test dataset - :param early_stopping_dataset: early stopping dataset - :param model: model to evaluate - :param hpam_set: hyperparameters to use - :param path_data: path to the data directory, e.g., data/ - :param response_transformation: sklearn.preprocessing scaler like StandardScaler or MinMaxScaler to use to scale - :param model_checkpoint_dir: directory to save model checkpoints. If "TEMPORARY", a temporary directory is created. - """ - train_dataset.shuffle(random_state=trial) - test_dataset.shuffle(random_state=trial) - if early_stopping_dataset is not None: - early_stopping_dataset.shuffle(random_state=trial) - test_dataset = train_and_predict( - model=model, - hpams=hpam_set, - path_data=path_data, - train_dataset=train_dataset, - prediction_dataset=test_dataset, - early_stopping_dataset=early_stopping_dataset, - response_transformation=response_transformation, - model_checkpoint_dir=model_checkpoint_dir, - ) - test_dataset.to_csv(trial_file) - - -def randomization_test( - randomization_test_views: dict[str, list[str]], - model: DRPModel, - hpam_set: dict, - path_data: str, - train_dataset: DrugResponseDataset, - test_dataset: DrugResponseDataset, - early_stopping_dataset: DrugResponseDataset | None, - path_out: str, - split_index: int, - randomization_type: str = "permutation", - response_transformation: TransformerMixin | None = None, - model_checkpoint_dir: str = "TEMPORARY", -) -> None: - """ - Run randomization tests for the given model and dataset. - - :param randomization_test_views: views to use for the randomization tests. - Key is the name of the randomization test and the value is a list of views to randomize - e.g. {"randomize_genomics": ["copy_number_var", "mutation"], - "methylation_only": ["gene_expression", "copy_number_var", "mutation"]}" - :param model: model to evaluate - :param hpam_set: hyperparameters to use - :param path_data: path to the data directory - :param train_dataset: training dataset - :param test_dataset: test dataset - :param early_stopping_dataset: early stopping dataset - :param path_out: path to the output directory - :param split_index: index of the split - :param randomization_type: type of randomization to use. Choose from "permutation", "invariant". - Default is "permutation" which permutes the features over the instances, keeping the - distribution of the features the same but dissolving the relationship to the target. - invariant randomization is done in a way that a key characteristic of the feature is preserved. - In case of matrices, this is the mean and standard deviation of the feature view for this - instance, for networks it is the degree distribution. - :param response_transformation: sklearn.preprocessing scaler like StandardScaler or MinMaxScaler - to use to scale the target - :param model_checkpoint_dir: directory to save model checkpoints - """ - for test_name, views in randomization_test_views.items(): - randomization_test_path = os.path.join(path_out, "randomization") - os.makedirs(randomization_test_path, exist_ok=True) - - randomization_test_file = os.path.join( - randomization_test_path, - f"randomization_{test_name}_split_{split_index}.csv", - ) - if not os.path.isfile(randomization_test_file): # if this splits test has not been run yet - for view in views: - print(f"Randomizing view {view} for randomization test {test_name} ...") - randomize_train_predict( - view=view, - test_name=test_name, - randomization_type=randomization_type, - randomization_test_file=randomization_test_file, - model=model, - hpam_set=hpam_set, - path_data=path_data, - train_dataset=train_dataset, - test_dataset=test_dataset, - early_stopping_dataset=early_stopping_dataset, - response_transformation=response_transformation, - model_checkpoint_dir=model_checkpoint_dir, - ) - else: - print(f"Randomization test {test_name} already exists. Skipping.") - - -@pipeline_function -def randomize_train_predict( - view: str, - test_name: str, - randomization_type: str, - randomization_test_file: str, - model: DRPModel, - hpam_set: dict, - path_data: str, - train_dataset: DrugResponseDataset, - test_dataset: DrugResponseDataset, - early_stopping_dataset: DrugResponseDataset | None, - model_checkpoint_dir: str = "TEMPORARY", - response_transformation: TransformerMixin | None = None, -) -> None: - """ - Randomize the features for a given view and run the model. - - :param view: view to randomize, e.g., gene_expression - :param test_name: name of the randomization test, e.g., SVRC_gene_expression - :param randomization_type: type of randomization to use, e.g., permutation - :param randomization_test_file: file to save the results to - :param model: model to evaluate - :param hpam_set: hyperparameters to use - :param path_data: path to the data directory - :param train_dataset: training dataset - :param test_dataset: test dataset - :param early_stopping_dataset: early stopping dataset - :param model_checkpoint_dir: directory to save model checkpoints - :param response_transformation: sklearn.preprocessing scaler like StandardScaler or MinMaxScaler to use to scale - """ - # build_model must be called before load_features so that models whose cell_line_views/drug_views - # are populated dynamically by build_model (e.g. SklearnModel subclasses) have their views set. - model.build_model(hyperparameters=hpam_set) - cl_features, drug_features = load_features(model, path_data, train_dataset) - - # Handle case where both features are None early on - if cl_features is None and drug_features is None: - warnings.warn( - "Both cl_features and drug_features are None. Skipping randomization test.", - stacklevel=2, - ) - return - - # Check if view is in either feature set, if not, warn and skip - if (cl_features is not None and view not in cl_features.view_names) and ( - drug_features is not None and view not in drug_features.view_names - ): - warnings.warn( - f"View {view} not found in features. Skipping randomization test {test_name} which includes this view.", - stacklevel=2, - ) - return - - cl_features_rand: FeatureDataset | None = None - if cl_features is not None and view in cl_features.view_names: - cl_features_rand = cl_features.copy() - cl_features_rand.randomize_features(view, randomization_type=randomization_type) # type: ignore[union-attr] - - drug_features_rand: FeatureDataset | None = None - if drug_features is not None and view in drug_features.view_names: - drug_features_rand = drug_features.copy() - drug_features_rand.randomize_features(view, randomization_type=randomization_type) # type: ignore[union-attr] - - test_dataset_rand = train_and_predict( - model=model, - hpams=hpam_set, - path_data=path_data, - train_dataset=train_dataset, - prediction_dataset=test_dataset, - early_stopping_dataset=early_stopping_dataset, - response_transformation=response_transformation, - cl_features=cl_features_rand, - drug_features=drug_features_rand, - model_checkpoint_dir=model_checkpoint_dir, - ) - test_dataset_rand.to_csv(randomization_test_file) - - -def split_early_stopping( - validation_dataset: DrugResponseDataset, test_mode: str -) -> tuple[DrugResponseDataset, DrugResponseDataset]: - """ - Split the validation dataset into a validation and early stopping dataset. - - :param validation_dataset: validation dataset - :param test_mode: test mode one of "LPO", "LCO", "LDO" (leave-pair-out, leave-cell-line-out, leave-drug-out) - :returns: tuple of validation and early stopping datasets - """ - validation_dataset.shuffle(random_state=42) - cv_v = validation_dataset.split_dataset( - n_cv_splits=4, - mode=test_mode, - split_validation=False, - random_state=42, - ) - # take the first fold of a 4 cv as the split ie. 3/4 for validation and 1/4 for early stopping - validation_dataset = cv_v[0]["train"] - early_stopping_dataset = cv_v[0]["test"] - return validation_dataset, early_stopping_dataset - - -@pipeline_function -def train_and_predict( - model: DRPModel, - hpams: dict, - path_data: str, - train_dataset: DrugResponseDataset, - prediction_dataset: DrugResponseDataset, - early_stopping_dataset: DrugResponseDataset | None = None, - response_transformation: TransformerMixin | None = None, - cl_features: FeatureDataset | None = None, - drug_features: FeatureDataset | None = None, - model_checkpoint_dir: str = "TEMPORARY", -) -> DrugResponseDataset: - """ - Train the model and predict the response for the prediction dataset. - - :param model: model to use, e.g., SimpleNeuralNetwork - :param hpams: hyperparameters to use - :param path_data: path to the data directory, e.g., data/ - :param train_dataset: training dataset - :param prediction_dataset: prediction dataset - :param early_stopping_dataset: early stopping dataset, optional - :param response_transformation: normalizer to use for the response data, e.g., StandardScaler - :param cl_features: cell line features - :param drug_features: drug features - :param model_checkpoint_dir: directory for model checkpoints, if "TEMPORARY", checkpoints are not saved. - Default is "TEMPORARY" - :returns: prediction dataset with predictions - :raises ValueError: if train_dataset does not have a dataset_name - """ - # Make copies to avoid that models ever mutate the data - train_dataset = train_dataset.copy() - prediction_dataset = prediction_dataset.copy() - if early_stopping_dataset is not None: - early_stopping_dataset = early_stopping_dataset.copy() - - model.build_model(hyperparameters=hpams) - if train_dataset.dataset_name is None: - raise ValueError("train_dataset must have a dataset_name") - if cl_features is None: - print("Loading cell line features ...") - cl_features = model.load_cell_line_features(data_path=path_data, dataset_name=train_dataset.dataset_name) - if drug_features is None: - print("Loading drug features ...") - drug_features = model.load_drug_features(data_path=path_data, dataset_name=train_dataset.dataset_name) - - cell_lines_to_keep = cl_features.identifiers if cl_features is not None else None - drugs_to_keep = drug_features.identifiers if drug_features is not None else None - - # making sure there are no missing features: - len_train_before = len(train_dataset) - len_pred_before = len(prediction_dataset) - if cell_lines_to_keep is not None: - print(f"Number of cell lines in features: {len(cell_lines_to_keep)}") - if drugs_to_keep is not None: - print(f"Number of drugs in features: {len(drugs_to_keep)}") - print(f"Number of cell lines in train dataset: {len(np.unique(train_dataset.cell_line_ids))}") - print(f"Number of drugs in train dataset: {len(np.unique(train_dataset.drug_ids))}") - - train_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - prediction_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - if len(train_dataset) < len_train_before or len(prediction_dataset) < len_pred_before: - print(f"Reduced training dataset from {len_train_before} to {len(train_dataset)}, due to missing features") - print( - f"Reduced prediction dataset from {len_pred_before} to {len(prediction_dataset)}, due to missing features" - ) - - if early_stopping_dataset is not None: - len_es_before = len(early_stopping_dataset) - early_stopping_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - print(f"Reduced early stopping dataset from {len_es_before} to {len(early_stopping_dataset)}") - - if response_transformation: - train_dataset.fit_transform(response_transformation) - if early_stopping_dataset is not None: - early_stopping_dataset.transform(response_transformation) - prediction_dataset.transform(response_transformation) - - drug_input = drug_features.copy() if drug_features is not None else None - print("Training model ...") - - if model_checkpoint_dir == "TEMPORARY": - with tempfile.TemporaryDirectory() as temp_dir: - print(f"Using temporary directory: {temp_dir} for model checkpoints") - - model.train( - output=train_dataset, - output_earlystopping=early_stopping_dataset, - cell_line_input=cl_features.copy(), - drug_input=drug_input, - model_checkpoint_dir=temp_dir, - ) - else: - if not os.path.exists(model_checkpoint_dir): - os.makedirs(model_checkpoint_dir, exist_ok=True) - print(f"Using directory: {model_checkpoint_dir} for model checkpoints") - model.train( - output=train_dataset, - output_earlystopping=early_stopping_dataset, - cell_line_input=cl_features.copy(), - drug_input=drug_input, - model_checkpoint_dir=model_checkpoint_dir, - ) - - if len(prediction_dataset) > 0: - drug_input = drug_features.copy() if drug_features is not None else None - prediction_dataset._predictions = model.predict( - cell_line_ids=prediction_dataset.cell_line_ids, - drug_ids=prediction_dataset.drug_ids, - cell_line_input=cl_features.copy(), - drug_input=drug_input, - ) - - else: - prediction_dataset._predictions = np.array([]) - - if response_transformation: - train_dataset.inverse_transform(response_transformation) - prediction_dataset.inverse_transform(response_transformation) - if early_stopping_dataset is not None: - early_stopping_dataset.inverse_transform(response_transformation) - - return prediction_dataset - - -def train_and_evaluate( - model: DRPModel, - hpams: dict[str, Any], - path_data: str, - train_dataset: DrugResponseDataset, - validation_dataset: DrugResponseDataset, - early_stopping_dataset: DrugResponseDataset | None = None, - response_transformation: TransformerMixin | None = None, - metric: str = "RMSE", - model_checkpoint_dir: str = "TEMPORARY", -) -> dict[str, float]: - """ - Train and evaluate the model, i.e., call train_and_predict() and then evaluate(). - - :param model: model to use - :param hpams: hyperparameters to use - :param path_data: path to the data directory - :param train_dataset: training dataset - :param validation_dataset: validation dataset - :param early_stopping_dataset: early stopping dataset - :param response_transformation: normalizer to use for the response data - :param metric: metric to evaluate the model on - :param model_checkpoint_dir: directory to save model checkpoints - :returns: dictionary of the evaluation results, e.g., {"RMSE": 0.1} - """ - validation_dataset = train_and_predict( - model=model, - hpams=hpams, - path_data=path_data, - train_dataset=train_dataset, - prediction_dataset=validation_dataset, - early_stopping_dataset=early_stopping_dataset, - response_transformation=response_transformation, - model_checkpoint_dir=model_checkpoint_dir, - ) - - # Compute final metrics using DRPModel helper (always includes R^2 and PCC) - # Add primary metric if it's not already included - additional_metrics = None - if metric not in ["R^2", "Pearson"]: - additional_metrics = [metric] - # Use "val_" prefix to clearly denote validation metrics (val_RMSE, val_R^2, val_Pearson) - results = model.compute_and_log_final_metrics( - validation_dataset, - additional_metrics=additional_metrics, - prefix="val_", - ) - - return results - - -def hpam_tune( - model: DRPModel, - train_dataset: DrugResponseDataset, - validation_dataset: DrugResponseDataset, - hpam_set: list[dict], - early_stopping_dataset: DrugResponseDataset | None = None, - response_transformation: TransformerMixin | None = None, - metric: str = "RMSE", - path_data: str = "data", - model_checkpoint_dir: str = "TEMPORARY", - *, - split_index: int | None = None, - wandb_project: str | None = None, - wandb_base_config: dict[str, Any] | None = None, -) -> dict: - """ - Tune the hyperparameters for the given model in an iterative manner. - - :param model: model to use - :param train_dataset: training dataset - :param validation_dataset: validation dataset - :param hpam_set: hyperparameters to tune - :param early_stopping_dataset: early stopping dataset - :param response_transformation: normalizer to use for the response data - :param metric: metric to evaluate which model is the best - :param path_data: path to the data directory, e.g., data/ - :param model_checkpoint_dir: directory to save model checkpoints - :param split_index: optional CV split index, used for naming wandb runs - :param wandb_project: optional wandb project name; if provided, enables per-trial wandb runs - :param wandb_base_config: optional base config dict to include in each wandb run - :returns: best hyperparameters - :raises AssertionError: if hpam_set is empty - """ - if len(hpam_set) == 0: - raise AssertionError("hpam_set must contain at least one hyperparameter configuration") - if len(hpam_set) == 1: - return hpam_set[0] - - # Mark that we're in hyperparameter tuning phase - # This prevents updating wandb.config during tuning - we'll only log final best hyperparameters - model._in_hyperparameter_tuning = True - - best_hyperparameters = None - mode = get_mode(metric) - best_score = float("inf") if mode == "min" else float("-inf") - for trial_idx, hyperparameter in enumerate(hpam_set): - print(f"Training model with hyperparameters: {hyperparameter}") - - # Create a separate wandb run for each hyperparameter trial if enabled - if wandb_project is not None: - trial_run_name = model.get_model_name() - if split_index is not None: - trial_run_name += f"_split_{split_index}" - trial_run_name += f"_trial_{trial_idx}" - - trial_config: dict[str, Any] = {} - if wandb_base_config is not None: - trial_config.update(wandb_base_config) - trial_config.update( - { - "phase": "hyperparameter_tuning", - "trial_index": trial_idx, - "hyperparameters": hyperparameter, - } - ) - - model.init_wandb( - project=wandb_project, - config=trial_config, - name=trial_run_name, - tags=[model.get_model_name(), "hpam_tuning"], - finish_previous=True, - ) - - # During hyperparameter tuning, don't update wandb config via log_hyperparameters - # Trial hyperparameters are stored in wandb.config for each run - score = train_and_evaluate( - model=model, - hpams=hyperparameter, - path_data=path_data, - train_dataset=train_dataset, - validation_dataset=validation_dataset, - early_stopping_dataset=early_stopping_dataset, - metric=metric, - response_transformation=response_transformation, - model_checkpoint_dir=model_checkpoint_dir, - )[metric] - - # Note: train_and_evaluate() already logs val_* metrics once via - # DRPModel.compute_and_log_final_metrics(..., prefix="val_"). - # Avoid logging val_{metric} again here (it would create duplicate points). - if np.isnan(score): - if model.is_wandb_enabled(): - model.finish_wandb() - continue - - if (mode == "min" and score < best_score) or (mode == "max" and score > best_score): - print(f"current best {metric} score: {np.round(score, 3)}") - best_score = score - best_hyperparameters = hyperparameter - - # Close this trial's run after all logging is done - if model.is_wandb_enabled(): - model.finish_wandb() - - if best_hyperparameters is None: - warnings.warn("all hpams lead to NaN respone. using last hpam combination.", stacklevel=2) - best_hyperparameters = hyperparameter - - return best_hyperparameters - - -def hpam_tune_raytune( - model: DRPModel, - train_dataset: DrugResponseDataset, - validation_dataset: DrugResponseDataset, - early_stopping_dataset: DrugResponseDataset | None, - hpam_set: list[dict], - response_transformation: TransformerMixin | None = None, - metric: str = "RMSE", - ray_path: str = "raytune", - path_data: str = "data", - model_checkpoint_dir: str = "TEMPORARY", -) -> dict: - """ - Tune the hyperparameters for the given model using Ray Tune. Ray[tune] must be installed. - - :param model: model to use - :param train_dataset: training dataset - :param validation_dataset: validation dataset - :param early_stopping_dataset: early stopping dataset - :param hpam_set: hyperparameters to tune - :param response_transformation: normalizer for response data - :param metric: evaluation metric - :param ray_path: path to the raytune directory - :param path_data: path to data directory, e.g., data/ - :param model_checkpoint_dir: directory for model checkpoints - :returns: best hyperparameters - :raises ValueError: if best_result is None - """ - print("Starting hyperparameter tuning with Ray Tune ...") - print(f"Hyperparameter combinations to evaluate: {len(hpam_set)}") - print() - - if len(hpam_set) == 1: - return hpam_set[0] - - import ray - from ray import tune - - path_data = os.path.abspath(path_data) - if not ray.is_initialized(): - ray.init(_temp_dir=os.path.join(os.path.expanduser("~"), "raytmp")) - resources_per_trial = {"gpu": 1} if torch.cuda.is_available() else {"cpu": 1} - - def trainable(hpams): - try: - inner = hpams["hpams"] - result = train_and_evaluate( - model=model, - hpams=inner, - path_data=path_data, - train_dataset=train_dataset, - validation_dataset=validation_dataset, - early_stopping_dataset=early_stopping_dataset, - metric=metric, - response_transformation=response_transformation, - model_checkpoint_dir=model_checkpoint_dir, - ) - tune.report(metrics={metric: result[metric]}) - except Exception as e: - import traceback - - print("Trial failed:", e) - traceback.print_exc() - - trainable = tune.with_resources(trainable, resources_per_trial) - param_space = {"hpams": tune.grid_search(hpam_set)} - - tuner = tune.Tuner( - trainable, - param_space=param_space, - run_config=tune.RunConfig( - storage_path=ray_path, - name="hpam_tuning", - ), - tune_config=tune.TuneConfig( - metric=metric, - mode=get_mode(metric), - ), - ) - - results = tuner.fit() - best_result = results.get_best_result(metric=metric, mode=get_mode(metric)) - ray.shutdown() - if best_result.config is None: - raise ValueError("Ray failed; no best result.") - return best_result.config["hpams"] - - -@pipeline_function -def make_model_list(models: list[type[DRPModel]], response_data: DrugResponseDataset) -> dict[str, str]: - """ - Make a list of models to evaluate: if it is a single drug model, add the drug id to the model name. - - :param models: list of models to evaluate - :param response_data: response data, needed to get the unique drugs for single drug models - :returns: dictionary of model names: model class, e.g., {"SimpleNeuralNetwork": "SimpleNeuralNetwork", - "MOLIR.Afatinib": "MOLIR"} - """ - model_list = {} - unique_drugs = np.unique(response_data.drug_ids) - for model in models: - if model.is_single_drug_model: - for drug in unique_drugs: - model_list[f"{model.get_model_name()}.{drug}"] = model.get_model_name() - else: - model_list[model.get_model_name()] = model.get_model_name() - return model_list - - -@pipeline_function -def get_model_name_and_drug_id(model_name: str) -> tuple[str, str | None]: - """Get the model name and drug id from the model name. - - :param model_name: model name, e.g., SimpleNeuralNetwork or MOLIR.Afatinib - :returns: tuple of model name and, potentially drug id if it is a single drug model - :raises AssertionError: if the model name is not found in the model factory - """ - if model_name in MULTI_DRUG_MODEL_FACTORY: - return model_name, None - else: - name_split = model_name.split(".") - model_name = name_split[0] - if model_name not in SINGLE_DRUG_MODEL_FACTORY: - raise AssertionError( - f"Model {model_name} not found in MODEL_FACTORY or SINGLE_DRUG_MODEL_FACTORY. " - "Please add the model to the factory." - ) - drug_id = name_split[1] - - return model_name, drug_id - - -@pipeline_function -def get_datasets_from_cv_split( - split: dict[str, DrugResponseDataset], model_class: type[DRPModel], model_name: str, drug_id: str | None = None -) -> tuple[DrugResponseDataset, DrugResponseDataset, DrugResponseDataset | None, DrugResponseDataset]: - """ - Get train, validation, (early stopping), and test datasets from the CV split. - - Returns copies of the datasets to prevent in-place modifications (e.g., add_rows, reduce_to) - from affecting the original split data used by subsequent models. - - :param split: dictionary of the CV split - :param model_class: model class - :param model_name: model name - :param drug_id: drug id for single drug models - :returns: tuple of train, validation, (early stopping), and test datasets (as copies) - """ - train_dataset = split["train"].copy() - validation_dataset = split["validation"].copy() - test_dataset = split["test"].copy() - - if model_class.early_stopping: - validation_dataset = split["validation_es"].copy() - early_stopping_dataset = split["early_stopping"].copy() - else: - early_stopping_dataset = None - - if model_name in SINGLE_DRUG_MODEL_FACTORY.keys(): - output_mask = train_dataset.drug_ids == drug_id - train_dataset.mask(output_mask) - validation_mask = validation_dataset.drug_ids == drug_id - validation_dataset.mask(validation_mask) - test_mask = test_dataset.drug_ids == drug_id - test_dataset.mask(test_mask) - if early_stopping_dataset is not None: - es_mask = early_stopping_dataset.drug_ids == drug_id - early_stopping_dataset.mask(es_mask) - - return ( - train_dataset, - validation_dataset, - early_stopping_dataset, - test_dataset, - ) - - -@pipeline_function -def generate_data_saving_path(model_name, drug_id, result_path, suffix) -> str: - """ - Generate a path to save data to. - - For single drug models, the path is result_path/model_name/drugs/drug_id/suffix. - For all others, it is result_path/model_name/suffix. - - :param model_name: model name - :param drug_id: drug id - :param result_path: path to the results directory - :param suffix: suffix to add to the path, e.g., "predictions", "best_hpams", "randomization", "robustness" - :returns: path to save data to - """ - is_single_drug_model = model_name in SINGLE_DRUG_MODEL_FACTORY - if is_single_drug_model: - model_path = os.path.join(result_path, model_name, "drugs", drug_id, suffix) - else: - model_path = os.path.join(result_path, model_name, suffix) - os.makedirs(model_path, exist_ok=True) - return model_path - - -def train_final_model( - model_class: type[DRPModel], - full_dataset: DrugResponseDataset, - response_transformation: TransformerMixin, - path_data: str, - model_checkpoint_dir: str, - metric: str, - final_model_path: str, - test_mode: str = "LCO", - val_ratio: float = 0.1, - hyperparameter_tuning: bool = True, -) -> None: - """ - Final Production Model Training. - - Tune a final model on the full data set using a validation split that reflects intended generalization. - No test set is used here. The performance during the nested CV is a - pessimistic estimate of the final model performance. - The validation split strategy is determined by `test_mode`: - - LCO: generalization to unseen cell lines (e.g., personalized medicine) - - LDO: generalization to new drugs (e.g., drug repurposing) - - LTO: generalization to new tissues - - LPO: general (pair-level) prediction - - :param model_class: model to use - :param full_dataset: full training dataset (union of outer folds) - :param response_transformation: sklearn scaler used for response normalization - :param path_data: path to data directory - :param model_checkpoint_dir: checkpoint dir for intermediate tuning models - :param metric: metric for tuning, e.g., "RMSE" - :param final_model_path: path to final_model save directory - :param test_mode: split logic for validation (LCO, LDO, LTO, LPO) - :param val_ratio: validation size ratio - :param hyperparameter_tuning: whether to perform hyperparameter tuning - """ - print("Training final model with application-specific validation strategy ...") - - full_dataset.remove_nan_responses() - model = model_class() - train_dataset, validation_dataset = make_train_val_split(full_dataset, test_mode=test_mode, val_ratio=val_ratio) - - if model_class.early_stopping: - validation_dataset, early_stopping_dataset = split_early_stopping_data(validation_dataset, test_mode) - else: - early_stopping_dataset = None - - hpam_set = model.get_hyperparameter_set() - if hyperparameter_tuning: - best_hpams = hpam_tune( - model=model, - train_dataset=train_dataset, - validation_dataset=validation_dataset, - early_stopping_dataset=early_stopping_dataset, - hpam_set=hpam_set, - response_transformation=response_transformation, - metric=metric, - path_data=path_data, - model_checkpoint_dir=model_checkpoint_dir, - ) - else: - best_hpams = hpam_set[0] - - print(f"Best hyperparameters for final model: {best_hpams}") - model.build_model(hyperparameters=best_hpams) - - cl_features = model.load_cell_line_features(data_path=path_data, dataset_name=full_dataset.dataset_name) - drug_features = model.load_drug_features(data_path=path_data, dataset_name=full_dataset.dataset_name) - cell_lines_to_keep = cl_features.identifiers - drugs_to_keep = drug_features.identifiers if drug_features is not None else None - - train_dataset.add_rows(validation_dataset) - train_dataset.shuffle(random_state=42) - len_train_before = len(train_dataset) - train_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - if len(train_dataset) < len_train_before: - print(f"Reduced training dataset from {len_train_before} to {len(train_dataset)}, due to missing features") - - if response_transformation: - train_dataset.fit_transform(response_transformation) - if early_stopping_dataset is not None: - len_early_stopping_before = len(early_stopping_dataset) - early_stopping_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - if len(early_stopping_dataset) < len_early_stopping_before: - print( - f"Reduced early stopping dataset from {len_early_stopping_before} to " - f"{len(early_stopping_dataset)}, due to missing features" - ) - early_stopping_dataset.transform(response_transformation) - - drug_features = drug_features.copy() if drug_features is not None else None - model.train( - output=train_dataset, - output_earlystopping=early_stopping_dataset, - cell_line_input=cl_features.copy(), - drug_input=drug_features, - model_checkpoint_dir=model_checkpoint_dir, - ) - if response_transformation: - train_dataset.inverse_transform(response_transformation) - if early_stopping_dataset is not None: - early_stopping_dataset.inverse_transform(response_transformation) - - os.makedirs(final_model_path, exist_ok=True) - model.save(final_model_path) - - -@pipeline_function -def make_train_val_split( - dataset: DrugResponseDataset, - test_mode: str, - val_ratio: float = 0.1, - random_state: int = 42, -) -> tuple[DrugResponseDataset, DrugResponseDataset]: - """ - Split a dataset into train and validation sets according to the test mode and desired ratio. - - :param dataset: full dataset to split - :param test_mode: one of "LPO", "LCO", "LDO", "LTO" - :param val_ratio: approximate fraction of data to use for validation - :param random_state: random seed - :returns: (train_dataset, validation_dataset) - :raises ValueError: if no tissue information is provided for the DrugResponseDataset - """ - if test_mode == "LTO": - if dataset.tissue is not None: - n_groups = len(np.unique(dataset.tissue)) - else: - raise ValueError("Tissue information is missing but required for LTO mode.") - - elif test_mode == "LCO": - n_groups = len(np.unique(dataset.cell_line_ids)) - elif test_mode == "LDO": - n_groups = len(np.unique(dataset.drug_ids)) - else: - n_groups = len(dataset) - - n_splits = int(1 / val_ratio) - n_splits = min(n_splits, n_groups) - - split = dataset.split_dataset( - n_cv_splits=n_splits, - mode=test_mode, - split_validation=False, - random_state=random_state, - )[0] - - return split["train"], split["test"] diff --git a/drevalpy/experiment/__init__.py b/drevalpy/experiment/__init__.py new file mode 100644 index 000000000..adfb4716e --- /dev/null +++ b/drevalpy/experiment/__init__.py @@ -0,0 +1,13 @@ +"""Experiment sub-module: randomization and robustness utilities.""" + +# Re-exported for convenience only. These types are owned and documented by +# drevalpy.types.results, so they are deliberately kept out of __all__: listing +# them would make autodoc document the same classes a second time under this +# package, producing ambiguous cross-references. +from drevalpy.types.results.run import RunResult as RunResult +from drevalpy.types.results.trial import TrialResult as TrialResult + +from ._randomization import randomization +from ._robustness import robustness + +__all__ = ["randomization", "robustness"] diff --git a/drevalpy/experiment/_randomization.py b/drevalpy/experiment/_randomization.py new file mode 100644 index 000000000..c96867a9c --- /dev/null +++ b/drevalpy/experiment/_randomization.py @@ -0,0 +1,62 @@ +"""Randomization test utilities for feature importance analysis.""" + +from __future__ import annotations + +from drevalpy.models.drp_model import DRPModel +from drevalpy.types.data.dataset import Dataset + + +def _single_view_tests(views: list[str], prefix: str) -> dict[tuple[str, str], list[str]]: + """One test per view: randomize that view only.""" + return {(prefix, view): [view] for view in views} + + +def _complement_view_tests(views: list[str], prefix: str) -> dict[tuple[str, str], list[str]]: + """One test per view: randomize all views except that one.""" + return {(prefix, view): [v for v in views if v != view] for view in views} + + +def randomization( + model_class: type[DRPModel], + dataset: Dataset, + randomization_mode: list[str], + *, + randomization_type: str = "permutation", + random_state: int | None = None, +) -> list[Dataset]: + """Generate randomized datasets for feature importance testing. + + For each randomization test (determined by model views and mode), produces + a copy of the dataset with the relevant views shuffled. Each returned + dataset has its ``randomization`` field set to ``(mode, view)``. + + :param model_class: Model class whose config defines available views. + :param dataset: Original dataset to randomize. + :param randomization_mode: List of mode codes (e.g. ["SVRC", "SVRD"]). + :param randomization_type: "permutation" or "invariant". + :param random_state: Seed for reproducibility. + :returns: List of randomized datasets. + """ + config = model_class.model_config() + cell_line_views = config.cell_line_views() + drug_views = config.drug_views() + + builders = { + "SVRC": lambda: _single_view_tests(cell_line_views, "SVRC"), + "SVCC": lambda: _complement_view_tests(cell_line_views, "SVCC"), + "SVRD": lambda: _single_view_tests(drug_views, "SVRD"), + "SVCD": lambda: _complement_view_tests(drug_views, "SVCD"), + } + + tests: dict[tuple[str, str], list[str]] = {} + for mode in randomization_mode: + if mode in builders: + tests.update(builders[mode]()) + + results: list[Dataset] = [] + for (mode, view), views in tests.items(): + ds = dataset.with_randomized_views( + views, randomization_type=randomization_type, random_state=random_state, randomization=(mode, view) + ) + results.append(ds) + return results diff --git a/drevalpy/experiment/_robustness.py b/drevalpy/experiment/_robustness.py new file mode 100644 index 000000000..b906ab18d --- /dev/null +++ b/drevalpy/experiment/_robustness.py @@ -0,0 +1,27 @@ +"""Robustness testing via pair-order shuffling.""" + +from __future__ import annotations + +from drevalpy.types import SplitMasks + + +def robustness(split_masks: SplitMasks, n_permutations: int) -> list[SplitMasks]: + """Generate shuffled copies of split_masks for robustness testing. + + Each returned SplitMasks has the same mask content but with .pairs in a + different random order (controlled by trial index as seed). This tests + model stability across different data presentation orders. + + :param split_masks: Original fold split masks. + :param n_permutations: Number of shuffled variants to generate. + :returns: List of SplitMasks with shuffled pair ordering, one per trial. + """ + return [ + SplitMasks( + train=split_masks.train.shuffled(seed=trial), + test=split_masks.test.shuffled(seed=trial), + val=split_masks.val.shuffled(seed=trial), + metadata={**split_masks.metadata, "robustness_trial": trial}, + ) + for trial in range(n_permutations) + ] diff --git a/drevalpy/log.py b/drevalpy/log.py new file mode 100644 index 000000000..fd696e920 --- /dev/null +++ b/drevalpy/log.py @@ -0,0 +1,40 @@ +"""Centralized logging for drevalpy using Rich.""" + +import logging + +from rich.logging import RichHandler + +_FORMAT = "%(message)s" +_LOG_LEVEL = logging.INFO + + +def setup_logging(level: int = _LOG_LEVEL) -> None: + """Configure the drevalpy logger with Rich output. + + Call this once at application startup. Subsequent calls update the level. + + :param level: Logging level (default: INFO). + """ + logger = logging.getLogger("drevalpy") + if not logger.handlers: + handler = RichHandler( + show_time=True, + show_path=False, + markup=True, + rich_tracebacks=True, + ) + handler.setFormatter(logging.Formatter(_FORMAT)) + logger.addHandler(handler) + logger.setLevel(level) + + +def get_logger(name: str) -> logging.Logger: + """Return a child logger under the drevalpy namespace. + + :param name: Module name (typically ``__name__``). + :returns: Logger instance. + """ + return logging.getLogger(name) + + +setup_logging() diff --git a/drevalpy/models/DIPK/data_utils.py b/drevalpy/models/DIPK/data_utils.py deleted file mode 100644 index c251371ec..000000000 --- a/drevalpy/models/DIPK/data_utils.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -Includes functions to load and process the DIPK dataset. - -- get_data: Creates a list of dictionaries with drug and cell line features. -- CollateFn: Class to collate the DataLoader batches. -- DIPKDataset: Dataset class for the DIPK model. - -""" - -import os -from abc import ABC - -import numpy as np -import pandas as pd -import torch -from torch.utils.data import Dataset - -from drevalpy.datasets.dataset import FeatureDataset - - -def load_bionic_features(data_path: str, dataset_name: str, gene_add_num: int = 512) -> FeatureDataset: - """ - Load biological network (BIONIC) features for DIPK. - - :param data_path: Path to the data, e.g., "data/" - :param dataset_name: Name of the dataset, e.g., GDSC2 - :param gene_add_num: Number of genes to add to the feature set - :returns: FeatureDataset with gene expression and biological network features - """ - # Load gene expression dataset - gene_expression_path = os.path.join(data_path, dataset_name, "gene_expression.csv") - gene_expression = pd.read_csv(gene_expression_path) - expression_dict = gene_expression.set_index("cell_line_name").drop("cellosaurus_id", axis=1).T.to_dict() - - # Load gene list and PPI features - gene_list_path = os.path.join(data_path, dataset_name, "DIPK_features", "gene_list_sel.txt") - with open(gene_list_path, encoding="gbk") as f: - gene_list = {line.strip() for line in f} - - ppi_path = os.path.join(data_path, dataset_name, "DIPK_features", "human_ppi_features.tsv") - dataset = pd.read_csv(ppi_path, index_col=0, sep="\t") - - # Ensure BIONIC dictionary uses gene names directly - bionic_gene_dict = {gene: dataset.loc[gene].values for gene in gene_list if gene in dataset.index} - - # Compute BIONIC features - bionic_feature_dict = {} - for cell_line, expressions in expression_dict.items(): - # Sort genes based on descending expression values - sorted_genes = sorted(expressions.items(), key=lambda x: -x[1]) - top_genes = [gene for gene, _ in sorted_genes[:gene_add_num]] - - # Aggregate BIONIC features for selected genes - selected_features = [bionic_gene_dict[gene] for gene in top_genes if gene in bionic_gene_dict] - if selected_features: - aggregated_feature = np.mean(selected_features, axis=0) - else: - # Handle case where no features are found (padding with zeros) - aggregated_feature = np.zeros(next(iter(bionic_gene_dict.values())).shape) - - bionic_feature_dict[cell_line] = aggregated_feature - - feature_data = {cell_line: {"bionic_features": features} for cell_line, features in bionic_feature_dict.items()} - return FeatureDataset(features=feature_data) - - -def get_data( - cell_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_features: FeatureDataset, - drug_features: FeatureDataset, - ic50: np.ndarray | None = None, -) -> list: - """ - Prepare data samples for training or prediction. - - Each sample includes: - - - Drug features (e.g., molecular embeddings). - - Cell line features (gene expression and bionic_features). - - Optional IC50 response values for supervised tasks. - - :param cell_ids: IDs of the cell lines from the dataset. - :param drug_ids: IDs of the drugs from the dataset. - :param cell_line_features: Input features associated with the cell lines. - :param drug_features: Input features associated with the drugs. - :param ic50: (Optional) Response values (e.g., IC50) to associate with samples. - :return: List of dictionaries, each containing drug and cell line features, with optional IC50. - """ - data_list = [] - for i in range(len(cell_ids)): - drug_id = str(drug_ids[i]) - cell_id = str(cell_ids[i]) - drug_tensor = torch.tensor(drug_features.features[drug_id]["molgnet_features"], dtype=torch.float32) - gene_expression = torch.tensor(cell_line_features.features[cell_id]["gene_expression"], dtype=torch.float32) - bionic_features = torch.tensor(cell_line_features.features[cell_id]["bionic_features"], dtype=torch.float32) - - sample = { - "molgnet_features": drug_tensor, - "gene_expression": gene_expression, - "bionic_features": bionic_features, - } - if ic50 is not None: - sample["ic50"] = torch.tensor([ic50[i]], dtype=torch.float32) - - data_list.append(sample) - - return data_list - - -class CollateFn: - """Collate function for the DataLoader, either for training or testing.""" - - def __init__(self, train=True): - """ - Initialize the CollateFn. - - :param train: indicates whether the DataLoader is used for training - """ - self.train = train - - def __call__(self, batch): - """ - Collate the batch. - - :param batch: batch of feature dictionaries - :returns: collated node features, gene features, bionic features, and (optional) IC50 values - """ - # Find the max number of atoms (nodes) in the batch for molgnet_features padding - max_atoms_molgnet = max([sample["molgnet_features"].size(0) for sample in batch]) - - # Pad molgnet_features to match the maximum number of atoms - padded_molgnet_features = [] - molgnet_mask = [] - - for sample in batch: - num_atoms = sample["molgnet_features"].size(0) - padding_size = max_atoms_molgnet - num_atoms - - # Pad molgnet_features - padded_features = torch.cat( - [sample["molgnet_features"], torch.zeros(padding_size, sample["molgnet_features"].size(1))], dim=0 - ) - padded_molgnet_features.append(padded_features) - - # Create a mask where valid atom features are True and padded ones are False - mask = torch.cat( - [torch.ones(num_atoms, dtype=torch.bool), torch.zeros(padding_size, dtype=torch.bool)], dim=0 - ) - molgnet_mask.append(mask) - - # Stack the padded molgnet features into a single tensor - molgnet_features = torch.stack(padded_molgnet_features) - molgnet_mask = torch.stack(molgnet_mask) - - # Collate other features - gene_features = torch.stack([sample["gene_expression"] for sample in batch]) - bionic_features = torch.stack([sample["bionic_features"] for sample in batch]) - - if self.train: - ic50_values = torch.stack([sample["ic50"] for sample in batch]) - # Return a dictionary with all features - return { - "molgnet_features": molgnet_features, - "gene_features": gene_features, - "bionic_features": bionic_features, - "ic50_values": ic50_values, - "molgnet_mask": molgnet_mask, - } - else: - # Return a dictionary without ic50_values for inference - return { - "molgnet_features": molgnet_features, - "gene_features": gene_features, - "bionic_features": bionic_features, - "molgnet_mask": molgnet_mask, - } - - -class DIPKDataset(Dataset, ABC): - """Dataset of graphs from get_data.""" - - def __init__(self, samples): - """ - Initialize the GraphDataset. - - :param samples: list - """ - super().__init__() - self._samples = samples - - def __getitem__(self, idx): - """ - Get the sample at index idx. - - :param idx: index - :returns: sample - """ - sample = self._samples[idx] - return sample - - def __len__(self) -> int: - """ - Get the number of graphs in the dataset. - - :return: number of samples - """ - return len(self._samples) diff --git a/drevalpy/models/DIPK/dipk.py b/drevalpy/models/DIPK/dipk.py deleted file mode 100644 index 1008dba24..000000000 --- a/drevalpy/models/DIPK/dipk.py +++ /dev/null @@ -1,440 +0,0 @@ -""" -DIPK model. Adapted from https://github.com/user15632/DIPK. - -Original publication: -Improving drug response prediction via integrating gene relationships with deep learning -Pengyong Li, Zhengxiang Jiang, Tianxiao Liu, Xinyu Liu, Hui Qiao, Xiaojun Yao -Briefings in Bioinformatics, Volume 25, Issue 3, May 2024, bbae153, https://doi.org/10.1093/bib/bbae153 -""" - -import json -import os -import secrets -from typing import Any, cast - -import numpy as np -import pandas as pd -import torch -import torch.optim as optim -from torch import nn -from torch.utils.data import DataLoader - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.models.drp_model import DRPModel -from drevalpy.models.utils import load_and_select_gene_features - -from .data_utils import CollateFn, DIPKDataset, get_data, load_bionic_features -from .gene_expression_encoder import GeneExpressionEncoder, encode_gene_expression, train_gene_expession_autoencoder -from .model_utils import Predictor - - -class DIPKModel(DRPModel): - """DIPK model. Adapted from https://github.com/user15632/DIPK.""" - - cell_line_views = ["gene_expression", "bionic_features"] - drug_views = ["molgnet_features"] - early_stopping = True - - def __init__(self) -> None: - """Initialize the DIPK model.""" - super().__init__() - self.DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") - # all of this gets initialized in build_model - self.model: Predictor | None = None - self.gene_expression_encoder: GeneExpressionEncoder | None = None - self.hyperparameters: dict[str, Any] = {} - - @classmethod - def get_model_name(cls) -> str: - """ - Get the model name. - - :returns: DIPK - """ - return "DIPK" - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - """ - Builds the DIPK model with the specified hyperparameters. - - :param hyperparameters: embedding_dim, heads, fc_layer_num, fc_layer_dim, dropout_rate, epochs, batch_size, lr - - Details of hyperparameters: - - - embedding_dim: int, embedding dimension used for the graph encoder which is not used in the final model - - heads: int, number of heads for the multi-head attention layer, defaults to 1 - - fc_layer_num: int, number of fully connected layers for the dense layers - - fc_layer_dim: list[int], number of neurons for each fully connected layer - - dropout_rate: float, dropout rate for all fully connected layers - - epochs: int, number of epochs to train the model - - batch_size: int, batch size for training - - lr: float, learning rate for training - """ - self.model = Predictor( - hyperparameters["heads"], - hyperparameters["fc_layer_num"], - hyperparameters["fc_layer_dim"], - hyperparameters["dropout_rate"], - ).to(self.DEVICE) - self.log_hyperparameters(hyperparameters) - self.hyperparameters = hyperparameters - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Trains the model. - - :param output: training data associated with the response output - :param cell_line_input: input data associated with the cell line - :param drug_input: input data associated with the drug - :param output_earlystopping: early stopping data associated with the response output - :param model_checkpoint_dir: directory to save the model checkpoint - :raises ValueError: if drug_input is None or if the model is not initialized - """ - if drug_input is None: - raise ValueError("DIPK model requires drug features.") - if not isinstance(self.model, Predictor): - raise ValueError("DIPK model not initialized.") - if output_earlystopping is None: - raise ValueError("DIPK model requires early stopping data.") - - loss_func = nn.MSELoss() - params = [{"params": self.model.parameters()}] - optimizer = optim.Adam(params, lr=self.hyperparameters["lr"]) - - train_gene_expression = cell_line_input.get_feature_matrix( - view="gene_expression", identifiers=output.cell_line_ids - ) - val_gene_expression = cell_line_input.get_feature_matrix( - view="gene_expression", identifiers=output_earlystopping.cell_line_ids - ) - - self.gene_expression_encoder = train_gene_expession_autoencoder( - train_gene_expression, - val_gene_expression, - epochs_autoencoder=self.hyperparameters["epochs_autoencoder"], - ) - self.hyperparameters["gene_encoder_input_dim"] = train_gene_expression.shape[1] - - cell_line_input.apply( - lambda x: encode_gene_expression(x, self.gene_expression_encoder), # type: ignore[arg-type] - view="gene_expression", - ) # type: ignore[arg-type] - - # Load data - collate = CollateFn(train=True) - train_samples = get_data( - cell_ids=output.cell_line_ids, - drug_ids=output.drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ic50=output.response, - ) - early_stopping_samples = get_data( - cell_ids=output_earlystopping.cell_line_ids, - drug_ids=output_earlystopping.drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ic50=output_earlystopping.response, - ) - - train_loader: DataLoader = DataLoader( - DIPKDataset(train_samples), batch_size=self.hyperparameters["batch_size"], shuffle=True, collate_fn=collate - ) - early_stopping_loader: DataLoader = DataLoader( - DIPKDataset(early_stopping_samples), - batch_size=self.hyperparameters["batch_size"], - shuffle=True, - collate_fn=collate, - ) - - # Early stopping parameters - best_val_loss = float("inf") - epochs_without_improvement = 0 - - # Ensure the checkpoint directory exists - os.makedirs(model_checkpoint_dir, exist_ok=True) - version = "version-" + "".join( - [secrets.choice("0123456789abcdef") for _ in range(20)] - ) # preventing conflicts of filenames - - checkpoint_path = os.path.join(model_checkpoint_dir, f"{version}_best_DIPK_model.pth") - - # Train model - print("Training DIPK model") - for epoch in range(self.hyperparameters["epochs"]): - self.model.train() - epoch_loss = 0.0 - batch_count = 0 - - # Training phase - for batch in train_loader: - drug_features = batch["molgnet_features"].to(self.DEVICE) - gene_features = batch["gene_features"].to(self.DEVICE) - bionic_features = batch["bionic_features"].to(self.DEVICE) - molgnet_mask = batch["molgnet_mask"].to(self.DEVICE) - ic50_values = batch["ic50_values"].to(self.DEVICE) - - # Forward pass - prediction = self.model( - molgnet_drug_features=drug_features, - gene_expression=gene_features, - bionic=bionic_features, - molgnet_mask=molgnet_mask, - ) - - # Compute the loss - loss = loss_func(torch.squeeze(prediction), torch.squeeze(ic50_values)) - - # Backpropagation - optimizer.zero_grad() - loss.backward() - optimizer.step() - - # Update loss and batch count - epoch_loss += loss.detach().item() - batch_count += 1 - - epoch_loss /= batch_count - print(f"DIPK: Epoch [{epoch + 1}] Training Loss: {epoch_loss:.4f}") - - # Validation phase for early stopping - self.model.eval() - val_loss = 0.0 - val_batch_count = 0 - with torch.no_grad(): - for batch in early_stopping_loader: - drug_features = batch["molgnet_features"].to(self.DEVICE) - gene_features = batch["gene_features"].to(self.DEVICE) - bionic_features = batch["bionic_features"].to(self.DEVICE) - molgnet_mask = batch["molgnet_mask"].to(self.DEVICE) - ic50_values = batch["ic50_values"].to(self.DEVICE) - - # Forward pass - prediction = self.model( - molgnet_drug_features=drug_features, - gene_expression=gene_features, - bionic=bionic_features, - molgnet_mask=molgnet_mask, - ) - - # Compute the loss - loss = loss_func(torch.squeeze(prediction), torch.squeeze(ic50_values)) - - # Update validation loss - val_loss += loss.item() - val_batch_count += 1 - - val_loss /= val_batch_count - print(f"DIPK: Epoch [{epoch + 1}] Validation Loss: {val_loss:.4f}") - - # Checkpointing: Save the best model - if val_loss < best_val_loss: - best_val_loss = val_loss - epochs_without_improvement = 0 - # Save the model checkpoint securely - torch.save(self.model.state_dict(), checkpoint_path) # noqa S614 - print(f"DIPK: Saved best model at epoch {epoch + 1}") - else: - epochs_without_improvement += 1 - if epochs_without_improvement >= self.hyperparameters["patience"]: - print(f"DIPK: Early stopping triggered at epoch {epoch + 1}") - break - - # Reload the best model after training - print("DIPK: Reloading the best model") - self.model.load_state_dict( - torch.load(checkpoint_path, map_location=self.DEVICE, weights_only=True) # noqa S614 - ) - self.model.to(self.DEVICE) # Ensure model is on the correct device - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the response values for the given cell lines and drugs. - - :param cell_line_ids: list of cell line IDs - :param drug_ids: list of drug IDs - :param cell_line_input: input data associated with the cell line - :param drug_input: input data associated with the drug - :return: predicted response values - :raises ValueError: if drug_input is None or if the model is not initialized or - if the gene expression encoder is not initialized - """ - if drug_input is None: - raise ValueError("DIPK model requires drug features.") - if not isinstance(self.model, Predictor): - raise ValueError("DIPK model not initialized.") - - # Encode gene expression data if this has not been done yet (e.g., for cross-study predictions) - if self.gene_expression_encoder is None: - raise ValueError("Gene expression encoder is not initialized.") - random_cell_line = next(iter(cell_line_input.features.keys())) - if ( - len(cell_line_input.features[random_cell_line]["gene_expression"]) - != self.gene_expression_encoder.latent_dim - ): - print("Encoding gene expression data for cross study prediction") - cell_line_input.apply( - lambda x: encode_gene_expression(x, self.gene_expression_encoder), # type: ignore[arg-type] - view="gene_expression", - ) # type: ignore[arg-type] - - # Load data - collate = CollateFn(train=False) - test_samples = get_data( - cell_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ) - test_loader: DataLoader = DataLoader( - DIPKDataset(test_samples), batch_size=self.hyperparameters["batch_size"], shuffle=False, collate_fn=collate - ) - - # Run prediction - self.model.eval() - predictions = [] - with torch.no_grad(): - for batch in test_loader: - drug_features = batch["molgnet_features"].to(self.DEVICE) - gene_features = batch["gene_features"].to(self.DEVICE) - bionic_features = batch["bionic_features"].to(self.DEVICE) - molgnet_mask = batch["molgnet_mask"].to(self.DEVICE) - - prediction = self.model( - molgnet_drug_features=drug_features, - gene_expression=gene_features, - bionic=bionic_features, - molgnet_mask=molgnet_mask, - ) - if prediction.numel() > 1: - predictions += torch.squeeze(prediction).cpu().tolist() - else: - predictions += [prediction.item()] - return np.array(predictions) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Load cell line features. - - :param data_path: path to the data - :param dataset_name: path to the dataset - :returns: cell line features - """ - # we use the interception of all genes that are present - # in the gene expression features of all datasets - gene_expression = load_and_select_gene_features( - feature_type="gene_expression", - gene_list="gene_expression_intersection", - data_path=data_path, - dataset_name=dataset_name, - ) - bionic_features = load_bionic_features( - data_path=data_path, - dataset_name=dataset_name, - ) - bionic_features.add_features(gene_expression) - - return bionic_features - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Load drug features. - - :param data_path: path to the data - :param dataset_name: path to the dataset - :returns: drug features - """ - - def load_feature(file_path, sep="\t"): - return np.array(pd.read_csv(file_path, index_col=0, sep=sep)) - - drug_path = os.path.join(data_path, dataset_name, "DIPK_features", "Drugs") - files_in_drug_path = os.listdir(drug_path) - drug_list = [ - file.split("_")[1].split(".csv")[0] - for file in files_in_drug_path - if file.endswith(".csv") and file.startswith("MolGNet") - ] - - f = FeatureDataset( - features={ - drug: { - "molgnet_features": load_feature(os.path.join(drug_path, f"MolGNet_{drug}.csv")), - } - for drug in drug_list - } - ) - - return f - - def save(self, directory: str) -> None: - """ - Save the DIPK model and gene expression encoder using PyTorch conventions. - - This method stores: - - - "dipk_model.pt": PyTorch state_dict of the DIPK predictor model - - "gene_encoder.pt": PyTorch state_dict of the trained gene expression encoder - - "hyperparameters.json": All hyperparameters including encoder input_dim - - :param directory: Target directory where the model files will be saved - :raises ValueError: If model or encoder is not built - """ - os.makedirs(directory, exist_ok=True) - if self.model is None or self.gene_expression_encoder is None: - raise ValueError("Cannot save model: model is not built.") - model = cast(Predictor, self.model) - - torch.save(model.state_dict(), os.path.join(directory, "dipk_model.pt")) # noqa: S614 - torch.save(self.gene_expression_encoder.state_dict(), os.path.join(directory, "gene_encoder.pt")) # noqa: S614 - with open(os.path.join(directory, "hyperparameters.json"), "w") as f: - json.dump(self.hyperparameters, f) - - @classmethod - def load(cls, directory: str) -> "DIPKModel": - """ - Load the DIPK model and gene expression encoder using PyTorch conventions. - - This method expects the following files in the given directory: - - - "dipk_model.pt": PyTorch state_dict of the DIPK predictor model - - "gene_encoder.pt": PyTorch state_dict of the gene expression encoder - - "hyperparameters.json": Dictionary of hyperparameters, must include "gene_encoder_input_dim" - - :param directory: Path to the directory containing the model files - :return: An instance of DIPK with loaded model and encoder - """ - instance = cls() - - with open(os.path.join(directory, "hyperparameters.json")) as f: - instance.hyperparameters = json.load(f) - - instance.build_model(instance.hyperparameters) - instance.model = cast(Predictor, instance.model) - - instance.model.load_state_dict( - torch.load(os.path.join(directory, "dipk_model.pt"), map_location=instance.DEVICE) # noqa: S614 - ) - instance.model.eval() - - input_dim = instance.hyperparameters["gene_encoder_input_dim"] - instance.gene_expression_encoder = GeneExpressionEncoder(input_dim=input_dim) - instance.gene_expression_encoder.load_state_dict( - torch.load(os.path.join(directory, "gene_encoder.pt"), map_location=instance.DEVICE) # noqa: S614 - ) - instance.gene_expression_encoder.eval() - - return instance diff --git a/drevalpy/models/DIPK/hyperparameters.yaml b/drevalpy/models/DIPK/hyperparameters.yaml deleted file mode 100644 index 0c8d036a1..000000000 --- a/drevalpy/models/DIPK/hyperparameters.yaml +++ /dev/null @@ -1,25 +0,0 @@ ---- -DIPK: - batch_size: - - 64 - lr: - - 0.0001 - heads: - - 2 - fc_layer_num: - - 3 - fc_layer_dim: - - - 256 - - 128 - - 64 - - 32 - - 16 - - 1 - dropout_rate: - - 0.3 - epochs: - - 100 - epochs_autoencoder: - - 100 - patience: - - 10 diff --git a/drevalpy/models/DrugGNN/__init__.py b/drevalpy/models/DrugGNN/__init__.py deleted file mode 100644 index 0eb635011..000000000 --- a/drevalpy/models/DrugGNN/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""A GNN based drug response prediction model.""" - -from .drug_gnn import DrugGNN - -__all__ = ["DrugGNN"] diff --git a/drevalpy/models/DrugGNN/drug_gnn.py b/drevalpy/models/DrugGNN/drug_gnn.py deleted file mode 100644 index 89f0fb7d9..000000000 --- a/drevalpy/models/DrugGNN/drug_gnn.py +++ /dev/null @@ -1,496 +0,0 @@ -"""DrugGNN model.""" - -import json -from pathlib import Path -from typing import Any - -import numpy as np -import pytorch_lightning as pl -import torch -import torch.nn as nn -from torch.optim import Adam -from torch.utils.data import Dataset as PytorchDataset -from torch_geometric.loader import DataLoader -from torch_geometric.nn import GCNConv, global_mean_pool - -from ...datasets.dataset import DrugResponseDataset, FeatureDataset -from ..drp_model import DRPModel -from ..lightning_metrics_mixin import RegressionMetricsMixin -from ..utils import load_and_select_gene_features - - -class DrugGraphNet(nn.Module): - """Neural network for DrugGNN.""" - - def __init__(self, num_node_features, num_cell_features, hidden_dim=64, dropout=0.2): - """Initialize the network. - - :param num_node_features: Number of features for each node in the drug graph. - :param num_cell_features: Number of features for the cell line. - :param hidden_dim: The hidden dimension size. - :param dropout: The dropout rate. - """ - super().__init__() - self.dropout = dropout - - # Drug Encoder (GNN) - self.conv1 = GCNConv(num_node_features, hidden_dim) - self.conv2 = GCNConv(hidden_dim, hidden_dim * 2) - self.conv3 = GCNConv(hidden_dim * 2, hidden_dim * 4) - self.drug_embed_fc = nn.Linear(hidden_dim * 4, hidden_dim) - - # Cell Line Encoder (MLP) - self.cell_fc1 = nn.Linear(num_cell_features, hidden_dim * 2) - self.cell_fc2 = nn.Linear(hidden_dim * 2, hidden_dim) - - # Combiner and Regressor - self.combiner_fc1 = nn.Linear(hidden_dim * 2, hidden_dim) - self.combiner_fc2 = nn.Linear(hidden_dim, 32) - self.output_fc = nn.Linear(32, 1) - - def forward(self, drug_graph, cell_features): - """Forward pass of the network. - - :param drug_graph: The drug graph. - :param cell_features: The cell line features. - :return: The output of the network. - """ - # Process drug graph - x, edge_index, batch = drug_graph.x, drug_graph.edge_index, drug_graph.batch - - x = self.conv1(x, edge_index) - x = nn.functional.relu(x) - x = nn.functional.dropout(x, p=self.dropout, training=self.training) - - x = self.conv2(x, edge_index) - x = nn.functional.relu(x) - x = nn.functional.dropout(x, p=self.dropout, training=self.training) - - x = self.conv3(x, edge_index) - x = nn.functional.relu(x) - - drug_embedding = global_mean_pool(x, batch) - drug_embedding = self.drug_embed_fc(drug_embedding) - - # Process cell line features - cell_embedding = nn.functional.relu(self.cell_fc1(cell_features)) - cell_embedding = nn.functional.dropout(cell_embedding, p=self.dropout, training=self.training) - cell_embedding = self.cell_fc2(cell_embedding) - - # Concatenate and predict - combined = torch.cat([drug_embedding, cell_embedding], dim=1) - x = nn.functional.relu(self.combiner_fc1(combined)) - x = nn.functional.dropout(x, p=self.dropout, training=self.training) - x = nn.functional.relu(self.combiner_fc2(x)) - x = nn.functional.dropout(x, p=self.dropout, training=self.training) - out = self.output_fc(x) - return out.view(-1) - - -class DrugGNNModule(RegressionMetricsMixin, pl.LightningModule): - """The LightningModule for the DrugGNN model.""" - - def __init__( - self, - num_node_features: int, - num_cell_features: int, - hidden_dim: int = 64, - dropout: float = 0.2, - learning_rate: float = 0.001, - ): - """Initialize the LightningModule. - - :param num_node_features: Number of features for each node in the drug graph. - :param num_cell_features: Number of features for the cell line. - :param hidden_dim: The hidden dimension size. - :param dropout: The dropout rate. - :param learning_rate: The learning rate. - """ - super().__init__() - self.save_hyperparameters() - self.model = DrugGraphNet( - num_node_features=self.hparams["num_node_features"], - num_cell_features=self.hparams["num_cell_features"], - hidden_dim=self.hparams["hidden_dim"], - dropout=self.hparams["dropout"], - ) - self.criterion = nn.MSELoss() - - # Initialize metrics storage for epoch-end R^2 and PCC computation - self._init_metrics_storage() - - def forward(self, batch): - """Forward pass of the module. - - :param batch: The batch. - :return: The output of the model. - """ - drug_graph, cell_features, _ = batch - return self.model(drug_graph, cell_features) - - def training_step(self, batch, batch_idx): - """A single training step. - - :param batch: The batch. - :param batch_idx: The batch index. - :return: The loss. - """ - drug_graph, cell_features, responses = batch - outputs = self.model(drug_graph, cell_features) - loss = self.criterion(outputs, responses) - self.log("train_loss", loss, on_step=False, on_epoch=True, batch_size=responses.size(0)) - - # Store predictions and targets for epoch-end metrics via mixin - self._store_predictions(outputs, responses, is_training=True) - - return loss - - def validation_step(self, batch, batch_idx): - """A single validation step. - - :param batch: The batch. - :param batch_idx: The batch index. - """ - drug_graph, cell_features, responses = batch - outputs = self.model(drug_graph, cell_features) - loss = self.criterion(outputs, responses) - self.log("val_loss", loss, on_step=False, on_epoch=True, batch_size=responses.size(0)) - - # Store predictions and targets for epoch-end metrics via mixin - self._store_predictions(outputs, responses, is_training=False) - - def predict_step(self, batch, batch_idx, dataloader_idx=0): - """A single prediction step. - - :param batch: The batch. - :param batch_idx: The batch index. - :param dataloader_idx: The dataloader index. - :return: The output of the model. - """ - return self.forward(batch) - - def configure_optimizers(self): - """Configure the optimizer. - - :return: The optimizer. - """ - return Adam(self.parameters(), lr=self.hparams.learning_rate) - - -class _DrugResponsePytorchDataset(PytorchDataset): - """A PyTorch Dataset to wrap the drug response data for DrugGNN.""" - - def __init__( - self, - response: np.ndarray, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_features: FeatureDataset, - drug_features: FeatureDataset, - ): - """Initialize the dataset. - - :param response: The drug response values. - :param cell_line_ids: The cell line IDs. - :param drug_ids: The drug IDs. - :param cell_line_features: A FeatureDataset object with cell line features. - :param drug_features: A FeatureDataset object with drug features. - """ - self.response = response - self.cell_line_ids = cell_line_ids - self.drug_ids = drug_ids - - # preconvert to tensors to avoid per item tensor creation - self.cell_features = { - cl_id: torch.tensor(features["gene_expression"], dtype=torch.float32) - for cl_id, features in cell_line_features.features.items() - } - self.response_tensor = torch.tensor(self.response, dtype=torch.float32) - - self.drug_graphs = { - drug_id: feature_views["drug_graph"] for drug_id, feature_views in drug_features.features.items() - } - - def __len__(self): - return len(self.response) - - def __getitem__(self, idx): - cell_line_id = self.cell_line_ids[idx] - drug_id = self.drug_ids[idx] - - drug_graph = self.drug_graphs[drug_id] - cell_feat = self.cell_features[cell_line_id] - response = self.response_tensor[idx] - - return drug_graph, cell_feat, response - - -class DrugGNN(DRPModel): - """DrugGNN model.""" - - def __init__(self): - """Initialize the DrugGNN model.""" - super().__init__() - self.model: DrugGNNModule | None = None - self.hyperparameters = {} - - @classmethod - def get_model_name(cls) -> str: - """Return the name of the model. - - :return: The name of the model. - """ - return "DrugGNN" - - @property - def cell_line_views(self) -> list[str]: - """Return the sources the model needs as input for describing the cell line. - - :return: The sources the model needs as input for describing the cell line. - """ - return ["gene_expression"] - - @property - def drug_views(self) -> list[str]: - """Return the sources the model needs as input for describing the drug. - - :return: The sources the model needs as input for describing the drug. - """ - return ["drug_graph"] - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - """Build the model. - - :param hyperparameters: The hyperparameters. - """ - # Log hyperparameters to wandb if enabled - self.log_hyperparameters(hyperparameters) - - self.hyperparameters = hyperparameters - - def _loader_kwargs(self) -> dict[str, Any]: - num_workers = int(self.hyperparameters.get("num_workers", 4)) - kw = { - "num_workers": num_workers, - "pin_memory": True, - } - if num_workers > 0: - kw["persistent_workers"] = True - kw["prefetch_factor"] = int(self.hyperparameters.get("prefetch_factor", 2)) - return kw - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - **kwargs, - ): - """Train the model. - - :param output: The output dataset. - :param cell_line_input: The cell line input dataset. - :param drug_input: The drug input dataset. - :param output_earlystopping: The early stopping output dataset. - :param kwargs: Additional arguments. - :raises ValueError: If drug input is not provided. - """ - if drug_input is None: - raise ValueError("Drug input is required for DrugGNN") - - # Determine feature sizes - num_node_features = next(iter(drug_input.features.values()))["drug_graph"].num_node_features - num_cell_features = next(iter(cell_line_input.features.values()))["gene_expression"].shape[0] - - self.model = DrugGNNModule( - num_node_features=num_node_features, - num_cell_features=num_cell_features, - hidden_dim=self.hyperparameters.get("hidden_dim", 64), - dropout=self.hyperparameters.get("dropout", 0.2), - learning_rate=self.hyperparameters.get("learning_rate", 0.001), - ) - - train_dataset = _DrugResponsePytorchDataset( - response=output.response, - cell_line_ids=output.cell_line_ids, - drug_ids=output.drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ) - train_loader = DataLoader( - train_dataset, - batch_size=self.hyperparameters.get("batch_size", 1024), - shuffle=True, - **self._loader_kwargs(), - ) - - val_loader = None - if output_earlystopping is not None and len(output_earlystopping) > 0: - val_dataset = _DrugResponsePytorchDataset( - response=output_earlystopping.response, - cell_line_ids=output_earlystopping.cell_line_ids, - drug_ids=output_earlystopping.drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ) - val_loader = DataLoader( - val_dataset, - batch_size=self.hyperparameters.get("batch_size", 32), - **self._loader_kwargs(), - ) - - # Set up wandb logger if project is provided - loggers = [] - if self.wandb_project is not None: - from pytorch_lightning.loggers import WandbLogger - - logger = WandbLogger(project=self.wandb_project, log_model=False) - loggers.append(logger) - - trainer = pl.Trainer( - max_epochs=self.hyperparameters.get("epochs", 100), - accelerator="auto", - devices="auto", - callbacks=[pl.callbacks.EarlyStopping(monitor="val_loss", mode="min", patience=5)] if val_loader else None, - logger=loggers if loggers else True, # Use default logger if no wandb - enable_progress_bar=True, - log_every_n_steps=int(self.hyperparameters.get("log_every_n_steps", 50)), - precision=self.hyperparameters.get("precision", 32), - ) - trainer.fit(self.model, train_dataloaders=train_loader, val_dataloaders=val_loader) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """Predict drug response. - - :param cell_line_ids: The cell line IDs. - :param drug_ids: The drug IDs. - :param cell_line_input: The cell line input dataset. - :param drug_input: The drug input dataset. - :raises RuntimeError: If the model has not been trained yet. - :raises ValueError: If drug input is not provided. - :return: The predicted drug response. - """ - if len(drug_ids) == 0 or len(cell_line_ids) == 0: - print("DrugGNN predict: No drug or cell line IDs provided; returning empty array.") - return np.array([]) - if self.model is None: - raise RuntimeError("Model has not been trained yet.") - if drug_input is None: - raise ValueError("Drug input is required for DrugGNN") - - self.model.eval() - - predict_dataset = _DrugResponsePytorchDataset( - response=np.zeros(len(cell_line_ids)), - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ) - predict_loader = DataLoader( - predict_dataset, - batch_size=self.hyperparameters.get("batch_size", 32), - **self._loader_kwargs(), - ) - - trainer = pl.Trainer(accelerator="auto", devices="auto", enable_progress_bar=False) - predictions_list = trainer.predict(self.model, dataloaders=predict_loader) - - if not predictions_list: - print("DrugGNN predict: No predictions were made; returning empty array.") - return np.array([]) - - predictions_flat = [ - item for sublist in predictions_list for item in (sublist if isinstance(sublist, list) else [sublist]) - ] - - predictions = torch.cat(predictions_flat).cpu().numpy() - return predictions - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """Loads the cell line features. - - :param data_path: Path to the gene expression and landmark genes - :param dataset_name: name of the dataset - :return: FeatureDataset containing the cell line gene expression features. - """ - return load_and_select_gene_features( - feature_type="gene_expression", - gene_list="landmark_genes_reduced", - data_path=data_path, - dataset_name=dataset_name, - ) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """Loads the pre-computed drug graph data. - - :param data_path: Path to the data directory. - :param dataset_name: Name of the dataset. - :raises FileNotFoundError: If the drug graph directory is not found. - :raises ValueError: If no drug graphs are loaded. - :return: FeatureDataset containing the drug graphs. - """ - graph_path = Path(data_path) / dataset_name / "drug_graphs" - if not graph_path.exists(): - raise FileNotFoundError( - f"Drug graph directory not found at {graph_path}. " - f"Please run 'create_drug_graphs.py' for the {dataset_name} dataset." - ) - - drug_graphs = {} - for p_file in graph_path.glob("*.pt"): - drug_id = p_file.stem - drug_graphs[drug_id] = torch.load(p_file, weights_only=False) # noqa: S614 - - if not drug_graphs: - raise ValueError(f"No drug graphs loaded from {graph_path}. Check the directory and file contents.") - - feature_dict = {drug_id: {"drug_graph": graph} for drug_id, graph in drug_graphs.items()} - - return FeatureDataset(features=feature_dict) - - def save_model(self, path: str | Path, drug_name=None): - """Save the model. - - :param path: The path to save the model to. - :param drug_name: The name of the drug. - :raises RuntimeError: If there is no model to save. - """ - if self.model is None: - raise RuntimeError("No model to save.") - path = Path(path) - path.mkdir(parents=True, exist_ok=True) - - trainer = pl.Trainer() - trainer.save_checkpoint(path / "model.ckpt", weights_only=True) - - with open(path / "config.json", "w") as f: - json.dump(self.hyperparameters, f, indent=4) - - def load_model(self, path: str | Path, drug_name=None): - """Load the model. - - :param path: The path to load the model from. - :param drug_name: The name of the drug. - """ - path = Path(path) - - config_path = path / "config.json" - with open(config_path) as f: - self.hyperparameters = json.load(f) - - self.model = DrugGNNModule.load_from_checkpoint( - path / "model.ckpt", - num_node_features=self.hyperparameters["num_node_features"], - num_cell_features=self.hyperparameters["num_cell_features"], - hidden_dim=self.hyperparameters.get("hidden_dim", 64), - dropout=self.hyperparameters.get("dropout", 0.2), - learning_rate=self.hyperparameters.get("learning_rate", 0.001), - ) diff --git a/drevalpy/models/DrugGNN/hyperparameters.yaml b/drevalpy/models/DrugGNN/hyperparameters.yaml deleted file mode 100644 index b028a3f06..000000000 --- a/drevalpy/models/DrugGNN/hyperparameters.yaml +++ /dev/null @@ -1,11 +0,0 @@ -DrugGNN: - learning_rate: - - 0.001 - epochs: - - 100 - hidden_dim: - - 64 - - 128 - dropout: - - 0.2 - - 0.3 diff --git a/drevalpy/models/MOLIR/hyperparameters.yaml b/drevalpy/models/MOLIR/hyperparameters.yaml deleted file mode 100644 index b35177b90..000000000 --- a/drevalpy/models/MOLIR/hyperparameters.yaml +++ /dev/null @@ -1,25 +0,0 @@ ---- -MOLIR: - mini_batch: - - 32 - h_dim1: - - 64 - - 16 - h_dim2: - - 64 - - 16 - h_dim3: - - 64 - - 16 - learning_rate: - - 0.01 - dropout_rate: - - 0.5 - weight_decay: - - 0.0001 - gamma: - - 0.5 - epochs: - - 30 - margin: - - 1.5 diff --git a/drevalpy/models/MOLIR/molir.py b/drevalpy/models/MOLIR/molir.py deleted file mode 100644 index 4aaab3464..000000000 --- a/drevalpy/models/MOLIR/molir.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Contains the MOLIR model, a regression adaptation of the MOLI model. - -Original authors: Sharifi-Noghabi et al. (2019, 10.1093/bioinformatics/btz318) -Code adapted from their Github: https://github.com/hosseinshn/MOLI -and Hauptmann et al. (2023, 10.1186/s12859-023-05166-7) https://github.com/kramerlab/Multi-Omics_analysis -""" - -from typing import Any - -import numpy as np -from sklearn.preprocessing import StandardScaler - -from ...datasets.dataset import DrugResponseDataset, FeatureDataset -from ..drp_model import DRPModel -from ..utils import VarianceFeatureSelector, get_multiomics_feature_dataset, scale_gene_expression -from .utils import MOLIModel, filter_and_sort_omics, get_dimensions_of_omics_data - - -class MOLIR(DRPModel): - """ - Regression extension of MOLI: multi-omics late integration deep neural network. - - Takes somatic mutation, copy number variation and gene expression data as input. MOLI uses type-specific encoding - subnetworks to learn features for each omics type, concatenates them into one representation and optimizes this - representation via a combined cost function consisting of a triplet loss and a binary cross-entropy loss. - We use a regression adaption with MSE loss and a mechanism to find positive and negative samples. - """ - - is_single_drug_model = True - cell_line_views = ["gene_expression", "mutations", "copy_number_variation_gistic"] - drug_views = [] - early_stopping = True - - def __init__(self) -> None: - """ - Initializes the MOLIR model. - - The hyperparameters are set in build_model, the model is set in train when we know the dimensionality of the - gene expression, mutation and copy number variation data. - """ - super().__init__() - self.model: MOLIModel | None = None - self.hyperparameters: dict[str, Any] = dict() - self.gene_expression_features = None - self.mutations_features = None - self.copy_number_variation_features = None - self.gene_expression_scaler = StandardScaler() - self.selector: VarianceFeatureSelector | None = None - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: MOLIR - """ - return "MOLIR" - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - """ - Builds the model from hyperparameters. - - :param hyperparameters: Custom hyperparameters for the model, includes mini_batch, layer dimensions (h_dim1, - h_dim2, h_dim3), learning_rate, dropout_rate, weight_decay, gamma, epochs, and margin. - """ - # Log hyperparameters to wandb if enabled - self.log_hyperparameters(hyperparameters) - - self.hyperparameters = hyperparameters - self.selector = VarianceFeatureSelector( - view="gene_expression", k=hyperparameters.get("n_gene_expression_features", 1000) - ) - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Initializes and trains the model. - - First, the gene expression data was reduced using a variance threshold (0.05) and standardized. We chose to use - the most variable 1000 genes instead to avoid issues with the variance threshold. - Then, the model is initialized with the hyperparameters and the dimensions of the gene expression, mutation and - copy number variation data. If there is no training data, the model is set to None (and predictions will be - skipped as well). If there is not enough training data, the predictions will be made on the randomly - initialized model. - - :param output: drug response data - :param cell_line_input: cell line omics features, i.e., gene expression, mutations and copy number variation - :param drug_input: drug features, not needed - :param output_earlystopping: early stopping data, not used when there is not enough data - :param model_checkpoint_dir: directory to save the model checkpoints - :raises ValueError: If drug_input is None. - """ - if len(output) > 0: - cell_line_input = scale_gene_expression( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - gene_expression_scaler=self.gene_expression_scaler, - ) - if self.selector is None: - raise ValueError("Feature selector not initialized. Build the model first.") - self.selector.fit(cell_line_input, output) - cell_line_input = self.selector.transform(cell_line_input) - - self.gene_expression_features = cell_line_input.meta_info["gene_expression"] - self.mutations_features = cell_line_input.meta_info["mutations"] - self.copy_number_variation_features = cell_line_input.meta_info["copy_number_variation_gistic"] - - if output_earlystopping is not None and self.early_stopping and len(output_earlystopping) < 2: - output_earlystopping = None - dim_gex, dim_mut, dim_cnv = get_dimensions_of_omics_data(cell_line_input) - self.model = MOLIModel( - hpams=self.hyperparameters, - input_dim_expr=dim_gex, - input_dim_mut=dim_mut, - input_dim_cnv=dim_cnv, - ) - if len(output) >= self.hyperparameters["mini_batch"]: - self.model.fit( - output_train=output, - cell_line_input=cell_line_input, - output_earlystopping=output_earlystopping, - model_checkpoint_dir=model_checkpoint_dir, - wandb_project=self.wandb_project, - ) - else: - print(f"Not enough training data provided ({len(output)}), will predict on randomly initialized model.") - else: - print("No training data provided, skipping model") - self.model = None - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the drug response. - - If there was no training data, only nans will be returned. - - :param cell_line_ids: Cell lines to predict - :param drug_ids: Drugs to predict - :param cell_line_input: cell line omics features - :param drug_input: drug features, not needed - :returns: Predicted drug response - :raises ValueError: If the model was not trained - """ - if self.model is None: - print("No model trained, will predict NA.") - return np.array([np.nan] * len(cell_line_ids)) - if ( - (self.gene_expression_features is None) - or (self.mutations_features is None) - or (self.copy_number_variation_features is None) - ): - raise ValueError("MOLIR Model not trained, please train the model first.") - - cell_line_input = scale_gene_expression( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - gene_expression_scaler=self.gene_expression_scaler, - ) - # Apply variance threshold to gene expression features - - if self.selector is None: - raise ValueError("Feature selector not initialized. Train the model first.") - cell_line_input = self.selector.transform(cell_line_input) - - input_data = self.get_feature_matrices( - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - gene_expression, mutations, cnvs = ( - input_data["gene_expression"], - input_data["mutations"], - input_data["copy_number_variation_gistic"], - ) - - gene_expression, mutations, cnvs = filter_and_sort_omics( - model=self, gene_expression=gene_expression, mutations=mutations, cnvs=cnvs, cell_line_input=cell_line_input - ) - - return self.model.predict(gene_expression, mutations, cnvs) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features: gene expression, mutations and copy number variation. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset with gene expression, mutations and copy number variation - """ - feature_dataset = get_multiomics_feature_dataset( - data_path=data_path, - dataset_name=dataset_name, - gene_lists={ - "gene_expression": "gene_expression_intersection", - "mutations": "mutations_intersection", - "copy_number_variation_gistic": "copy_number_variation_gistic_intersection", - }, - omics=self.cell_line_views, - ) - - return feature_dataset - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset | None: - """ - Returns None, as drug features are not needed for MOLIR. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: None - """ - return None diff --git a/drevalpy/models/PharmaFormer/__init__.py b/drevalpy/models/PharmaFormer/__init__.py deleted file mode 100644 index f78b623a0..000000000 --- a/drevalpy/models/PharmaFormer/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""PharmaFormer model.""" - -from .pharmaformer import PharmaFormerModel - -__all__ = ["PharmaFormerModel"] diff --git a/drevalpy/models/PharmaFormer/hyperparameters.yaml b/drevalpy/models/PharmaFormer/hyperparameters.yaml deleted file mode 100644 index 2982f0c1c..000000000 --- a/drevalpy/models/PharmaFormer/hyperparameters.yaml +++ /dev/null @@ -1,33 +0,0 @@ ---- -PharmaFormer: - gene_hidden_size: - - 2048 - - 4096 - drug_hidden_size: - - 128 - - 256 - feature_dim: - - 64 - - 128 - nhead: - - 4 - - 8 - num_layers: - - 2 - - 3 - dim_feedforward: - - 1024 - - 2048 - dropout: - - 0.1 - - 0.2 - batch_size: - - 64 - - 128 - lr: - - 0.00001 - - 0.0001 - epochs: - - 100 - patience: - - 10 diff --git a/drevalpy/models/PharmaFormer/pharmaformer.py b/drevalpy/models/PharmaFormer/pharmaformer.py deleted file mode 100644 index e7afe73ee..000000000 --- a/drevalpy/models/PharmaFormer/pharmaformer.py +++ /dev/null @@ -1,510 +0,0 @@ -""" -Contains PharmaFormer, a transformer-based deep learning model for drug response prediction. - -A Transformer-based deep learning model designed to predict clinical drug responses -by integrating gene expression profiles and drug molecular structures. - -Original authors: Zhou et al. (2025, 10.1038/s41698-025-01082-6) -Code adapted from their Github: https://github.com/zhouyuru1205/PharmaFormer -""" - -import json -import os -import secrets -from typing import Any, cast - -import numpy as np -import pandas as pd -import torch -import torch.nn as nn -import torch.optim as optim -from sklearn.preprocessing import MinMaxScaler, StandardScaler -from torch.utils.data import DataLoader, Dataset - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.models.drp_model import DRPModel -from drevalpy.models.utils import load_and_select_gene_features - -from .model_utils import CombinedModel - - -class _PharmaFormerDataset(Dataset): - """PyTorch Dataset for PharmaFormer model.""" - - def __init__( - self, - response: np.ndarray, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_features: FeatureDataset, - drug_features: FeatureDataset, - ): - """ - Initialize the dataset. - - :param response: Drug response values - :param cell_line_ids: Cell line identifiers - :param drug_ids: Drug identifiers - :param cell_line_features: FeatureDataset with cell line features - :param drug_features: FeatureDataset with drug features - """ - self.response = response - self.cell_line_ids = cell_line_ids - self.drug_ids = drug_ids - self.cell_line_features = cell_line_features - self.drug_features = drug_features - - def __len__(self) -> int: - """Return the length of the dataset. - - :return: Length of the dataset - """ - return len(self.response) - - def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - """ - Get a single item from the dataset. - - :param idx: Index of the item - :return: Tuple of (gene_features, drug_features, response) - """ - cell_line_id = self.cell_line_ids[idx] - drug_id = self.drug_ids[idx] - - gene_features = torch.tensor( - self.cell_line_features.features[cell_line_id]["gene_expression"], dtype=torch.float32 - ) - drug_features = torch.tensor(self.drug_features.features[drug_id]["bpe_smiles"], dtype=torch.float32) - response = torch.tensor(self.response[idx], dtype=torch.float32) - - return gene_features, drug_features, response - - -class PharmaFormerModel(DRPModel): - """PharmaFormer model for drug response prediction.""" - - cell_line_views = ["gene_expression"] - drug_views = ["bpe_smiles"] - early_stopping = True - - def __init__(self) -> None: - """Initialize the PharmaFormer model.""" - super().__init__() - self.DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") - self.model: CombinedModel | None = None - self.hyperparameters: dict[str, Any] = {} - self.gene_expression_scaler: StandardScaler | None = None - self.gene_expression_normalizer: MinMaxScaler | None = None - - @classmethod - def get_model_name(cls) -> str: - """ - Get the model name. - - :returns: PharmaFormer - """ - return "PharmaFormer" - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - """ - Builds the PharmaFormer model with the specified hyperparameters. - - :param hyperparameters: Model hyperparameters including gene_hidden_size, drug_hidden_size, - feature_dim, nhead, num_layers, dim_feedforward, dropout, batch_size, lr, epochs, patience - """ - # Log hyperparameters to wandb if enabled - self.log_hyperparameters(hyperparameters) - - self.hyperparameters = hyperparameters - # Model will be built in train() when we know the input dimensions - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Trains the model. - - :param output: training data associated with the response output - :param cell_line_input: input data associated with the cell line - :param drug_input: input data associated with the drug - :param output_earlystopping: early stopping data associated with the response output - :param model_checkpoint_dir: directory to save the model checkpoint - :raises ValueError: if drug_input is None or if early stopping data is missing - """ - if drug_input is None: - raise ValueError("PharmaFormer model requires drug features.") - if output_earlystopping is None: - raise ValueError("PharmaFormer model requires early stopping data.") - - # Get feature dimensions - train_gene_features = cell_line_input.get_feature_matrix( - view="gene_expression", identifiers=output.cell_line_ids - ) - gene_input_size = train_gene_features.shape[1] - - # Standardize and normalize gene expression (matching original PharmaFormer) - self.gene_expression_scaler = StandardScaler() - self.gene_expression_normalizer = MinMaxScaler() - - train_gene_scaled = self.gene_expression_scaler.fit_transform(train_gene_features) - self.gene_expression_normalizer.fit_transform(train_gene_scaled) - - # Apply transformations to all gene expression features - cell_line_input = cell_line_input.copy() - for cell_line_id in cell_line_input.features: - gene_expr = cell_line_input.features[cell_line_id]["gene_expression"] - gene_expr_scaled = self.gene_expression_scaler.transform(gene_expr.reshape(1, -1)) - gene_expr_normalized = self.gene_expression_normalizer.transform(gene_expr_scaled) - cell_line_input.features[cell_line_id]["gene_expression"] = gene_expr_normalized.flatten() - - # Build model with known input dimensions - self.model = CombinedModel( - gene_input_size=gene_input_size, - gene_hidden_size=self.hyperparameters["gene_hidden_size"], - drug_hidden_size=self.hyperparameters["drug_hidden_size"], - feature_dim=self.hyperparameters["feature_dim"], - nhead=self.hyperparameters["nhead"], - num_layers=self.hyperparameters.get("num_layers", 3), - dim_feedforward=self.hyperparameters.get("dim_feedforward", 2048), - dropout=self.hyperparameters.get("dropout", 0.1), - ).to(self.DEVICE) - - loss_func = nn.MSELoss() - optimizer = optim.Adam(self.model.parameters(), lr=self.hyperparameters["lr"]) - - # Create datasets - train_dataset = _PharmaFormerDataset( - response=output.response, - cell_line_ids=output.cell_line_ids, - drug_ids=output.drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ) - early_stopping_dataset = _PharmaFormerDataset( - response=output_earlystopping.response, - cell_line_ids=output_earlystopping.cell_line_ids, - drug_ids=output_earlystopping.drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ) - - train_loader = DataLoader( - train_dataset, - batch_size=self.hyperparameters["batch_size"], - shuffle=True, - ) - early_stopping_loader = DataLoader( - early_stopping_dataset, - batch_size=self.hyperparameters["batch_size"], - shuffle=False, - ) - - # Early stopping parameters - best_val_loss = float("inf") - epochs_without_improvement = 0 - - # Ensure the checkpoint directory exists - os.makedirs(model_checkpoint_dir, exist_ok=True) - version = "version-" + "".join([secrets.choice("0123456789abcdef") for _ in range(20)]) - - checkpoint_path = os.path.join(model_checkpoint_dir, f"{version}_best_PharmaFormer_model.pth") - - # Train model - print("Training PharmaFormer model") - for epoch in range(self.hyperparameters["epochs"]): - self.model.train() - epoch_loss = 0.0 - batch_count = 0 - train_predictions = [] - train_targets = [] - - # Training phase - for gene_inputs, smiles_inputs, targets in train_loader: - gene_inputs = gene_inputs.to(self.DEVICE) - smiles_inputs = smiles_inputs.to(self.DEVICE) - targets = targets.to(self.DEVICE) - - # Forward pass - outputs = self.model(gene_inputs, smiles_inputs) - loss = loss_func(outputs.squeeze(), targets) - - # Backpropagation - optimizer.zero_grad() - loss.backward() - optimizer.step() - - epoch_loss += loss.detach().item() - batch_count += 1 - - # Store predictions and targets for R^2 and PCC computation - train_predictions.append(outputs.squeeze().detach().cpu().numpy()) - train_targets.append(targets.detach().cpu().numpy()) - - epoch_loss /= batch_count - print(f"PharmaFormer: Epoch [{epoch + 1}/{self.hyperparameters['epochs']}] Training Loss: {epoch_loss:.4f}") - - # Compute and log training R^2 and PCC using DRPModel helper - train_metrics = {"train_loss": epoch_loss} - if len(train_predictions) > 0: - all_train_preds = np.concatenate(train_predictions) - all_train_targets = np.concatenate(train_targets) - perf_metrics = self.compute_performance_metrics(all_train_preds, all_train_targets, prefix="train_") - train_metrics.update(perf_metrics) - - # Log training metrics to wandb if enabled - if self.is_wandb_enabled(): - self.log_metrics(train_metrics, step=epoch) - - # Validation phase for early stopping - self.model.eval() - val_loss = 0.0 - val_batch_count = 0 - val_predictions = [] - val_targets = [] - with torch.no_grad(): - for gene_inputs, smiles_inputs, targets in early_stopping_loader: - gene_inputs = gene_inputs.to(self.DEVICE) - smiles_inputs = smiles_inputs.to(self.DEVICE) - targets = targets.to(self.DEVICE) - - outputs = self.model(gene_inputs, smiles_inputs) - loss = loss_func(outputs.squeeze(), targets) - - val_loss += loss.item() - val_batch_count += 1 - - # Store predictions and targets for R^2 and PCC computation - val_predictions.append(outputs.squeeze().detach().cpu().numpy()) - val_targets.append(targets.detach().cpu().numpy()) - - val_loss /= val_batch_count - print(f"PharmaFormer: Epoch [{epoch + 1}/{self.hyperparameters['epochs']}] Validation Loss: {val_loss:.4f}") - - # Compute and log validation R^2 and PCC using DRPModel helper - val_metrics = {"val_loss": val_loss} - if len(val_predictions) > 0: - all_val_preds = np.concatenate(val_predictions) - all_val_targets = np.concatenate(val_targets) - perf_metrics = self.compute_performance_metrics(all_val_preds, all_val_targets, prefix="val_") - val_metrics.update(perf_metrics) - - # Log validation metrics to wandb if enabled - if self.is_wandb_enabled(): - self.log_metrics(val_metrics, step=epoch) - - # Checkpointing: Save the best model - if val_loss < best_val_loss: - best_val_loss = val_loss - epochs_without_improvement = 0 - torch.save(self.model.state_dict(), checkpoint_path) # noqa: S614 - print(f"PharmaFormer: Saved best model at epoch {epoch + 1}") - else: - epochs_without_improvement += 1 - if epochs_without_improvement >= self.hyperparameters.get("patience", 10): - print(f"PharmaFormer: Early stopping triggered at epoch {epoch + 1}") - break - - # Reload the best model after training - print("PharmaFormer: Reloading the best model") - self.model.load_state_dict( - torch.load(checkpoint_path, map_location=self.DEVICE, weights_only=True) - ) # noqa: S614 - self.model.to(self.DEVICE) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the response values for the given cell lines and drugs. - - :param cell_line_ids: list of cell line IDs - :param drug_ids: list of drug IDs - :param cell_line_input: input data associated with the cell line - :param drug_input: input data associated with the drug - :return: predicted response values - :raises ValueError: if drug_input is None or if the model is not initialized - """ - if drug_input is None: - raise ValueError("PharmaFormer model requires drug features.") - if self.model is None: - raise ValueError("PharmaFormer model not initialized.") - - # Apply transformations to gene expression if scalers are available - if self.gene_expression_scaler is not None and self.gene_expression_normalizer is not None: - cell_line_input = cell_line_input.copy() - for cell_line_id in cell_line_ids: - if cell_line_id in cell_line_input.features: - gene_expr = cell_line_input.features[cell_line_id]["gene_expression"] - gene_expr_scaled = self.gene_expression_scaler.transform(gene_expr.reshape(1, -1)) - gene_expr_normalized = self.gene_expression_normalizer.transform(gene_expr_scaled) - cell_line_input.features[cell_line_id]["gene_expression"] = gene_expr_normalized.flatten() - - # Create dataset - predict_dataset = _PharmaFormerDataset( - response=np.zeros(len(cell_line_ids)), - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ) - - predict_loader = DataLoader( - predict_dataset, batch_size=self.hyperparameters.get("batch_size", 64), shuffle=False - ) - - # Run prediction - self.model.eval() - predictions = [] - with torch.no_grad(): - for gene_inputs, smiles_inputs, _ in predict_loader: - gene_inputs = gene_inputs.to(self.DEVICE) - smiles_inputs = smiles_inputs.to(self.DEVICE) - - outputs = self.model(gene_inputs, smiles_inputs) - if outputs.numel() > 1: - predictions += outputs.squeeze().cpu().tolist() - else: - predictions += [outputs.item()] - - return np.array(predictions) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Load cell line features. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: cell line features - """ - return load_and_select_gene_features( - feature_type="gene_expression", - gene_list="landmark_genes_reduced", - data_path=data_path, - dataset_name=dataset_name, - ) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Load drug features (BPE-encoded SMILES). - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: drug features - :raises FileNotFoundError: if the BPE SMILES file is not found - """ - bpe_smiles_file = os.path.join(data_path, dataset_name, "drug_bpe_smiles.csv") - if not os.path.exists(bpe_smiles_file): - raise FileNotFoundError( - f"BPE SMILES file not found: {bpe_smiles_file}. " - "Please run the BPE featurizer first: " - "python -m drevalpy.datasets.featurizer.create_pharmaformer_drug_embeddings " - ) - - bpe_df = pd.read_csv(bpe_smiles_file, dtype={"pubchem_id": str}) - features = {} - for _, row in bpe_df.iterrows(): - drug_id = row["pubchem_id"] - # Extract all feature columns (excluding pubchem_id) - embedding = row.drop("pubchem_id").values.astype(np.float32) - features[drug_id] = {"bpe_smiles": embedding} - - return FeatureDataset(features) - - def save(self, directory: str) -> None: - """ - Save the PharmaFormer model using PyTorch conventions. - - This method stores: - - - "pharmaformer_model.pt": PyTorch state_dict of the model - - "hyperparameters.json": All hyperparameters - - "gene_scaler.pkl": Fitted StandardScaler for gene expression - - "gene_normalizer.pkl": Fitted MinMaxScaler for gene expression - - :param directory: Target directory where the model files will be saved - :raises ValueError: If model is not built - """ - import joblib - - os.makedirs(directory, exist_ok=True) - if self.model is None: - raise ValueError("Cannot save model: model is not built.") - - model = cast(CombinedModel, self.model) - - torch.save(model.state_dict(), os.path.join(directory, "pharmaformer_model.pt")) # noqa: S614 - - # Save hyperparameters including gene_input_size - save_hyperparameters = self.hyperparameters.copy() - if self.model is not None: - # Extract gene_input_size from the model - save_hyperparameters["gene_input_size"] = self.model.feature_extractor.gene_fc1.in_features - - with open(os.path.join(directory, "hyperparameters.json"), "w") as f: - json.dump(save_hyperparameters, f) - - if self.gene_expression_scaler is not None: - joblib.dump(self.gene_expression_scaler, os.path.join(directory, "gene_scaler.pkl")) - if self.gene_expression_normalizer is not None: - joblib.dump(self.gene_expression_normalizer, os.path.join(directory, "gene_normalizer.pkl")) - - @classmethod - def load(cls, directory: str) -> "PharmaFormerModel": - """ - Load the PharmaFormer model using PyTorch conventions. - - This method expects the following files in the given directory: - - - "pharmaformer_model.pt": PyTorch state_dict of the model - - "hyperparameters.json": Dictionary of hyperparameters - - "gene_scaler.pkl": Fitted StandardScaler (optional) - - "gene_normalizer.pkl": Fitted MinMaxScaler (optional) - - :param directory: Path to the directory containing the model files - :return: An instance of PharmaFormerModel with loaded model - """ - import joblib - - instance = cls() - - with open(os.path.join(directory, "hyperparameters.json")) as f: - instance.hyperparameters = json.load(f) - - # Load scalers if they exist - scaler_path = os.path.join(directory, "gene_scaler.pkl") - normalizer_path = os.path.join(directory, "gene_normalizer.pkl") - if os.path.exists(scaler_path): - instance.gene_expression_scaler = joblib.load(scaler_path) - if os.path.exists(normalizer_path): - instance.gene_expression_normalizer = joblib.load(normalizer_path) - - # Model will be built when needed (requires input dimensions) - # For now, we'll need to rebuild it with the saved hyperparameters - # This requires knowing the gene_input_size, which should be saved in hyperparameters - if "gene_input_size" in instance.hyperparameters: - instance.model = CombinedModel( - gene_input_size=instance.hyperparameters["gene_input_size"], - gene_hidden_size=instance.hyperparameters["gene_hidden_size"], - drug_hidden_size=instance.hyperparameters["drug_hidden_size"], - feature_dim=instance.hyperparameters["feature_dim"], - nhead=instance.hyperparameters["nhead"], - num_layers=instance.hyperparameters.get("num_layers", 3), - dim_feedforward=instance.hyperparameters.get("dim_feedforward", 2048), - dropout=instance.hyperparameters.get("dropout", 0.1), - ).to(instance.DEVICE) - - instance.model.load_state_dict( - torch.load(os.path.join(directory, "pharmaformer_model.pt"), map_location=instance.DEVICE) # noqa: S614 - ) - instance.model.eval() - - return instance diff --git a/drevalpy/models/Precily/__init__.py b/drevalpy/models/Precily/__init__.py deleted file mode 100644 index 74d61a136..000000000 --- a/drevalpy/models/Precily/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Precily model.""" - -from .precily import PrecilyModel - -__all__ = ["PrecilyModel"] diff --git a/drevalpy/models/Precily/hyperparameters.yaml b/drevalpy/models/Precily/hyperparameters.yaml deleted file mode 100644 index 398a578ea..000000000 --- a/drevalpy/models/Precily/hyperparameters.yaml +++ /dev/null @@ -1,15 +0,0 @@ -Precily: - learning_rate: - - 1.0e-3 - - 1.0e-4 - - 1.0e-5 - dropout: - - 0.1 - - 0.2 - - 0.3 - epochs: - - 50 - batch_size: - - 128 - seed: - - 42 diff --git a/drevalpy/models/Precily/precily.py b/drevalpy/models/Precily/precily.py deleted file mode 100644 index 4fb26d72f..000000000 --- a/drevalpy/models/Precily/precily.py +++ /dev/null @@ -1,353 +0,0 @@ -r""" -Precily model for drug response prediction. - -Contains Precily, a pathway-based deep learning model for drug response -prediction. A deep neural network that predicts LN(IC50) by combining -GSVA pathway-activity scores with SMILESVec drug embeddings. - -Original authors: Chawla et al. (2022, 10.1038/s41467-022-33291-z) -Reference code: https://github.com/SmritiChawla/Precily - -""" - -import json -import os -from typing import Any, cast - -import numpy as np -import pandas as pd -import torch -import torch.nn as nn -import torch.optim as optim -from torch.utils.data import DataLoader, Dataset - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.models.drp_model import DRPModel - -from .model_utils import PrecilyNetwork - - -class _PrecilyDataset(Dataset): - """PyTorch Dataset yielding (pathway_features, drug_features, response).""" - - def __init__( - self, - response: np.ndarray, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_features: FeatureDataset, - drug_features: FeatureDataset, - ): - """ - Initialize the dataset. - - :param response: drug response values - :param cell_line_ids: cell line identifiers - :param drug_ids: drug identifiers - :param cell_line_features: FeatureDataset with the "pathways" view - :param drug_features: FeatureDataset with the "smilesvec" view - """ - self.response = response - self.cell_line_ids = cell_line_ids - self.drug_ids = drug_ids - self.cell_line_features = cell_line_features - self.drug_features = drug_features - - def __len__(self) -> int: - """ - Return the number of samples. - - :return: Number of samples in the dataset. - """ - return len(self.response) - - def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - """ - Get a single sample by index. - - :param idx: sample index - :return: (pathway_features, drug_features, response) tensors - """ - cell_line_id = self.cell_line_ids[idx] - drug_id = self.drug_ids[idx] - - pathway = torch.tensor(self.cell_line_features.features[cell_line_id]["pathways"], dtype=torch.float32) - drug = torch.tensor(self.drug_features.features[drug_id]["smilesvec"], dtype=torch.float32) - response = torch.tensor(self.response[idx], dtype=torch.float32) - - return pathway, drug, response - - -class PrecilyModel(DRPModel): - """Precily model for drug response prediction.""" - - cell_line_views = ["pathways"] - drug_views = ["smilesvec"] - early_stopping = False - - def __init__(self) -> None: - """Initialize the Precily model.""" - super().__init__() - self.DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") - self.model: PrecilyNetwork | None = None - self.hyperparameters: dict[str, Any] = {} - - @classmethod - def get_model_name(cls) -> str: - """ - Get the model name. - - :returns: Precily - """ - return "Precily" - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - """ - Store hyperparameters. - - The network is built in train() once the input dimension - (n_pathways + n_drug_features) is known. - - :param hyperparameters: dropout, learning_rate, epochs, batch_size, seed - """ - self.log_hyperparameters(hyperparameters) - self.hyperparameters = hyperparameters - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Train the Precily model. - - :param output: training response data - :param cell_line_input: cell line pathway features - :param drug_input: drug SMILESVec features - :param output_earlystopping: unused - :param model_checkpoint_dir: unused - :raises ValueError: if drug_input is None - """ - if drug_input is None: - raise ValueError("Precily model requires drug features.") - - # Resolve input dimension from the feature matrices. - n_pathways = len(next(iter(cell_line_input.features.values()))["pathways"]) - drug_dim = len(next(iter(drug_input.features.values()))["smilesvec"]) - input_dim = n_pathways + drug_dim - - self.model = PrecilyNetwork( - input_dim=input_dim, - dropout=self.hyperparameters.get("dropout", 0.1), - ).to(self.DEVICE) - - loss_func = nn.MSELoss() - optimizer = optim.Adam(self.model.parameters(), lr=self.hyperparameters["learning_rate"]) - - train_dataset = _PrecilyDataset( - response=output.response, - cell_line_ids=output.cell_line_ids, - drug_ids=output.drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ) - train_loader = DataLoader( - train_dataset, - batch_size=self.hyperparameters["batch_size"], - shuffle=True, - ) - - print("Training Precily model") - for epoch in range(self.hyperparameters["epochs"]): - self.model.train() - epoch_loss = 0.0 - batch_count = 0 - for pathway_inputs, drug_inputs, targets in train_loader: - pathway_inputs = pathway_inputs.to(self.DEVICE) - drug_inputs = drug_inputs.to(self.DEVICE) - targets = targets.to(self.DEVICE) - - x = torch.cat([pathway_inputs, drug_inputs], dim=1) - outputs = self.model(x) - loss = loss_func(outputs, targets) - - optimizer.zero_grad() - loss.backward() - optimizer.step() - - epoch_loss += loss.detach().item() - batch_count += 1 - - epoch_loss /= max(batch_count, 1) - print(f"Precily: Epoch [{epoch + 1}/{self.hyperparameters['epochs']}] " f"Training Loss: {epoch_loss:.4f}") - if self.is_wandb_enabled(): - self.log_metrics({"train_loss": epoch_loss}, step=epoch) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predict LN(IC50) for the given cell line / drug pairs. - - :param cell_line_ids: cell line identifiers - :param drug_ids: drug identifiers - :param cell_line_input: cell line pathway features - :param drug_input: drug SMILESVec features - :return: predicted response values - :raises ValueError: if drug_input is None or the model is not built - """ - if drug_input is None: - raise ValueError("Precily model requires drug features.") - if self.model is None: - raise ValueError("Precily model not initialized.") - - predict_dataset = _PrecilyDataset( - response=np.zeros(len(cell_line_ids)), - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_features=cell_line_input, - drug_features=drug_input, - ) - predict_loader = DataLoader( - predict_dataset, - batch_size=self.hyperparameters.get("batch_size", 128), - shuffle=False, - ) - - self.model.eval() - predictions = [] - with torch.no_grad(): - for pathway_inputs, drug_inputs, _ in predict_loader: - pathway_inputs = pathway_inputs.to(self.DEVICE) - drug_inputs = drug_inputs.to(self.DEVICE) - x = torch.cat([pathway_inputs, drug_inputs], dim=1) - outputs = self.model(x) - if outputs.numel() > 1: - predictions += outputs.cpu().tolist() - else: - predictions += [outputs.item()] - - return np.array(predictions) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - r""" - Load precomputed GSVA pathway scores. - - Generate it with the Precily pathway featurizer: - python -m drevalpy.datasets.featurizer.create_precily_pathway_features \\ - --gene_sets - - :param data_path: path to the data - :param dataset_name: dataset name - :returns: cell line FeatureDataset with the "pathways" view - :raises FileNotFoundError: if the pathway feature CSV is missing - """ - pathway_file = os.path.join(data_path, dataset_name, "pathway_features.csv") - if not os.path.exists(pathway_file): - raise FileNotFoundError( - f"Pathway feature file not found: {pathway_file}. " - "Run the featurizer first: " - "python -m drevalpy.datasets.featurizer.create_precily_pathway_features " - f"{dataset_name} --gene_sets " - ) - - df = pd.read_csv(pathway_file, index_col=0) - features = {cell_line_id: {"pathways": row.values.astype(np.float32)} for cell_line_id, row in df.iterrows()} - return FeatureDataset(features) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - r""" - Load precomputed SMILESVec drug embeddings. - - Generate it with the Precily drug featurizer: - python -m drevalpy.datasets.featurizer.create_precily_drug_embeddings \\ - --smilesvec_model - - :param data_path: path to the data - :param dataset_name: dataset name - :returns: drug FeatureDataset with the "smilesvec" view - :raises FileNotFoundError: if the drug feature CSV is missing - """ - drug_file = os.path.join(data_path, dataset_name, "drug_smilesvec.csv") - if not os.path.exists(drug_file): - raise FileNotFoundError( - f"Drug SMILESVec feature file not found: {drug_file}. " - "Run the featurizer first: " - "python -m drevalpy.datasets.featurizer.create_precily_drug_embeddings " - f"{dataset_name} --smilesvec_model " - ) - - df = pd.read_csv(drug_file, dtype={"pubchem_id": str}) - features = {} - for _, row in df.iterrows(): - drug_id = row["pubchem_id"] - embedding = row.drop("pubchem_id").values.astype(np.float32) - features[drug_id] = {"smilesvec": embedding} - - return FeatureDataset(features) - - def save(self, directory: str) -> None: - """ - Save the Precily model using PyTorch conventions. - - Stores: - - - "precily_model.pt": PyTorch state_dict of the network - - "hyperparameters.json": all hyperparameters plus the resolved - input_dim (so the network can be rebuilt with the right shape) - - :param directory: target directory - :raises ValueError: if the model is not built - """ - os.makedirs(directory, exist_ok=True) - if self.model is None: - raise ValueError("Cannot save model: model is not built.") - - model = cast(PrecilyNetwork, self.model) - torch.save(model.state_dict(), os.path.join(directory, "precily_model.pt")) - - save_hyperparameters = self.hyperparameters.copy() - save_hyperparameters["input_dim"] = model.net[0].in_features - with open(os.path.join(directory, "hyperparameters.json"), "w") as f: - json.dump(save_hyperparameters, f) - - @classmethod - def load(cls, directory: str) -> "PrecilyModel": - """ - Load a Precily model saved with save method. - - Expects in ``directory``: - - - "precily_model.pt": network state_dict - - "hyperparameters.json": hyperparameters incl. "input_dim" - - :param directory: directory containing the saved files - :return: a restored PrecilyModel - """ - instance = cls() - - with open(os.path.join(directory, "hyperparameters.json")) as f: - instance.hyperparameters = json.load(f) - - if "input_dim" in instance.hyperparameters: - instance.model = PrecilyNetwork( - input_dim=instance.hyperparameters["input_dim"], - dropout=instance.hyperparameters.get("dropout", 0.1), - ).to(instance.DEVICE) - instance.model.load_state_dict( - torch.load( - os.path.join(directory, "precily_model.pt"), - map_location=instance.DEVICE, - weights_only=True, - ) - ) - instance.model.eval() - - return instance diff --git a/drevalpy/models/README.md b/drevalpy/models/README.md index 927400243..ed48961f4 100644 --- a/drevalpy/models/README.md +++ b/drevalpy/models/README.md @@ -1,8 +1,44 @@ -This directory is used for the implementation of the models. +# Models -Steps required to implement a new model: +Built-in models are generated from zoo presets under `drevalpy/models/zoo/` and +exposed through `construct_model` on the root `drevalpy.models` package. -1. Create a new file (in a new directory) -2. Create a class for your model which inherits from the DRPModel class (`from dreval.drp_model import DRPModel`) -3. Implement the required methods -4. Add the model to the model factory in `models/__init__.py` +## Extension path + +Do **not** subclass `DRPModel` directly for new models. Instead: + +1. Register featurizers and/or predictors under `drevalpy.components`. +2. Compose them with a `ModelConfig` (YAML zoo entry, recipe triple, or dict). +3. Call `construct_model(name)`, `construct_model(name, spec)`, or + `construct_model(name, config)`. + +Example: + +```python +from drevalpy.components import load_extensions +from drevalpy.models import config, construct_model + +load_extensions(directories=["./my_components"], zoo_files=["./my_zoo.yaml"]) + +MyModel = construct_model("MyModel", "scaledGeneExpression:fingerprints:elasticNet") +# Or resolve a registered zoo name: +MyModelZoo = construct_model("MyModel") +cfg = config.from_spec("MyModel") +MyModelFromConfig = construct_model("MyModel", cfg) +model = MyModelZoo() +``` + +Reload a fitted checkpoint without a class handle: + +```python +from drevalpy.models import load_model + +loaded = load_model("checkpoints/my_model") # reads checkpoints/my_model.zip +``` + +See `docs/python/custom_models.rst` for a complete external extension walkthrough. + +## Unsupported + +- Direct `DRPModel` subclass authoring as the documented extension path +- Direct fitted-state introspection (use `model.save`/`ModelClass.load` instead) diff --git a/drevalpy/models/SRMF/hyperparameters.yaml b/drevalpy/models/SRMF/hyperparameters.yaml deleted file mode 100644 index 1b9c894ff..000000000 --- a/drevalpy/models/SRMF/hyperparameters.yaml +++ /dev/null @@ -1,9 +0,0 @@ ---- -SRMF: - K: 45 - lambda_l: 0.01 - lambda_d: 0 - lambda_c: 0.01 - max_iter: 50 - seed: 1 - n_features: 1036 diff --git a/drevalpy/models/SRMF/srmf.py b/drevalpy/models/SRMF/srmf.py deleted file mode 100644 index 589ade8f6..000000000 --- a/drevalpy/models/SRMF/srmf.py +++ /dev/null @@ -1,367 +0,0 @@ -""" -Contains the SRMF (Similarity Regularization Matrix Factorization) model. - -Original publication: Wang, L., Li, X., Zhang, L. et al. Improved anticancer drug response prediction in cell lines -using matrix factorization with similarity regularization. BMC Cancer 17, 513 (2017). -https://doi.org/10.1186/s12885-017-3500-5. -Matlab code adapted from https://github.com/linwang1982/SRMF. -""" - -import json -import os - -import joblib -import numpy as np -import pandas as pd -from scipy.spatial.distance import jaccard - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER -from drevalpy.models.drp_model import DRPModel -from drevalpy.models.utils import load_and_select_gene_features, load_drug_fingerprint_features - - -class SRMF(DRPModel): - r""" - SRMF model: Similarity Regularization Matrix Factorization. - - The primary idea is to map m drugs and n cell lines into a shared latent space, with a low dimensionality K, - where :math:`K << min (m, n)`. The properties of a drug :math:`d_i` and a cell line :math:`c_j` are described by - two latent coordinates :math:`u_i` and :math:`v_j` (K dimensional row vectors), respectively. The drug response - matrix Y is approximated by: :math:`min_{U,V} || W \cdot (Y - U \cdot V^T) ||^2_F + lambda_l \cdot - (||U||^2_F + ||V||^2_F) + lambda_d cdot ||S_d - U \cdot U^T||^2_F + - lambda_c \cdot ||S_c - V \cdot V^T||^2_F` - where W is a weight matrix (:math:`W_{ij} = 1 if Y_{ij}` is a known response value, else 0). U, V contain - :math:`u_i`, :math:`v_j` as row vectors, respectively, :math:`||.||_F` is the Frobenius norm. To avoid overfitting, - L2 regularization is used. :math:`S_d, S_c` are drug/cell line similarity matrices. Differences between two - drugs/cell lines are minimized in latent space. - """ - - cell_line_views = ["gene_expression"] - drug_views = ["fingerprints"] - - def __init__(self) -> None: - """Initalization method for SRMF Model.""" - super().__init__() - self.best_u: pd.DataFrame = pd.DataFrame() - self.best_v: pd.DataFrame = pd.DataFrame() - self.w: pd.DataFrame = pd.DataFrame() - self.k: int = 45 - self.lambda_l: float = 0.01 - self.lambda_d: float = 0.0 - self.lambda_c: float = 0.01 - self.max_iter: int = 50 - self.seed: int = 1 - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: SRMF - """ - return "SRMF" - - def build_model(self, hyperparameters: dict) -> None: - """ - Initializes hyperparameters for SRMF model. - - K is the latent dimensionality, lambda_l, lambda_d, lambda_c are regularization parameters, max_iter is the - number of iterations, seed is the random seed. - - :param hyperparameters: dictionary containing the hyperparameters - """ - self.log_hyperparameters(hyperparameters) - self.k = hyperparameters.get("K", 45) - self.lambda_l = hyperparameters.get("lambda_l", 0.01) - self.lambda_d = hyperparameters.get("lambda_d", 0) - self.lambda_c = hyperparameters.get("lambda_c", 0.01) - self.max_iter = hyperparameters.get("max_iter", 50) - self.seed = hyperparameters.get("seed", 1) - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Prepares data and trains the SRMF model. - - :param output: response data - :param cell_line_input: feature data for cell lines - :param drug_input: feature data for drugs - :param output_earlystopping: optional early stopping dataset, not used in SRMF - :param model_checkpoint_dir: directory to save the model checkpoints, not used in SRMF - :raises ValueError: if drug_input is None - """ - if drug_input is None: - raise ValueError("SRMF requires drug features.") - - drugs = np.unique(drug_input.identifiers) # transductive approach - all drug features are used - cell_lines = np.unique(cell_line_input.identifiers) # transductive approach - all cell line features are used - - drug_features = drug_input.features - - drug_similarity = pd.DataFrame(index=drugs, columns=drugs, dtype=float) # jaccard similarity - - for drug_from in drugs: - for drug_to in drugs: - # skip if already computed - if not np.isnan(drug_similarity.loc[drug_from, drug_to]): - continue - drug_similarity.loc[drug_from, drug_to] = 1 - jaccard( - drug_features[drug_from]["fingerprints"], - drug_features[drug_to]["fingerprints"], - ) - drug_similarity.loc[drug_to, drug_from] = drug_similarity.loc[drug_from, drug_to] - - cell_line_features = cell_line_input.get_feature_matrix(view="gene_expression", identifiers=cell_lines) - # pearson correlation as similarity - cell_line_similarity = np.corrcoef(cell_line_features, rowvar=True) - - # Prepare response and weight matrices - drug_response_matrix = output.to_dataframe() - if "tissue" in drug_response_matrix.columns: - drug_response_matrix = drug_response_matrix.drop(columns=["tissue"]) - drug_response_matrix = ( - drug_response_matrix.groupby([CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER]).mean().reset_index() - ) - drug_response_matrix = drug_response_matrix.pivot( - index=CELL_LINE_IDENTIFIER, columns=DRUG_IDENTIFIER, values="response" - ) - - drug_response_matrix = drug_response_matrix.reindex( - index=cell_lines, columns=drugs - ) # missing rows and columns are filled with NaN - - self.w = ~np.isnan(drug_response_matrix) - drug_response_matrix = drug_response_matrix.copy() - drug_response_matrix[np.isnan(drug_response_matrix)] = 0 - - # Train the model - best_u, best_v = self._cmf( - w=self.w.T.values, - int_mat=drug_response_matrix.values.T, - drug_mat=drug_similarity.values, - cell_mat=cell_line_similarity, - ) - self.best_u = pd.DataFrame(best_u, index=drugs) - self.best_v = pd.DataFrame(best_v, index=cell_lines) - self.training_mean = np.nanmean(output._response) # Store training mean - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the drug response based on the trained latent factors. - - :param drug_ids: drug identifiers - :param cell_line_ids: cell line identifiers - :param cell_line_input: not needed for prediction in SRMF - :param drug_input: not needed for prediction in SRMF - :returns: predicted response matrix - """ - # Use training mean for missing drugs - best_u = np.full((len(drug_ids), self.k), self.training_mean) - for idx, drug in enumerate(drug_ids): - if drug in self.best_u.index: - best_u[idx, :] = self.best_u.loc[drug].values - - # Use training mean for missing cell lines - best_v = np.full((len(cell_line_ids), self.k), self.training_mean) - for idx, cell in enumerate(cell_line_ids): - if cell in self.best_v.index: - best_v[idx, :] = self.best_v.loc[cell].values - - # calculate the diagonal of the matrix product which is the prediction, - # faster than np.dot(best_u, best_v.T).diagonal() - diagonal_predictions = np.einsum("ij,ji->i", best_u, best_v.T) - return diagonal_predictions - - def _cmf(self, w, int_mat, drug_mat, cell_mat) -> tuple[np.ndarray, np.ndarray]: - """ - Implements the SRMF model with specific update rules and regularization. - - :param w: weight matrix - :param int_mat: interaction matrix - :param drug_mat: drug similarity matrix - :param cell_mat: cell line similarity matrix - :returns: best drug and cell line latent factors - """ - rng = np.random.default_rng(self.seed) - m, n = w.shape - u0 = np.sqrt(1 / self.k) * rng.standard_normal(size=(m, self.k)) - v0 = np.sqrt(1 / self.k) * rng.standard_normal(size=(n, self.k)) - - best_u, best_v = u0, v0 - - last_loss = self._compute_loss(u0, v0, w, int_mat, drug_mat, cell_mat) - best_loss = last_loss - wr = w * int_mat - - for _ in range(self.max_iter): - u = self._alg_update(u0, v0, w, wr, drug_mat, self.lambda_l, self.lambda_d) - v = self._alg_update(v0, u, w.T, wr.T, cell_mat, self.lambda_l, self.lambda_c) - curr_loss = self._compute_loss(u, v, w, int_mat, drug_mat, cell_mat) - - if curr_loss < best_loss: - best_u, best_v = u, v - best_loss = curr_loss - - delta_loss = (curr_loss - last_loss) / last_loss - if abs(delta_loss) < 1e-6: - break - - last_loss = curr_loss - u0, v0 = u, v - - return best_u, best_v - - def _compute_loss(self, u, v, w, int_mat, drug_mat, cell_mat) -> np.float64: - """ - Computes the loss for SRMF, including similarity regularization. - - :param u: drug latent factors - :param v: cell line latent factors - :param w: weight matrix - :param int_mat: interaction matrix - :param drug_mat: drug similarity matrix - :param cell_mat: cell line similarity matrix - :returns: loss value - """ - loss = np.sum((w * (int_mat - np.dot(u, v.T))) ** 2) - loss += self.lambda_l * (np.sum(u**2) + np.sum(v**2)) - loss += self.lambda_d * np.sum((drug_mat - np.dot(u, u.T)) ** 2) - loss += self.lambda_c * np.sum((cell_mat - np.dot(v, v.T)) ** 2) - return loss - - def _alg_update(self, u, v, w, r, s, lambda_l, lambda_d) -> np.ndarray: - """ - Algorithm update rule for u or v in the SRMF model. - - :param u: drug latent factors - :param v: cell line latent factors - :param w: weight matrix - :param r: weight * interaction matrix - :param s: drug/cell line similarity matrix - :param lambda_l: regularization parameter - :param lambda_d: drug/cell line similarity regularization parameter - :returns: updated u or v - """ - x = np.dot(r, v) + 2 * lambda_d * np.dot(s, u) - y = 2 * lambda_d * np.dot(u.T, u) - u0 = np.zeros_like(u) - d = np.dot(v.T, v) - m, _ = w.shape - - for i in range(m): - ii = np.where(w[i, :] > 0)[0] - if ii.size == 0: - b = y + lambda_l * np.eye(u.shape[1]) - elif ii.size == w.shape[1]: - b = d + y + lambda_l * np.eye(u.shape[1]) - else: - a = np.dot(v[ii, :].T, v[ii, :]) - b = a + y + lambda_l * np.eye(u.shape[1]) - - u0[i, :] = np.linalg.solve(b, x[i, :]) - - return u0 - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features, in this case the gene expression features. - - :param data_path: Path to the gene expression and landmark genes, e.g., data/ - :param dataset_name: Name of the dataset, e.g., GDSC2 - :returns: FeatureDataset containing the cell line gene expression features, filtered - through the landmark genes - """ - return load_and_select_gene_features( - feature_type="gene_expression", - gene_list=None, - data_path=data_path, - dataset_name=dataset_name, - ) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the drug features, in this case the drug fingerprints. - - :param data_path: Path to the drug features, in this case the drug fingerprints, e.g., data/ - :param dataset_name: Name of the dataset, e.g., GDSC2 - :returns: FeatureDataset containing the drug fingerprint features - """ - return load_drug_fingerprint_features(data_path, dataset_name, fill_na=True) - - def save(self, directory: str) -> None: - """ - Save the SRMF model's parameters and latent matrices to the specified directory. - - Files saved: - - - best_u.pkl: latent factors for drugs - - best_v.pkl: latent factors for cell lines - - w_mask.pkl: response presence mask - - config.json: model configuration (hyperparameters and training mean) - - :param directory: Target directory to store model artifacts - """ - os.makedirs(directory, exist_ok=True) - joblib.dump(self.best_u, os.path.join(directory, "best_u.pkl")) - joblib.dump(self.best_v, os.path.join(directory, "best_v.pkl")) - joblib.dump(self.w, os.path.join(directory, "w_mask.pkl")) - with open(os.path.join(directory, "config.json"), "w") as f: - json.dump( - { - "k": self.k, - "lambda_l": self.lambda_l, - "lambda_d": self.lambda_d, - "lambda_c": self.lambda_c, - "max_iter": self.max_iter, - "seed": self.seed, - "training_mean": self.training_mean, - }, - f, - ) - - @classmethod - def load(cls, directory: str) -> "SRMF": - """ - Load a trained SRMF model from the specified directory. - - Expects the following files: - - - best_u.pkl: latent factors for drugs - - best_v.pkl: latent factors for cell lines - - w_mask.pkl: response presence mask - - config.json: model configuration (hyperparameters and training mean) - - :param directory: Directory containing the saved model artifacts - :return: An instance of SRMF with restored parameters - :raises FileNotFoundError: if any required file is missing - """ - required_files = ["best_u.pkl", "best_v.pkl", "w_mask.pkl", "config.json"] - for file in required_files: - if not os.path.exists(os.path.join(directory, file)): - raise FileNotFoundError(f"Missing file: {file}") - - instance = cls() - instance.best_u = joblib.load(os.path.join(directory, "best_u.pkl")) - instance.best_v = joblib.load(os.path.join(directory, "best_v.pkl")) - instance.w = joblib.load(os.path.join(directory, "w_mask.pkl")) - - with open(os.path.join(directory, "config.json")) as f: - config = json.load(f) - - instance.build_model(config) - instance.training_mean = config["training_mean"] - - return instance diff --git a/drevalpy/models/SimpleNeuralNetwork/__init__.py b/drevalpy/models/SimpleNeuralNetwork/__init__.py deleted file mode 100644 index a39e48d45..000000000 --- a/drevalpy/models/SimpleNeuralNetwork/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Module for the baseline models SimpleNeuralNetwork and MultiViewNeuralNetwork.""" diff --git a/drevalpy/models/SimpleNeuralNetwork/hyperparameters.yaml b/drevalpy/models/SimpleNeuralNetwork/hyperparameters.yaml deleted file mode 100644 index 17fcae0af..000000000 --- a/drevalpy/models/SimpleNeuralNetwork/hyperparameters.yaml +++ /dev/null @@ -1,55 +0,0 @@ ---- -SimpleNeuralNetwork: - cell_line_views: - - gene_expression - drug_views: - - fingerprints - - drug_chemberta_embeddings - dropout_prob: - - 0.3 - units_per_layer: - - - 32 - - 16 - - 8 - - 4 - - - 128 - - 64 - - 32 - - - 64 - - 64 - - 32 - - - 1024 - - 128 - - 64 - - 16 - max_epochs: - - 100 - -MultiViewNeuralNetwork: - cell_line_views: - - - gene_expression - - methylation - - mutations - - copy_number_variation_gistic - drug_views: - - fingerprints - dropout_prob: - - 0.3 - units_per_layer: - - - 16 - - 8 - - 4 - - - 32 - - 16 - - 8 - - 4 - - - 128 - - 64 - - 32 - - - 64 - - 64 - - 32 - methylation_pca_components: - - 100 - max_epochs: - - 100 diff --git a/drevalpy/models/SimpleNeuralNetwork/multi_view_neural_network.py b/drevalpy/models/SimpleNeuralNetwork/multi_view_neural_network.py deleted file mode 100644 index 22b74a8a6..000000000 --- a/drevalpy/models/SimpleNeuralNetwork/multi_view_neural_network.py +++ /dev/null @@ -1,300 +0,0 @@ -"""Contains the baseline MultiViewNeuralNetwork model.""" - -import json -import os -import warnings - -import joblib -import numpy as np -import torch -from sklearn.decomposition import PCA -from sklearn.preprocessing import StandardScaler - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset - -from ..drp_model import DRPModel -from ..utils import ( - _get_view_as_list, - load_multi_cell_line_view, - load_single_drug_view, - prepare_expression_and_methylation, -) -from .utils import FeedForwardNetwork - - -class MultiViewNeuralNetwork(DRPModel): - """Simple Feedforward Neural Network model with dropout using multiple omics data.""" - - cell_line_views = [ - "gene_expression", - "methylation", - "mutations", - "copy_number_variation_gistic", - ] - drug_views = ["fingerprints"] - early_stopping = True - - def __init__(self): - """ - Initalization method for MultiViewNeuralNetwork Model. - - The PCA is initialized to None because it depends on hyperparameter, therefore built in build_model. - """ - super().__init__() - self.model = None - self.methylation_scaler = StandardScaler() - self.methylation_pca = None - self.pca_ncomp = 100 - self.gene_expression_scaler = StandardScaler() - self.input_dims = dict() - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: MultiViewNeuralNetwork - """ - return "MultiViewNeuralNetwork" - - def build_model(self, hyperparameters: dict): - """ - Builds the model from hyperparameters. - - The model is a simple feedforward neural network with dropout. The PCA is used to reduce the dimensionality of - the methylation data. - - :param hyperparameters: dictionary containing the hyperparameters units_per_layer, dropout_prob, and - methylation_pca_components. - """ - # Log hyperparameters to wandb if enabled - self.log_hyperparameters(hyperparameters) - - self.hyperparameters = hyperparameters - self.cell_line_views = _get_view_as_list( - hyperparameters.get( - "cell_line_views", ["gene_expression", "methylation", "mutations", "copy_number_variation_gistic"] - ) - ) - self.drug_views = _get_view_as_list(hyperparameters.get("drug_views", ["fingerprints"])) - if "methylation" in self.cell_line_views: - self.pca_ncomp = hyperparameters["methylation_pca_components"] - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features for a multi-view neural network. - - :param data_path: data path e.g. data/ - :param dataset_name: dataset name e.g. GDSC1 - :returns: FeatureDataset containing the cell line omics features - """ - return load_multi_cell_line_view(self.cell_line_views, data_path, dataset_name, self.get_model_name()) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset | None: - """ - Load the drug features for a multi-view neural network. - - :param data_path: path to the drug features, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC1 - :returns: FeatureDataset containing the drug features - """ - return load_single_drug_view(self.drug_views, data_path, dataset_name, self.get_model_name()) - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "", - ): - """ - Fits the PCA and trains the model. - - :param output: training data associated with the response output - :param cell_line_input: cell line omics features - :param drug_input: drug omics features - :param output_earlystopping: optional early stopping dataset - :param model_checkpoint_dir: directory to save the model checkpoints - :raises ValueError: if drug_input is missing - """ - if drug_input is None: - raise ValueError(f"Drug input ({self.drug_views[0]}) is needed for the MultiViewNeuralNetwork model.") - first_cl_feature = next(iter(cell_line_input.features.values())) - if "methylation" in self.cell_line_views: - n_met_features = first_cl_feature["methylation"].shape[0] - if n_met_features > self.pca_ncomp: - self.methylation_pca = PCA(n_components=self.pca_ncomp) - else: - self.methylation_pca = PCA(n_components=n_met_features) - - # if gene expression or methylation don't even occur, this just returns cell_line_input, so it's fine - cell_line_input = prepare_expression_and_methylation( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - gene_expression_scaler=self.gene_expression_scaler, - methylation_scaler=self.methylation_scaler, - methylation_pca=self.methylation_pca, - ) - first_drug_feature = next(iter(drug_input.features.values())) - - cell_line_dims = { - view: first_cl_feature[view].shape[0] for view in self.cell_line_views if view != "methylation" - } - if "methylation" in self.cell_line_views: - cell_line_dims["methylation"] = self.methylation_pca.n_components - - drug_dims = {view: first_drug_feature[view].shape[0] for view in self.drug_views} - - self.input_dims = {**cell_line_dims, **drug_dims} - total_dim = sum(self.input_dims.values()) - - self.model = FeedForwardNetwork( - hyperparameters=self.hyperparameters, - input_dim=total_dim, - ) - - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - message=".*does not have many workers which may be a bottleneck.*", - ) - self.model.fit( - output_train=output, - cell_line_input=cell_line_input, - drug_input=drug_input, - cell_line_views=self.cell_line_views, - drug_views=self.drug_views, - output_earlystopping=output_earlystopping, - trainer_params={ - "max_epochs": self.hyperparameters.get("max_epochs", 100), - "progress_bar_refresh_rate": 500, - }, - batch_size=16, - patience=5, - num_workers=1, - model_checkpoint_dir=model_checkpoint_dir, - wandb_project=self.wandb_project, - ) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Applies arcsinh + scaling to gene expression and scaling + PCA to methylation, then predicts. - - :param drug_ids: drug identifiers - :param cell_line_ids: cell line identifiers - :param drug_input: drug omics features - :param cell_line_input: cell line omics features - :returns: predicted response - """ - cell_line_input = prepare_expression_and_methylation( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - gene_expression_scaler=self.gene_expression_scaler, - methylation_scaler=self.methylation_scaler, - methylation_pca=self.methylation_pca, - ) - - inputs = self.get_feature_matrices( - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - # concatenate in the order of self.cell_line_views - array_list = [] - for view in self.cell_line_views + self.drug_views: - feature_mat = inputs[view] - array_list.append(feature_mat) - - x = np.concatenate(array_list, axis=1) - return self.model.predict(x) - - def save(self, directory: str) -> None: - """ - Save the trained model, hyperparameters, scalers, PCA object, and feature dimensions to disk. - - Files always saved: model.pt, hyperparameters.json, metadata.json. - Conditionally saved: gene_scaler.pkl (if gene_expression in views), - methylation_scaler.pkl and methylation_pca.pkl (if methylation in views). - - :param directory: Target directory - """ - os.makedirs(directory, exist_ok=True) - - torch.save(self.model.state_dict(), os.path.join(directory, "model.pt")) # noqa: S614 - - with open(os.path.join(directory, "hyperparameters.json"), "w") as f: - json.dump(self.hyperparameters, f) - - if "gene_expression" in self.cell_line_views: - joblib.dump(self.gene_expression_scaler, os.path.join(directory, "gene_scaler.pkl")) - if "methylation" in self.cell_line_views: - joblib.dump(self.methylation_scaler, os.path.join(directory, "methylation_scaler.pkl")) - joblib.dump(self.methylation_pca, os.path.join(directory, "methylation_pca.pkl")) - - metadata = { - "input_dims": self.input_dims, - } - with open(os.path.join(directory, "metadata.json"), "w") as f: - json.dump(metadata, f) - - @classmethod - def load(cls, directory: str) -> "MultiViewNeuralNetwork": - """ - Load a trained MultiViewNeuralNetwork instance from disk. - - Always required: model.pt, hyperparameters.json, metadata.json. - Conditionally required: gene_scaler.pkl (if gene_expression in views), - methylation_scaler.pkl and methylation_pca.pkl (if methylation in views). - - :param directory: Directory containing the saved model files - :return: Fully restored MultiViewNeuralNetwork instance - :raises FileNotFoundError: if any required file is missing - """ - instance = cls() - - with open(os.path.join(directory, "hyperparameters.json")) as f: - hyperparameters = json.load(f) - - instance.build_model(hyperparameters) - - required_files = ["model.pt", "hyperparameters.json", "metadata.json"] - if "gene_expression" in instance.cell_line_views: - required_files.append("gene_scaler.pkl") - if "methylation" in instance.cell_line_views: - required_files.extend(["methylation_scaler.pkl", "methylation_pca.pkl"]) - - missing = [f for f in required_files if not os.path.exists(os.path.join(directory, f))] - if missing: - raise FileNotFoundError(f"Missing model files: {', '.join(missing)}") - - if "gene_expression" in instance.cell_line_views: - instance.gene_expression_scaler = joblib.load(os.path.join(directory, "gene_scaler.pkl")) - if "methylation" in instance.cell_line_views: - instance.methylation_scaler = joblib.load(os.path.join(directory, "methylation_scaler.pkl")) - instance.methylation_pca = joblib.load(os.path.join(directory, "methylation_pca.pkl")) - - with open(os.path.join(directory, "metadata.json")) as f: - metadata = json.load(f) - - instance.input_dims = metadata["input_dims"] - total_dim = sum(instance.input_dims.values()) - - instance.model = FeedForwardNetwork( - hyperparameters=instance.hyperparameters, - input_dim=total_dim, - ) - instance.model.load_state_dict(torch.load(os.path.join(directory, "model.pt"))) # noqa: S614 - instance.model.eval() - - return instance diff --git a/drevalpy/models/SimpleNeuralNetwork/simple_neural_network.py b/drevalpy/models/SimpleNeuralNetwork/simple_neural_network.py deleted file mode 100644 index 1f82dce61..000000000 --- a/drevalpy/models/SimpleNeuralNetwork/simple_neural_network.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Contains the SimpleNeuralNetwork model.""" - -import json -import os -import platform -import warnings - -import joblib -import numpy as np -import torch -from sklearn.preprocessing import StandardScaler - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset - -from ..drp_model import DRPModel -from ..utils import ( - _get_view_as_list, - load_single_cell_line_view, - load_single_drug_view, - scale_gene_expression, -) -from .utils import FeedForwardNetwork - - -class SimpleNeuralNetwork(DRPModel): - """Simple Feedforward Neural Network model with dropout using only gene expression data.""" - - cell_line_views = [] - drug_views = [] - early_stopping = True - - def __init__(self): - """Initializes the SimpleNeuralNetwork. - - The model is built in train(). The gene_expression_scalar is set to the StandardScaler() and later fitted - using the training data only. - """ - super().__init__() - self.model = None - self.gene_expression_scaler = StandardScaler() - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: SimpleNeuralNetwork - """ - return "SimpleNeuralNetwork" - - def build_model(self, hyperparameters: dict): - """ - Builds the model from hyperparameters. - - :param hyperparameters: includes units_per_layer and dropout_prob. - """ - # Log hyperparameters to wandb if enabled - self.log_hyperparameters(hyperparameters) - - self.hyperparameters = hyperparameters - self.cell_line_views = _get_view_as_list(hyperparameters.get("cell_line_views", ["gene_expression"])) - self.drug_views = _get_view_as_list(hyperparameters.get("drug_views", ["fingerprints"])) - self.hyperparameters.setdefault("input_dim_omic", None) - self.hyperparameters.setdefault("input_dim_fp", None) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features for a single-view neural network. - - :param data_path: Path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the cell line features - """ - return load_single_cell_line_view(self.cell_line_views, data_path, dataset_name, self.get_model_name()) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset | None: - """ - Loads the drug features for a single-view neural network. - - :param data_path: Path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the drug features - """ - return load_single_drug_view(self.drug_views, data_path, dataset_name, self.get_model_name()) - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - First scales the gene expression data and trains the model. - - The gene expression data is first arcsinh transformed. Afterward, the StandardScaler() is fitted on the - training gene expression data only. Then, it transforms all gene expression data. - - :param output: training data associated with the response output - :param cell_line_input: cell line omics features - :param drug_input: drug omics features - :param output_earlystopping: optional early stopping dataset - :param model_checkpoint_dir: directory to save the model checkpoints - :raises ValueError: if drug_input (fingerprints) is missing - - """ - if drug_input is None: - raise ValueError(f"drug_input ({self.drug_views[0]}) are required for SimpleNeuralNetwork.") - - # Apply arcsinh transformation and scaling to gene expression features - if "gene_expression" in self.cell_line_views: - cell_line_input = scale_gene_expression( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - gene_expression_scaler=self.gene_expression_scaler, - ) - - dim_omic = next(iter(cell_line_input.features.values()))[self.cell_line_views[0]].shape[0] - dim_fingerprint = next(iter(drug_input.features.values()))[self.drug_views[0]].shape[0] - self.hyperparameters["input_dim_omic"] = dim_omic - self.hyperparameters["input_dim_fp"] = dim_fingerprint - - self.model = FeedForwardNetwork( - hyperparameters=self.hyperparameters, - input_dim=dim_omic + dim_fingerprint, - ) - - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - message=".*does not have many workers which may be a bottleneck.*", - ) - warnings.filterwarnings( - "ignore", - message="Starting from v1\\.9\\.0, `tensorboardX` has been removed.*", - ) - if (output_earlystopping is not None) and len(output_earlystopping) == 0: - output_earlystopping = output - print("SimpleNeuralNetwork: Early stopping dataset empty. Using training data for early stopping") - - print("Probably, your training dataset is small.") - - self.model.fit( - output_train=output, - cell_line_input=cell_line_input, - drug_input=drug_input, - cell_line_views=self.cell_line_views, - drug_views=self.drug_views, - output_earlystopping=output_earlystopping, - trainer_params={ - "max_epochs": self.hyperparameters.get("max_epochs", 100), - "progress_bar_refresh_rate": 500, - }, - batch_size=16, - patience=5, - num_workers=1 if platform.system() == "Windows" else 8, - model_checkpoint_dir=model_checkpoint_dir, - wandb_project=self.wandb_project, - ) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the response for the given input. - - :param cell_line_ids: IDs of the cell lines to be predicted - :param drug_ids: IDs of the drugs to be predicted - :param cell_line_input: gene expression of the test data - :param drug_input: fingerprints of the test data - :returns: the predicted drug responses - """ - # Apply arcsinh transformation and scaling to gene expression features - if "gene_expression" in self.cell_line_views: - cell_line_input = scale_gene_expression( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - gene_expression_scaler=self.gene_expression_scaler, - ) - - x = self.get_concatenated_features( - cell_line_view=self.cell_line_views[0], - drug_view=self.drug_views[0], - cell_line_ids_output=cell_line_ids, - drug_ids_output=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - return self.model.predict(x) - - def save(self, directory: str) -> None: - """ - Save the trained model, hyperparameters, and gene expression scaler to the given directory. - - This enables full reconstruction of the model using `load`. - - Files saved: - - - model.pt: PyTorch state_dict of the trained model - - hyperparameters.json: Dictionary containing all relevant model hyperparameters - - scaler.pkl: Fitted StandardScaler for gene expression features - - :param directory: Target directory to store all model artifacts - """ - os.makedirs(directory, exist_ok=True) - - torch.save(self.model.state_dict(), os.path.join(directory, "model.pt")) # noqa: S614 - - with open(os.path.join(directory, "hyperparameters.json"), "w") as f: - json.dump(self.hyperparameters, f) - - joblib.dump(self.gene_expression_scaler, os.path.join(directory, "scaler.pkl")) - - @classmethod - def load(cls, directory: str) -> "SimpleNeuralNetwork": - """ - Load a trained SimpleNeuralNetwork instance from disk. - - This includes: - - - model.pt: PyTorch state_dict of the trained model - - hyperparameters.json: Dictionary with model hyperparameters - - scaler.pkl: Fitted StandardScaler for gene expression features - - :param directory: Directory containing the saved model files - :return: An instance of SimpleNeuralNetwork with restored state - :raises FileNotFoundError: if any required file is missing - """ - hyperparam_file = os.path.join(directory, "hyperparameters.json") - scaler_file = os.path.join(directory, "scaler.pkl") - model_file = os.path.join(directory, "model.pt") - - if not all(os.path.exists(f) for f in [hyperparam_file, scaler_file, model_file]): - raise FileNotFoundError("Missing model files. Required: model.pt, hyperparameters.json, scaler.pkl") - - instance = cls() - - with open(hyperparam_file) as f: - hyperparameters = json.load(f) - - instance.build_model(hyperparameters) - instance.gene_expression_scaler = joblib.load(scaler_file) - - dim_omic = instance.hyperparameters["input_dim_omic"] - dim_fp = instance.hyperparameters["input_dim_fp"] - - instance.model = FeedForwardNetwork(instance.hyperparameters, input_dim=dim_omic + dim_fp) - instance.model.load_state_dict(torch.load(model_file)) # noqa: S614 - instance.model.eval() - - return instance diff --git a/drevalpy/models/SimpleNeuralNetwork/utils.py b/drevalpy/models/SimpleNeuralNetwork/utils.py deleted file mode 100644 index a90174ca6..000000000 --- a/drevalpy/models/SimpleNeuralNetwork/utils.py +++ /dev/null @@ -1,361 +0,0 @@ -"""Utility functions for the simple neural network models.""" - -import os -import secrets - -import numpy as np -import pytorch_lightning as pl -import torch -from pytorch_lightning.callbacks import EarlyStopping, TQDMProgressBar -from pytorch_lightning.loggers import WandbLogger -from torch import nn -from torch.utils.data import DataLoader, Dataset - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset - -from ..lightning_metrics_mixin import RegressionMetricsMixin - - -class RegressionDataset(Dataset): - """Dataset for regression tasks for the data loader.""" - - def __init__( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset, - cell_line_views: list[str], - drug_views: list[str], - ): - """ - Initializes the regression dataset. - - :param output: response values - :param cell_line_input: input omics data - :param drug_input: input fingerprint data - :param cell_line_views: either gene expression for the SimpleNeuralNetwork or all omics data for the - MultiViewNeuralNetwork - :param drug_views: fingerprints - :raises AssertionError: if the views are not found in the input data - """ - self.cell_line_views = cell_line_views - self.drug_views = drug_views - self.output = output - self.cell_line_input = cell_line_input - self.drug_input = drug_input - for cl_view in self.cell_line_views: - if cl_view not in cell_line_input.view_names: - raise AssertionError(f"Cell line view {cl_view} not found in cell line input") - for d_view in self.drug_views: - if d_view not in drug_input.view_names: - raise AssertionError(f"Drug view {d_view} not found in drug input") - - def __getitem__(self, idx): - """ - Overwrites the getitem method from the Dataset class. - - Retrieves the cell line and drug features and the response for the given index. - :param idx: index of the sample of interest - :returns: the cell line feature(s) and the response - :raises TypeError: if the features are not numpy arrays - """ - cell_line_id = self.output.cell_line_ids[idx] - drug_id = self.output.drug_ids[idx] - response = self.output.response[idx] - cell_line_features = None - drug_features = None - for cl_view in self.cell_line_views: - feature_mat = self.cell_line_input.features[cell_line_id][cl_view] - - if cell_line_features is None: - cell_line_features = feature_mat - else: - cell_line_features = np.concatenate((cell_line_features, feature_mat)) - for d_view in self.drug_views: - if drug_features is None: - drug_features = self.drug_input.features[drug_id][d_view] - else: - drug_features = np.concatenate((drug_features, self.drug_input.features[drug_id][d_view])) - if not isinstance(cell_line_features, np.ndarray): - raise TypeError(f"Cell line features for {cell_line_id} are not numpy array") - if not isinstance(drug_features, np.ndarray): - raise TypeError(f"Drug features for {drug_id} are not numpy array") - data = np.concatenate((cell_line_features, drug_features)) - # cast to float32 - data = data.astype(np.float32) - response = np.float32(response) - return data, response - - def __len__(self): - """ - Overwrites the len method from the Dataset class. - - :returns: the length of the output - """ - return len(self.output.response) - - -class FeedForwardNetwork(RegressionMetricsMixin, pl.LightningModule): - """Feed forward neural network for regression tasks with basic architecture.""" - - def __init__(self, hyperparameters: dict[str, int | float | list[int] | str | list[str]], input_dim: int) -> None: - """ - Initializes the feed forward network. - - The model uses a simple architecture with fully connected layers, batch normalization, and dropout. An MSE - loss is used. - - :param hyperparameters: hyperparameters - :param input_dim: input dimension, for SimpleNeuralNetwork it is the sum of the gene expression and - fingerprint, for MultiViewNeuralNetwork it is the sum of all omics data and fingerprints - :raises TypeError: if the hyperparameters are not of the correct type - """ - super().__init__() - self.save_hyperparameters() - - if not isinstance(hyperparameters["units_per_layer"], list): - raise TypeError("units_per_layer must be a list of integers") - if not all(isinstance(x, int) for x in hyperparameters["units_per_layer"]): - raise TypeError("units_per_layer must be a list of integers") - if not isinstance(hyperparameters["dropout_prob"], float): - raise TypeError("dropout_prob must be a float") - - n_units_per_layer: list[int] = hyperparameters["units_per_layer"] # type: ignore[assignment] - dropout_prob: float = hyperparameters["dropout_prob"] - self.n_units_per_layer = n_units_per_layer - self.dropout_prob = dropout_prob - self.loss = nn.MSELoss() - # self.checkpoint_callback is initialized in the fit method - self.checkpoint_callback: pl.callbacks.ModelCheckpoint | None = None - self.fully_connected_layers = nn.ModuleList() - self.batch_norm_layers = nn.ModuleList() - self.dropout_layer = None - - self.fully_connected_layers.append(nn.Linear(input_dim, self.n_units_per_layer[0])) - self.batch_norm_layers.append(nn.BatchNorm1d(self.n_units_per_layer[0])) - - for i in range(1, len(self.n_units_per_layer)): - self.fully_connected_layers.append(nn.Linear(self.n_units_per_layer[i - 1], self.n_units_per_layer[i])) - self.batch_norm_layers.append(nn.BatchNorm1d(self.n_units_per_layer[i])) - - self.fully_connected_layers.append(nn.Linear(self.n_units_per_layer[-1], 1)) - if self.dropout_prob is not None: - self.dropout_layer = nn.Dropout(p=self.dropout_prob) - - # Initialize metrics storage for epoch-end R^2 and PCC computation - self._init_metrics_storage() - - def fit( - self, - output_train: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None, - cell_line_views: list[str], - drug_views: list[str], - output_earlystopping: DrugResponseDataset | None = None, - trainer_params: dict | None = None, - batch_size=32, - patience=5, - num_workers: int = 2, - model_checkpoint_dir: str = "checkpoints", - wandb_project: str | None = None, - ) -> None: - """ - Fits the model. - - First, the data is loaded using a DataLoader. Then, the model is trained using the Lightning Trainer. - - :param output_train: Response values for training - :param cell_line_input: Cell line features - :param drug_input: Drug features - :param cell_line_views: Cell line info needed for this model - :param drug_views: Drug info needed for this model - :param output_earlystopping: Response values for early stopping - :param trainer_params: custom parameters for the trainer - :param batch_size: batch size for the DataLoader, default is 32 - :param patience: patience for early stopping, default is 5 - :param num_workers: number of workers for the DataLoader, default is 2 - :param model_checkpoint_dir: directory to save the model checkpoints - :param wandb_project: optional wandb project name for logging. If provided, uses WandbLogger - for PyTorch Lightning training. - :raises ValueError: if drug_input is missing - """ - if trainer_params is None: - trainer_params = { - "max_epochs": 100, - "progress_bar_refresh_rate": 500, - } - if drug_input is None: - raise ValueError( - "Drug input (fingerprints) are required for SimpleNeuralNetwork and " "MultiViewNeuralNetwork." - ) - - train_dataset = RegressionDataset( - output=output_train, - cell_line_input=cell_line_input, - drug_input=drug_input, - cell_line_views=cell_line_views, - drug_views=drug_views, - ) - train_loader = DataLoader( - train_dataset, - batch_size=batch_size, - shuffle=True, - num_workers=num_workers, - persistent_workers=True, - drop_last=True, # to avoid batch norm errors, if last batch is smaller than batch_size, it is not processed - ) - - val_loader = None - if output_earlystopping is not None: - val_dataset = RegressionDataset( - output=output_earlystopping, - cell_line_input=cell_line_input, - drug_input=drug_input, - cell_line_views=cell_line_views, - drug_views=drug_views, - ) - val_loader = DataLoader( - val_dataset, - batch_size=batch_size, - shuffle=False, - num_workers=num_workers, - persistent_workers=True, - ) - - # Train the model - monitor = "train_loss" if (val_loader is None) else "val_loss" - - early_stop_callback = EarlyStopping(monitor=monitor, mode="min", patience=patience) - - unique_subfolder = os.path.join(model_checkpoint_dir, "run_" + secrets.token_hex(8)) - os.makedirs(unique_subfolder, exist_ok=True) - - # prevent conflicts - name = "version-" + "".join([secrets.choice("0123456789abcdef") for _ in range(10)]) - self.checkpoint_callback = pl.callbacks.ModelCheckpoint( - dirpath=unique_subfolder, - monitor=monitor, - mode="min", - save_top_k=1, - filename=name, - ) - - progress_bar = TQDMProgressBar(refresh_rate=trainer_params["progress_bar_refresh_rate"]) - trainer_params_copy = trainer_params.copy() - del trainer_params_copy["progress_bar_refresh_rate"] - - # Set up wandb logger if project is provided - # Note: This method receives wandb_project as parameter, but the model instance - # should have wandb already initialized via DRPModel.init_wandb() - loggers = [] - if wandb_project is not None: - logger = WandbLogger(project=wandb_project, log_model=False) - loggers.append(logger) - - # Initialize the Lightning trainer - trainer = pl.Trainer( - callbacks=[ - early_stop_callback, - self.checkpoint_callback, - progress_bar, - ], - logger=loggers if loggers else True, # Use default logger if no wandb - default_root_dir=model_checkpoint_dir, - devices=1, - **trainer_params_copy, - ) - if val_loader is None: - trainer.fit(self, train_loader) - else: - trainer.fit(self, train_loader, val_loader) - - # load best model - if self.checkpoint_callback.best_model_path is not None: - checkpoint = torch.load(self.checkpoint_callback.best_model_path, weights_only=True) # noqa: S614 - self.load_state_dict(checkpoint["state_dict"]) - else: - print("checkpoint_callback: No best model found, using the last model.") - - def forward(self, x) -> torch.Tensor: - """ - Forward pass of the model. - - :param x: input data - :returns: predicted response - """ - for i in range(len(self.fully_connected_layers) - 2): - x = self.fully_connected_layers[i](x) - x = self.batch_norm_layers[i](x) - if self.dropout_layer is not None: - x = self.dropout_layer(x) - x = torch.relu(x) - - x = torch.relu(self.fully_connected_layers[-2](x)) - x = self.fully_connected_layers[-1](x) - - return x.squeeze() - - def _forward_loss_and_log(self, x, y, log_as: str): - """ - Forward pass, calculates the loss, and logs the loss. - - :param x: input data - :param y: response - :param log_as: either train_loss or val_loss - :returns: loss - """ - y_pred = self.forward(x) - result = self.loss(y_pred, y) - self.log(log_as, result, on_step=True, on_epoch=True, prog_bar=True) - - # Store predictions and targets for epoch-end metrics via mixin - self._store_predictions(y_pred, y, is_training=(log_as == "train_loss")) - - return result - - def training_step(self, batch): - """ - Overwrites the training step from the LightningModule. - - Does a forward pass, calculates the loss and logs the loss. - - :param batch: batch of data - :returns: loss - """ - x, y = batch - return self._forward_loss_and_log(x, y, "train_loss") - - def validation_step(self, batch): - """ - Overwrites the validation step from the LightningModule. - - Does a forward pass, calculates the loss and logs the loss. - - :param batch: batch of data - :returns: loss - """ - x, y = batch - return self._forward_loss_and_log(x, y, "val_loss") - - def predict(self, x: np.ndarray) -> np.ndarray: - """ - Predicts the response for the given input. - - :param x: input data - :returns: predicted response - """ - is_training = self.training - self.eval() - with torch.no_grad(): - y_pred = self.forward(torch.from_numpy(x).float().to(self.device)) - self.train(is_training) - return y_pred.cpu().detach().numpy() - - def configure_optimizers(self) -> torch.optim.Optimizer: - """ - Overwrites the configure_optimizers from the LightningModule. - - :returns: Adam optimizer - """ - return torch.optim.Adam(self.parameters()) diff --git a/drevalpy/models/SparseGO/__init__.py b/drevalpy/models/SparseGO/__init__.py deleted file mode 100644 index 82f53b99b..000000000 --- a/drevalpy/models/SparseGO/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""SparseGO model for drug response prediction.""" - -from .sparsego import SparseGOModel - -__all__ = ["SparseGOModel"] diff --git a/drevalpy/models/SparseGO/hyperparameters.yaml b/drevalpy/models/SparseGO/hyperparameters.yaml deleted file mode 100644 index dd1e42155..000000000 --- a/drevalpy/models/SparseGO/hyperparameters.yaml +++ /dev/null @@ -1,34 +0,0 @@ ---- -SparseGO: - input_type: - - expression - num_neurons_per_GO: - - 6 - num_neurons_per_final_GO: - - 6 - num_neurons_drug: - - [100, 50, 6] - - [200, 100, 50] - num_neurons_final: - - 12 - - 6 - drug_dim: - - 2048 - learning_rate: - - 0.1 - momentum: - - 0.9 - decay_rate: - - 0.002 - p_drop_genes: - - 0.15 - p_drop_terms: - - 0.15 - p_drop_drugs: - - 0.15 - p_drop_final: - - 0.0 - epochs: - - 400 - batch_size: - - 20000 diff --git a/drevalpy/models/SparseGO/sparsego.py b/drevalpy/models/SparseGO/sparsego.py deleted file mode 100644 index 30c887b40..000000000 --- a/drevalpy/models/SparseGO/sparsego.py +++ /dev/null @@ -1,731 +0,0 @@ -"""SparseGO model for drug response prediction. - -A sparse visible neural network (VNN) structured according to the Gene Ontology (GO) -hierarchy, combined with an ANN for drug fingerprints. - -Original authors: Sada Del Real & Rubio (2023, 10.1016/j.ebiom.2023.104767) -Code adapted from https://github.com/KatynaSada/SparseGO_lightning -""" - -import json -import os -import warnings -from typing import Any, cast - -import numpy as np -import torch -import torch.nn as nn -from scipy import sparse -from torch.utils.data import DataLoader, TensorDataset - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.models.drp_model import DRPModel -from drevalpy.models.utils import load_and_select_gene_features, load_drug_fingerprint_features - -from .utils import create_index, load_mapping, load_ontology, pairs_in_layers, sort_pairs - - -class SparseLinearNew(nn.Module): - """Sparse linear layer with user-defined connectivity. - - Applies a linear transformation y = xA^T + b where A is a sparse weight - matrix. Only the connections specified in the connectivity tensor are learned; - all other weights are permanently zero. - - :param in_features: Size of each input sample. - :param out_features: Size of each output sample. - :param bias: If True, adds a learnable bias. Default: True. - :param sparsity: Sparsity of weight matrix if connectivity is None. Default: 0.9. - :param connectivity: LongTensor of shape (2, nnz) specifying the (row, col) - indices of non-zero weights. Used for GO-structured layers. - """ - - def __init__( - self, - in_features: int, - out_features: int, - bias: bool = True, - sparsity: float = 0.9, - connectivity: torch.Tensor | None = None, - ): - """Initialize SparseLinearNew layer. - - :param in_features: Size of each input sample. - :param out_features: Size of each output sample. - :param bias: If True, adds a learnable bias. Default: True. - :param sparsity: Sparsity of weight matrix if connectivity is None. Default: 0.9. - :param connectivity: LongTensor of shape (2, nnz) specifying non-zero weight positions. - :raises ValueError: if connectivity has wrong shape or nnz exceeds matrix size. - """ - if not (in_features < 2**31 and out_features < 2**31 and sparsity < 1.0): - raise ValueError("in_features and out_features must be < 2^31, sparsity must be < 1.0") - if connectivity is not None: - if connectivity.shape[0] != 2 or connectivity.shape[1] <= 0: - raise ValueError("Input shape for connectivity should be (2, nnz)") - if connectivity.shape[1] > in_features * out_features: - raise ValueError("Nnz can't be bigger than the weight matrix") - - super().__init__() - self.in_features = in_features - self.out_features = out_features - self.connectivity = connectivity - - coalesce_device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu") - - if connectivity is None: - self.sparsity = sparsity - nnz = round((1.0 - sparsity) * in_features * out_features) - if in_features * out_features <= 10**8: - idx = np.random.choice(in_features * out_features, nnz, replace=False) - indices: torch.Tensor = torch.as_tensor(idx, device=coalesce_device) - row_ind = indices.floor_divide(in_features) - col_ind = indices.fmod(in_features) - else: - warnings.warn( - "Matrix too large to sample non-zero indices without replacement, sparsity will be approximate", - RuntimeWarning, - stacklevel=2, - ) - row_ind = torch.randint(0, out_features, (nnz,), device=coalesce_device) - col_ind = torch.randint(0, in_features, (nnz,), device=coalesce_device) - indices = torch.stack((row_ind, col_ind)) - else: - nnz = connectivity.shape[1] - self.sparsity = nnz / (out_features * in_features) - indices = connectivity.to(device=coalesce_device) - - values = torch.empty(nnz, device=coalesce_device) - sparse = torch.sparse_coo_tensor(indices, values, (out_features, in_features)).coalesce() - indices, values = sparse.indices(), sparse.values() - - self.register_buffer("indices", indices.cpu()) - self.weights = nn.Parameter(values.cpu()) - - if bias: - self.bias = nn.Parameter(torch.Tensor(out_features)) - else: - self.register_parameter("bias", None) - - self.reset_parameters() - - def reset_parameters(self) -> None: - """Initialize weights and bias with uniform distribution.""" - bound = 1 / self.in_features**0.5 - nn.init.uniform_(self.weights, -bound, bound) - if self.bias is not None: - nn.init.uniform_(self.bias, -bound, bound) - - def forward(self, inputs: torch.Tensor) -> torch.Tensor: - """Forward pass through sparse linear layer. - - :param inputs: Input tensor of shape (batch_size, in_features). - :return: Output tensor of shape (batch_size, out_features). - """ - output_shape = list(inputs.shape) - output_shape[-1] = self.out_features - - if len(output_shape) == 1: - inputs = inputs.view(1, -1) - inputs = inputs.flatten(end_dim=-2) - - indices = cast(torch.Tensor, self.indices) - sparse_matrix = torch.sparse_coo_tensor( - indices, - self.weights, - [self.out_features, self.in_features], - ) - output = torch.sparse.mm(sparse_matrix, inputs.t()).t() - - if self.bias is not None: - output += self.bias - - return output.view(output_shape) - - -class SparseGONetwork(nn.Module): - """Sparse Visible Neural Network structured according to the Gene Ontology hierarchy. - - Two-branch architecture: - - - VNN branch: sparse layers following GO parent-child relationships, takes - gene expression or mutation data as input. - - ANN branch: fully connected layers processing Morgan drug fingerprints. - - Both branches are concatenated and fed into a final regression head that - predicts the drug response. - - Adapted from sparseGO_nn in https://github.com/KatynaSada/SparseGO - - :param layer_connections: List of (parent, child) pair arrays per layer, - output of pairs_in_layers(). - :param num_neurons_per_GO: Number of neurons per GO term (default 6). - :param num_neurons_per_final_GO: Number of neurons in the final GO layer. - :param num_neurons_drug: List of hidden layer sizes for the drug ANN branch. - :param num_neurons_final: Number of neurons in the final combined layer. - :param drug_dim: Dimensionality of the drug fingerprint vector. - :param gene2id_mapping: Dictionary mapping gene names to indices in the - ontology (matches columns of the gene expression matrix). - :param p_drop_final: Dropout rate for the final combined layers. - :param p_drop_genes: Dropout rate for the gene input layer. - :param p_drop_terms: Dropout rate for GO term layers. - :param p_drop_drugs: Dropout rate for drug ANN layers. - """ - - def __init__( - self, - layer_connections: list, - num_neurons_per_GO: int, - num_neurons_per_final_GO: int, - num_neurons_drug: list[int], - num_neurons_final: int, - drug_dim: int, - gene2id_mapping: dict, - p_drop_final: float = 0, - p_drop_genes: float = 0.1, - p_drop_terms: float = 0.1, - p_drop_drugs: float = 0.1, - ): - """Initialize SparseGONetwork. - - :param layer_connections: List of (parent, child) pair arrays per layer. - :param num_neurons_per_GO: Number of neurons per GO term. - :param num_neurons_per_final_GO: Number of neurons in the final GO layer. - :param num_neurons_drug: List of hidden layer sizes for the drug ANN branch. - :param num_neurons_final: Number of neurons in the final combined layer. - :param drug_dim: Dimensionality of the drug fingerprint vector. - :param gene2id_mapping: Dictionary mapping gene names to integer indices. - :param p_drop_final: Dropout rate for the final combined layers. - :param p_drop_genes: Dropout rate for the gene input layer. - :param p_drop_terms: Dropout rate for GO term layers. - :param p_drop_drugs: Dropout rate for drug ANN layers. - """ - super().__init__() - - self.num_neurons_per_GO = num_neurons_per_GO - self.num_neurons_per_final_GO = num_neurons_per_final_GO - self.num_neurons_drug = num_neurons_drug - self.drug_dim = drug_dim - self.layer_connections = layer_connections - - print("\nNumber of neurons per GO term: ", num_neurons_per_GO) - print("Number of neurons of final GO term: ", num_neurons_per_final_GO) - print("Number of drug neurons: ", num_neurons_drug) - print("Number of final neurons: ", num_neurons_final) - - # (1) Layer of genes with terms - input_id = self._genes_layer(layer_connections[0], p_drop_genes, gene2id_mapping) - - print("Number of term-term hierarchy levels:", len(layer_connections)) - - # (2...) Layers of terms with terms - for i in range(1, len(layer_connections)): - neurons = num_neurons_per_final_GO if i == len(layer_connections) - 1 else num_neurons_per_GO - input_id = self._terms_layer(input_id, layer_connections[i], str(i), neurons, p_drop_terms) - - # Drug ANN branch - self._construct_drug_branch(p_drop_drugs) - - # Final combined layers - final_input_size = num_neurons_per_final_GO + num_neurons_drug[-1] - self.add_module("final_batchnorm_layer", nn.BatchNorm1d(final_input_size)) - self.add_module("drop_final", nn.Dropout(p_drop_final)) - self.add_module("final_linear_layer", nn.Linear(final_input_size, num_neurons_final)) - self.add_module("final_tanh", nn.Tanh()) - self.add_module("final_aux_batchnorm_layer", nn.BatchNorm1d(num_neurons_final)) - self.add_module("drop_aux_final", nn.Dropout(p_drop_final)) - self.add_module("final_aux_linear_layer", nn.Linear(num_neurons_final, 1)) - self.add_module("final_aux_tanh", nn.Tanh()) - self.add_module("final_linear_layer_output", nn.Linear(1, 1)) - - def _m(self, name: str) -> nn.Module: - """Get a registered submodule by name. - - :param name: Module name as registered via add_module. - :return: The submodule. - :raises ValueError: if the module is not found. - """ - module = self._modules[name] - if module is None: - raise ValueError(f"Module '{name}' not found") - return module - - def _genes_layer(self, genes_terms_pairs: np.ndarray, p_drop_genes: float, gene2id: dict) -> dict: - """Build the first sparse layer connecting genes to GO terms. - - :param genes_terms_pairs: Array of (GO_term, gene) pairs. - :param p_drop_genes: Dropout rate. - :param gene2id: Dictionary mapping gene names to indices. - :return: Dictionary mapping GO term names to their indices in this layer. - """ - term2id = create_index(genes_terms_pairs[:, 0]) - - self.gene_dim = len(gene2id) - self.term_dim = len(term2id) - - rows = [term2id[term] for term in genes_terms_pairs[:, 0]] - columns = [gene2id[gene] for gene in genes_terms_pairs[:, 1]] - data = np.ones(len(rows)) - - genes_terms = sparse.coo_matrix((data, (rows, columns)), shape=(self.term_dim, self.gene_dim)) - - # Expand to k neurons per GO term by repeating each row k times - genes_terms_more_neurons = sparse.lil_matrix((self.term_dim * self.num_neurons_per_GO, self.gene_dim)) - genes_terms = genes_terms.tolil() - row = 0 - for i in range(genes_terms_more_neurons.shape[0]): - if (i != 0) and (i % self.num_neurons_per_GO) == 0: - row += 1 - genes_terms_more_neurons[i, :] = genes_terms[row, :] - - rows_t = torch.from_numpy(sparse.find(genes_terms_more_neurons)[0]).view(1, -1).long() - cols_t = torch.from_numpy(sparse.find(genes_terms_more_neurons)[1]).view(1, -1).long() - connections = torch.cat((rows_t, cols_t), dim=0) - - input_terms = len(gene2id) - output_terms = self.num_neurons_per_GO * len(term2id) - - self.genes_terms_sparse_linear_1 = SparseLinearNew(input_terms, output_terms, connectivity=connections) - self.genes_terms_batchnorm = nn.BatchNorm1d(input_terms) - self.genes_terms_tanh = nn.Tanh() - self.drop_0 = nn.Dropout(p_drop_genes) - - return term2id - - def _terms_layer( - self, - input_id: dict, - layer_pairs: np.ndarray, - number: str, - neurons_per_GO: int, - p_drop_terms: float, - ) -> dict: - """Build one sparse layer connecting GO terms to GO terms. - - :param input_id: Dictionary mapping child GO term names to indices. - :param layer_pairs: Array of (parent_term, child_term) pairs for this layer. - :param number: Layer number as string, used for module naming. - :param neurons_per_GO: Number of neurons for parent terms in this layer. - :param p_drop_terms: Dropout rate. - :return: Dictionary mapping parent GO term names to their indices. - """ - output_id = create_index(layer_pairs[:, 0]) - - rows = [output_id[term] for term in layer_pairs[:, 0]] - columns = [input_id[term] for term in layer_pairs[:, 1]] - data = np.ones(len(rows)) - - connections_matrix = sparse.coo_matrix((data, (rows, columns)), shape=(len(output_id), len(input_id))) - - # Kronecker product to expand to k neurons per term - ones = sparse.csr_matrix(np.ones([neurons_per_GO, self.num_neurons_per_GO], dtype=int)) - connections_matrix_more_neurons = sparse.csr_matrix(sparse.kron(connections_matrix, ones)) - - rows_t = torch.from_numpy(sparse.find(connections_matrix_more_neurons)[0]).view(1, -1).long() - cols_t = torch.from_numpy(sparse.find(connections_matrix_more_neurons)[1]).view(1, -1).long() - connections = torch.cat((rows_t, cols_t), dim=0) - - input_terms = self.num_neurons_per_GO * len(input_id) - output_terms = neurons_per_GO * len(output_id) - - self.add_module( - f"GO_terms_sparse_linear_{number}", - SparseLinearNew(input_terms, output_terms, connectivity=connections), - ) - self.add_module(f"drop_{number}", nn.Dropout(p_drop_terms)) - self.add_module(f"GO_terms_tanh_{number}", nn.Tanh()) - self.add_module(f"GO_terms_batchnorm_{number}", nn.BatchNorm1d(input_terms)) - - return output_id - - def _construct_drug_branch(self, p_drop_drugs: float) -> None: - """Build the fully connected ANN branch for drug fingerprints. - - :param p_drop_drugs: Dropout rate for drug layers. - """ - input_size = self.drug_dim - for i in range(len(self.num_neurons_drug)): - self.add_module(f"drug_linear_layer_{i + 1}", nn.Linear(input_size, self.num_neurons_drug[i])) - self.add_module(f"drug_drop_{i + 1}", nn.Dropout(p_drop_drugs)) - self.add_module(f"drug_tanh_{i + 1}", nn.Tanh()) - self.add_module(f"drug_batchnorm_layer_{i + 1}", nn.BatchNorm1d(input_size)) - input_size = self.num_neurons_drug[i] - - def forward(self, x: torch.Tensor) -> torch.Tensor: - """Forward pass through the full SparseGO network. - - :param x: Input tensor of shape (batch_size, gene_dim + drug_dim). - :return: Predicted drug response of shape (batch_size, 1). - """ - gene_input = x.narrow(1, 0, self.gene_dim) - drug_input = x.narrow(1, self.gene_dim, self.drug_dim) - - # VNN branch - gene_output = cast(nn.Module, self._modules["genes_terms_batchnorm"])(gene_input) - gene_output = cast(nn.Module, self._modules["drop_0"])(gene_output) - terms_output = cast(nn.Module, self._modules["genes_terms_tanh"])( - cast(nn.Module, self._modules["genes_terms_sparse_linear_1"])(gene_output) - ) - - for i in range(1, len(self.layer_connections)): - terms_output = cast(nn.Module, self._modules[f"GO_terms_batchnorm_{i}"])(terms_output) - terms_output = cast(nn.Module, self._modules[f"drop_{i}"])(terms_output) - terms_output = cast(nn.Module, self._modules[f"GO_terms_tanh_{i}"])( - cast(nn.Module, self._modules[f"GO_terms_sparse_linear_{i}"])(terms_output) - ) - - # ANN branch - drug_out = drug_input - for i in range(1, len(self.num_neurons_drug) + 1): - drug_out = cast(nn.Module, self._modules[f"drug_batchnorm_layer_{i}"])(drug_out) - drug_out = cast(nn.Module, self._modules[f"drug_drop_{i}"])(drug_out) - drug_out = cast(nn.Module, self._modules[f"drug_tanh_{i}"])( - cast(nn.Module, self._modules[f"drug_linear_layer_{i}"])(drug_out) - ) - - # Final - final_input = torch.cat((terms_output, drug_out), 1) - output = cast(nn.Module, self._modules["final_batchnorm_layer"])(final_input) - output = cast(nn.Module, self._modules["drop_final"])(output) - output = cast(nn.Module, self._modules["final_tanh"])( - cast(nn.Module, self._modules["final_linear_layer"])(output) - ) - output = cast(nn.Module, self._modules["final_aux_batchnorm_layer"])(output) - output = cast(nn.Module, self._modules["drop_aux_final"])(output) - output = cast(nn.Module, self._modules["final_aux_tanh"])( - cast(nn.Module, self._modules["final_aux_linear_layer"])(output) - ) - return cast(nn.Module, self._modules["final_linear_layer_output"])(output) - - -class SparseGOModel(DRPModel): - """SparseGO drug response prediction model. - - Wraps SparseGONetwork as a drevalpy DRPModel. Supports gene expression - or mutation data as cell line features, and Morgan fingerprints as drug - features. - - Requires two additional files in the dataset directory: - - - sparseGO_ont.txt: GO ontology file with term-term and gene-term connections. - - gene2ind.txt: Mapping of gene names to indices in the expression matrix. - - These files can be generated using the create_sparsego_features.py featurizer. - """ - - cell_line_views = ["gene_expression", "mutations"] - drug_views = ["fingerprints"] - early_stopping = False - - def __init__(self) -> None: - """Initialize SparseGOModel.""" - super().__init__() - self.DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") - self.model: SparseGONetwork | None = None - self.hyperparameters: dict[str, Any] = {} - self.layer_connections: list | None = None - self.gene2id_mapping_ont: dict | None = None - self.ontology_gene_order: list[str] | None = None - self._checkpoint_path: str | None = None - - @classmethod - def get_model_name(cls) -> str: - """Return the model name. - - :return: SparseGO - """ - return "SparseGO" - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - """Store hyperparameters and build the network if ontology is already loaded. - - If load_cell_line_features() has not been called yet, network construction - is deferred to the first call of train() or predict(). - - :param hyperparameters: Dictionary with model hyperparameters. - """ - self.log_hyperparameters(hyperparameters) - self.hyperparameters = hyperparameters - - if self.layer_connections is not None and self.gene2id_mapping_ont is not None: - self._build_network() - - def _build_network(self) -> None: - """Build SparseGONetwork once layer_connections and hyperparameters are both available. - - If the model was restored via load(), the saved weights are loaded into the - freshly built network here, since the architecture can only be reconstructed - after load_cell_line_features() has read the ontology files. - - :raises ValueError: if layer_connections or gene2id_mapping_ont are not set. - """ - if self.layer_connections is None or self.gene2id_mapping_ont is None: - raise ValueError("Call load_cell_line_features() before building the network.") - self.model = SparseGONetwork( - layer_connections=self.layer_connections, - num_neurons_per_GO=self.hyperparameters.get("num_neurons_per_GO", 6), - num_neurons_per_final_GO=self.hyperparameters.get("num_neurons_per_final_GO", 6), - num_neurons_drug=self.hyperparameters.get("num_neurons_drug", [200, 100, 50]), - num_neurons_final=self.hyperparameters.get("num_neurons_final", 12), - drug_dim=self.hyperparameters.get("drug_dim", 2048), - gene2id_mapping=self.gene2id_mapping_ont, - p_drop_final=self.hyperparameters.get("p_drop_final", 0.0), - p_drop_genes=self.hyperparameters.get("p_drop_genes", 0.1), - p_drop_terms=self.hyperparameters.get("p_drop_terms", 0.1), - p_drop_drugs=self.hyperparameters.get("p_drop_drugs", 0.1), - ).to(self.DEVICE) - self._load_weights_if_needed() - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """Train the SparseGO model. - - :param output: Training drug response dataset. - :param cell_line_input: Cell line features (gene expression or mutations). - :param drug_input: Drug features (Morgan fingerprints). - :param output_earlystopping: Unused, kept for API compatibility. - :param model_checkpoint_dir: Unused, kept for API compatibility. - :raises ValueError: if drug_input is None or ontology not loaded. - """ - if drug_input is None: - raise ValueError("SparseGO requires drug features (fingerprints).") - if self.model is None: - if self.layer_connections is None or self.gene2id_mapping_ont is None: - raise ValueError("Call load_cell_line_features() before train().") - self._build_network() - if self.model is None: - raise ValueError("Model could not be built.") - - input_type = self.hyperparameters.get("input_type", "expression") - feature_key = "gene_expression" if input_type == "expression" else "mutations" - - cell_matrix = cell_line_input.get_feature_matrix(view=feature_key, identifiers=output.cell_line_ids) - drug_matrix = drug_input.get_feature_matrix(view="fingerprints", identifiers=output.drug_ids) - features = np.concatenate([cell_matrix, drug_matrix], axis=1) - labels = output.response.reshape(-1, 1) - - features_t = torch.from_numpy(features).float() - labels_t = torch.from_numpy(labels).float() - - dataset = TensorDataset(features_t, labels_t) - loader = DataLoader( - dataset, - batch_size=self.hyperparameters.get("batch_size", 10000), - shuffle=True, - ) - - criterion = nn.MSELoss() - lr = self.hyperparameters.get("learning_rate", 0.1) - decay_rate = self.hyperparameters.get("decay_rate", 0.002) - momentum = self.hyperparameters.get("momentum", 0.9) - optimizer = torch.optim.SGD(self.model.parameters(), lr=lr, momentum=momentum) - epochs = self.hyperparameters.get("epochs", 100) - - self.model.train() - for epoch in range(epochs): - current_lr = lr * (1 / (1 + decay_rate * epoch)) - for param_group in optimizer.param_groups: - param_group["lr"] = current_lr - - epoch_loss = 0.0 - for batch_features, batch_labels in loader: - batch_features = batch_features.to(self.DEVICE) - batch_labels = batch_labels.to(self.DEVICE) - optimizer.zero_grad() - outputs = self.model(batch_features) - loss = criterion(outputs, batch_labels) - loss.backward() - optimizer.step() - epoch_loss += loss.detach().item() - - print(f"SparseGO Epoch [{epoch + 1}/{epochs}] Loss: {epoch_loss / len(loader):.4f}") - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """Predict drug response for the given cell line and drug pairs. - - :param cell_line_ids: Array of cell line identifiers. - :param drug_ids: Array of drug identifiers. - :param cell_line_input: Cell line features. - :param drug_input: Drug features. - :return: Predicted response values as a 1D numpy array. - :raises ValueError: if drug_input is None or ontology not loaded. - """ - if drug_input is None: - raise ValueError("SparseGO requires drug features (fingerprints).") - if self.model is None: - if self.layer_connections is None or self.gene2id_mapping_ont is None: - raise ValueError("Call load_cell_line_features() before predict().") - self._build_network() - if self.model is None: - raise ValueError("Model could not be built.") - - input_type = self.hyperparameters.get("input_type", "expression") - feature_key = "gene_expression" if input_type == "expression" else "mutations" - - cell_matrix = cell_line_input.get_feature_matrix(view=feature_key, identifiers=cell_line_ids) - drug_matrix = drug_input.get_feature_matrix(view="fingerprints", identifiers=drug_ids) - features = np.concatenate([cell_matrix, drug_matrix], axis=1) - features_t = torch.from_numpy(features).float() - - dataset = TensorDataset(features_t, torch.zeros(len(features_t))) - loader = DataLoader( - dataset, - batch_size=self.hyperparameters.get("batch_size", 10000), - shuffle=False, - ) - - self.model.eval() - predictions = [] - with torch.no_grad(): - for batch_features, _ in loader: - batch_features = batch_features.to(self.DEVICE) - outputs = self.model(batch_features) - predictions.append(outputs.squeeze().cpu().numpy()) - - return np.concatenate(predictions) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """Load cell line features and build the GO ontology structure. - - Loads gene expression features via the standard drevalpy - mechanism, then restricts and reorders them to match the ontology's - gene order (from gene2ind.txt) after loading. - - :param data_path: Path to the data directory. - :param dataset_name: Name of the dataset (e.g. 'CTRPv2'). - :return: FeatureDataset with gene expression or mutation features, - already restricted to and ordered like the ontology genes. - :raises FileNotFoundError: if sparseGO_ont.txt or gene2ind.txt are missing. - :raises ValueError: if ontology genes are missing from the loaded features. - """ - input_type = self.hyperparameters.get("input_type", "expression") - feature_type = "gene_expression" if input_type == "expression" else "mutations" - - ont_file = os.path.join(data_path, dataset_name, "sparseGO_ont.txt") - gene2ind_file = os.path.join(data_path, dataset_name, "gene2ind.txt") - - if not os.path.exists(ont_file): - raise FileNotFoundError( - f"Ontology file not found: {ont_file}. " "Please generate it using create_sparsego_features.py." - ) - if not os.path.exists(gene2ind_file): - raise FileNotFoundError( - f"Gene index file not found: {gene2ind_file}. " "Please generate it using create_sparsego_features.py." - ) - - self.gene2id_mapping_ont = load_mapping(gene2ind_file) - self.ontology_gene_order = sorted(self.gene2id_mapping_ont, key=self.gene2id_mapping_ont.__getitem__) - - cell_line_features = load_and_select_gene_features( - feature_type=feature_type, - gene_list=None, - data_path=data_path, - dataset_name=dataset_name, - ) - - # Restrict + reorder to ontology gene order - feature_gene_names = cell_line_features.meta_info[feature_type] - gene_to_col = {gene: i for i, gene in enumerate(feature_gene_names)} - missing = [g for g in self.ontology_gene_order if g not in gene_to_col] - if missing: - raise ValueError( - f"Genes from gene2ind.txt missing in {feature_type} for dataset {dataset_name}: " - f"{missing[:5]}{'...' if len(missing) > 5 else ''}" - ) - col_idx = [gene_to_col[g] for g in self.ontology_gene_order] - - for cell_line in cell_line_features.features: - cell_line_features.features[cell_line][feature_type] = cell_line_features.features[cell_line][feature_type][ - col_idx - ] - cell_line_features.meta_info[feature_type] = np.array(self.ontology_gene_order) - - dG, terms_pairs, genes_terms_pairs = load_ontology(ont_file, self.gene2id_mapping_ont) - sorted_pairs, level_list, level_number = sort_pairs( - genes_terms_pairs, terms_pairs, dG, self.gene2id_mapping_ont - ) - self.layer_connections = pairs_in_layers(sorted_pairs, level_list, level_number) - self.gene_dim_input = len(self.gene2id_mapping_ont) - - return cell_line_features - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """Load drug features (Morgan fingerprints) and auto-detect drug_dim. - - :param data_path: Path to the data directory. - :param dataset_name: Name of the dataset. - :return: FeatureDataset with Morgan fingerprint features. - """ - features = load_drug_fingerprint_features( - data_path=data_path, - dataset_name=dataset_name, - ) - sample_id = list(features.identifiers)[0] - self.hyperparameters["drug_dim"] = features.get_feature_matrix( - view="fingerprints", identifiers=np.array([sample_id]) - ).shape[1] - return features - - def save(self, directory: str) -> None: - """Save the model weights and hyperparameters. - - :param directory: Directory to save model files. - :raises ValueError: if the model has not been trained. - """ - os.makedirs(directory, exist_ok=True) - - if self.model is None: - raise ValueError("Cannot save: model is not built.") - - torch.save(self.model.state_dict(), os.path.join(directory, "sparsego_model.pt")) # noqa: S614 - - save_hp = self.hyperparameters.copy() - with open(os.path.join(directory, "hyperparameters.json"), "w") as f: - json.dump(save_hp, f) - - @classmethod - def load(cls, directory: str) -> "SparseGOModel": - """Load a previously saved SparseGOModel. - - Note: load_cell_line_features() must be called after loading to - rebuild the ontology structure. The saved weights are then applied - automatically once the network is built (in _build_network), i.e. on - the first train()/predict() call or an explicit build_model(). - - :param directory: Directory containing model files. - :return: Loaded SparseGOModel instance. - """ - instance = cls() - - with open(os.path.join(directory, "hyperparameters.json")) as f: - instance.hyperparameters = json.load(f) - - instance._checkpoint_path = os.path.join(directory, "sparsego_model.pt") - - return instance - - def _load_weights_if_needed(self) -> None: - """Load saved weights into the model if a checkpoint path is set. - - Called internally by _build_network() after the network is constructed. - No-op unless the instance was created via load() (which sets the - checkpoint path). The path is cleared after a successful load so - subsequent rebuilds do not reload stale weights. - """ - if self._checkpoint_path is not None and self.model is not None: - self.model.load_state_dict( - torch.load(self._checkpoint_path, map_location=self.DEVICE, weights_only=True) # noqa: S614 - ) - self.model.eval() - self._checkpoint_path = None diff --git a/drevalpy/models/SuperFELTR/hyperparameters.yaml b/drevalpy/models/SuperFELTR/hyperparameters.yaml deleted file mode 100644 index b7764d687..000000000 --- a/drevalpy/models/SuperFELTR/hyperparameters.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -SuperFELTR: - mini_batch: 55 - dropout_rate: - - 0.5 - weight_decay: 0.01 - out_dim_expr_encoder: 256 - out_dim_mutation_encoder: 32 - out_dim_cnv_encoder: 64 - epochs: 30 - margin: 1.0 - learning_rate: 0.01 diff --git a/drevalpy/models/SuperFELTR/superfeltr.py b/drevalpy/models/SuperFELTR/superfeltr.py deleted file mode 100644 index 6334dfc56..000000000 --- a/drevalpy/models/SuperFELTR/superfeltr.py +++ /dev/null @@ -1,284 +0,0 @@ -""" -Contains the SuperFELTR model. - -Regression extension of Super.FELT: supervised feature extraction learning using triplet loss for drug response -prediction with multi-omics data. -Very similar to MOLI. Differences: - - * In MOLI, encoders and the classifier were trained jointly. Super.FELT trains them independently - * MOLI was trained without feature selection (except for the Variance Threshold on the gene expression). - Super.FELT uses feature selection for all omics data. - -The input remains the same: somatic mutation, copy number variation and gene expression data. -Original authors of SuperFELT: Park, Soh & Lee. (2021, 10.1186/s12859-021-04146-z) -Code adapted from their Github: https://github.com/DMCB-GIST/Super.FELT -and Hauptmann et al. (2023, 10.1186/s12859-023-05166-7) https://github.com/kramerlab/Multi-Omics_analysis -""" - -from typing import Any - -import numpy as np -import pytorch_lightning as pl - -from ...datasets.dataset import DrugResponseDataset, FeatureDataset -from ..drp_model import DRPModel -from ..MOLIR.utils import filter_and_sort_omics, get_dimensions_of_omics_data, make_ranges -from ..utils import VarianceFeatureSelector, get_multiomics_feature_dataset -from .utils import SuperFELTEncoder, SuperFELTRegressor, train_superfeltr_model - - -class SuperFELTR(DRPModel): - """Regression extension of Super.FELT.""" - - is_single_drug_model = True - cell_line_views = ["gene_expression", "mutations", "copy_number_variation_gistic"] - drug_views = [] - early_stopping = True - - def __init__(self) -> None: - """ - Initialization method for SuperFELTR Model. - - The encoders and the regressor are initialized to None because they are built later in the first training pass. - The hyperparameters are also initialized to an empty dict because they are initialized in build_model. The - ranges are initialized during training which is why here, they get dummy values. The best checkpoint is - determined after training. - """ - super().__init__() - # encoders and regressor are initialized to None because they are built later in the first training pass - self.expr_encoder: SuperFELTEncoder | None = None - self.mut_encoder: SuperFELTEncoder | None = None - self.cnv_encoder: SuperFELTEncoder | None = None - self.regressor: SuperFELTRegressor | None = None - self.hyperparameters: dict[str, Any] = dict() - # ranges are initialized later because they are initialized using the standard variation of the train - # response data which is only available when entering the training - self.ranges: tuple[float, float] = (0.0, 1.0) - # best checkpoint is determined after training - self.best_checkpoint: pl.callbacks.ModelCheckpoint | None = None - self.gene_expression_features = None - self.mutations_features = None - self.copy_number_variation_features = None - self.selectors: dict[str, VarianceFeatureSelector] = {} - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: SuperFELTR - """ - return "SuperFELTR" - - def build_model(self, hyperparameters) -> None: - """ - Builds the model from hyperparameters. - - :param hyperparameters: dictionary containing the hyperparameters for the model. Contain mini_batch, - dropout_rate, weight_decay, out_dim_expr_encoder, out_dim_mutation_encoder, out_dim_cnv_encoder, epochs, - variance thresholds for gene expression, mutation, and copy number variation, margin, and learning rate. - """ - # Log hyperparameters to wandb if enabled - self.log_hyperparameters(hyperparameters) - - self.hyperparameters = hyperparameters - - n_features = hyperparameters.get("n_features_per_view", 1000) - for view in self.cell_line_views: - self.selectors[view] = VarianceFeatureSelector(view=view, k=n_features) - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "superfeltr_checkpoints", - ) -> None: - """ - Does feature selection, trains the encoders sequentially, and then trains the regressor. - - If there is not enough training data, the model is trained with random initialization, if there is no - training data at all, the model is skipped and later on, NA is predicted. - - :param output: training data associated with the response output - :param cell_line_input: cell line omics features - :param drug_input: not needed, as it is a single drug model - :param output_earlystopping: optional early stopping dataset - :param model_checkpoint_dir: not needed - :raises ValueError: if drug_input is not None - """ - if drug_input is not None: - raise ValueError("SuperFELTR is a single drug model and does not require drug input.") - - if len(output) > 0: - cell_line_input = self._fit_feature_selection(output=output, cell_line_input=cell_line_input) - if output_earlystopping is not None and self.early_stopping and len(output_earlystopping) < 2: - output_earlystopping = None - dim_gex, dim_mut, dim_cnv = get_dimensions_of_omics_data(cell_line_input) - self.ranges = make_ranges(output) - - # difference to MOLI: encoders and regressor are trained independently - # Create and train encoders - encoders = {} - encoder_dims = {"expression": dim_gex, "mutation": dim_mut, "copy_number_variation_gistic": dim_cnv} - for omic_type, dim in encoder_dims.items(): - encoder = SuperFELTEncoder( - input_size=dim, hpams=self.hyperparameters, omic_type=omic_type, ranges=self.ranges - ) - if len(output) >= self.hyperparameters["mini_batch"]: - print(f"Training SuperFELTR Encoder for {omic_type} ... ") - best_checkpoint = train_superfeltr_model( - model=encoder, - hpams=self.hyperparameters, - output_train=output, - cell_line_input=cell_line_input, - output_earlystopping=output_earlystopping, - patience=5, - model_checkpoint_dir=model_checkpoint_dir, - wandb_project=self.wandb_project, - ) - encoders[omic_type] = SuperFELTEncoder.load_from_checkpoint(best_checkpoint.best_model_path) - else: - print( - f"Not enough training data provided for SuperFELTR Encoder for {omic_type}. Using random " - f"initialization." - ) - encoders[omic_type] = encoder - - self.expr_encoder, self.mut_encoder, self.cnv_encoder = ( - encoders["expression"], - encoders["mutation"], - encoders["copy_number_variation_gistic"], - ) - - self.regressor = SuperFELTRegressor( - input_size=self.hyperparameters["out_dim_expr_encoder"] - + self.hyperparameters["out_dim_mutation_encoder"] - + self.hyperparameters["out_dim_cnv_encoder"], - hpams=self.hyperparameters, - encoders=(self.expr_encoder, self.mut_encoder, self.cnv_encoder), - ) - if len(output) >= self.hyperparameters["mini_batch"]: - print("Training SuperFELTR Regressor ... ") - self.best_checkpoint = train_superfeltr_model( - model=self.regressor, - hpams=self.hyperparameters, - output_train=output, - cell_line_input=cell_line_input, - output_earlystopping=output_earlystopping, - patience=5, - model_checkpoint_dir=model_checkpoint_dir, - wandb_project=self.wandb_project, - ) - else: - print("Not enough training data provided for SuperFELTR Regressor. Using random initialization.") - self.best_checkpoint = None - else: - print("No training data provided, skipping model") - self.best_checkpoint = None - self.expr_encoder, self.mut_encoder, self.cnv_encoder, self.regressor = None, None, None, None - if self.best_checkpoint is not None: - # load best model - self.regressor = SuperFELTRegressor.load_from_checkpoint( - self.best_checkpoint.best_model_path, - input_size=self.hyperparameters["out_dim_expr_encoder"] - + self.hyperparameters["out_dim_mutation_encoder"] - + self.hyperparameters["out_dim_cnv_encoder"], - hpams=self.hyperparameters, - encoders=(self.expr_encoder, self.mut_encoder, self.cnv_encoder), - ) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the drug response. - - If there is no training data, NA is predicted. If there was not enough training data, predictions are made - with the randomly initialized model. - - :param cell_line_ids: cell line ids - :param drug_ids: drug ids - :param cell_line_input: cell line omics features - :param drug_input: drug omics features, not needed - :returns: predicted drug response - :raises ValueError: if drug_input is not None - """ - if self.expr_encoder is None or self.mut_encoder is None or self.cnv_encoder is None or self.regressor is None: - print("No training data was available, predicting NA") - return np.array([np.nan] * len(cell_line_ids)) - if ( - self.gene_expression_features is None - or self.mutations_features is None - or self.copy_number_variation_features is None - ): - raise ValueError("Model was not trained, no features available.") - - if drug_input is not None: - raise ValueError("SuperFELTR is a single drug model and does not require drug input.") - - for view in self.cell_line_views: - selector = self.selectors[view] - cell_line_input = selector.transform(cell_line_input) - - input_data = self.get_feature_matrices( - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - gene_expression = input_data["gene_expression"] - mutations = input_data["mutations"] - cnvs = input_data["copy_number_variation_gistic"] - - gene_expression, mutations, cnvs = filter_and_sort_omics( - model=self, gene_expression=gene_expression, mutations=mutations, cnvs=cnvs, cell_line_input=cell_line_input - ) - - if self.best_checkpoint is None: - print("Not enough training data provided for SuperFELTR Regressor. Predicting with random initialization.") - return self.regressor.predict(gene_expression, mutations, cnvs) - - return self.regressor.predict(gene_expression, mutations, cnvs) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features: gene expression, mutations, and copy number variation. - - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :returns: FeatureDataset containing the cell line gene expression features, mutations, and copy number variation - """ - feature_dataset = get_multiomics_feature_dataset( - data_path=data_path, dataset_name=dataset_name, gene_lists=None, omics=self.cell_line_views - ) - # log transformation - feature_dataset.apply(function=np.arcsinh, view="gene_expression") - return feature_dataset - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset | None: - """ - Returns None, as drug features are not needed for SuperFELTR. - - :param data_path: Path to the fingerprints, e.g., data/ - :param dataset_name: Name of the dataset - :returns: None - """ - return None - - def _fit_feature_selection(self, output: DrugResponseDataset, cell_line_input: FeatureDataset) -> FeatureDataset: - for view in self.cell_line_views: - selector = self.selectors[view] - selector.fit(cell_line_input, output) - cell_line_input = selector.transform(cell_line_input) - - self.gene_expression_features = cell_line_input.meta_info["gene_expression"] - self.mutations_features = cell_line_input.meta_info["mutations"] - self.copy_number_variation_features = cell_line_input.meta_info["copy_number_variation_gistic"] - return cell_line_input diff --git a/drevalpy/models/__init__.py b/drevalpy/models/__init__.py index 334d407cb..f5092380b 100644 --- a/drevalpy/models/__init__.py +++ b/drevalpy/models/__init__.py @@ -1,112 +1,17 @@ -"""Module containing all drug response prediction models.""" +"""Public drug response prediction models. -__all__ = [ - "MULTI_DRUG_MODEL_FACTORY", - "SINGLE_DRUG_MODEL_FACTORY", - "MODEL_FACTORY", - "NaivePredictor", - "NaiveDrugMeanPredictor", - "NaiveCellLineMeanPredictor", - "NaiveTissueMeanPredictor", - "NaiveTissueDrugMeanPredictor", - "NaiveMeanEffectsPredictor", - "ElasticNetModel", - "RandomForest", - "SVMRegressor", - "SimpleNeuralNetwork", - "MultiViewNeuralNetwork", - "MultiViewRandomForest", - "SingleDrugRandomForest", - "SingleDrugElasticNet", - "SRMF", - "GradientBoosting", - "MOLIR", - "SuperFELTR", - "DIPKModel", - "DrugGNN", - "PharmaFormerModel", - "PrecilyModel", - "KNNRegressor", - "AdaBoostDecisionTree", - "LassoModel", - "MultiViewXGBoost", - "MultiViewLightGBM", - "SparseGO", -] - -from .baselines.multi_view_lightgbm import MultiViewLightGBM -from .baselines.multi_view_random_forest import MultiViewRandomForest -from .baselines.multi_view_xgboost import MultiViewXGBoost -from .baselines.naive_pred import ( - NaiveCellLineMeanPredictor, - NaiveDrugMeanPredictor, - NaiveMeanEffectsPredictor, - NaivePredictor, - NaiveTissueDrugMeanPredictor, - NaiveTissueMeanPredictor, -) -from .baselines.singledrug_baselines import SingleDrugElasticNet, SingleDrugRandomForest -from .baselines.sklearn_models import ( - AdaBoostDecisionTree, - ElasticNetModel, - GradientBoosting, - KNNRegressor, - LassoModel, - RandomForest, - SVMRegressor, -) -from .DIPK.dipk import DIPKModel -from .drp_model import DRPModel -from .DrugGNN import DrugGNN -from .MOLIR.molir import MOLIR -from .PharmaFormer.pharmaformer import PharmaFormerModel -from .Precily import PrecilyModel -from .SimpleNeuralNetwork.multi_view_neural_network import MultiViewNeuralNetwork -from .SimpleNeuralNetwork.simple_neural_network import SimpleNeuralNetwork -from .SparseGO.sparsego import SparseGOModel -from .SRMF.srmf import SRMF -from .SuperFELTR.superfeltr import SuperFELTR +``construct_model`` returns thin generated ``DRPModel`` subclasses. +Every built-in model name has a zoo YAML under ``drevalpy/models/zoo/``. +""" -# SINGLE_DRUG_MODEL_FACTORY is used in the pipeline! -SINGLE_DRUG_MODEL_FACTORY: dict[str, type[DRPModel]] = { - "SingleDrugElasticNet": SingleDrugElasticNet, - "SingleDrugRandomForest": SingleDrugRandomForest, - "MOLIR": MOLIR, - "SuperFELTR": SuperFELTR, -} +from __future__ import annotations -# MULTI_DRUG_MODEL_FACTORY is used in the pipeline! -MULTI_DRUG_MODEL_FACTORY: dict[str, type[DRPModel]] = { - # Naive predictors - "NaivePredictor": NaivePredictor, - "NaiveCellLineMeanPredictor": NaiveCellLineMeanPredictor, - "NaiveDrugMeanPredictor": NaiveDrugMeanPredictor, - "NaiveMeanEffectsPredictor": NaiveMeanEffectsPredictor, - "NaiveTissueMeanPredictor": NaiveTissueMeanPredictor, - "NaiveTissueDrugMeanPredictor": NaiveTissueDrugMeanPredictor, - # Sklearn Baselines - "AdaBoostDecisionTree": AdaBoostDecisionTree, - "ElasticNet": ElasticNetModel, - "Lasso": LassoModel, - "GradientBoosting": GradientBoosting, - "KNNRegressor": KNNRegressor, - "RandomForest": RandomForest, - "MultiViewRandomForest": MultiViewRandomForest, - "SVR": SVMRegressor, - # Other Baselines - "DrugGNN": DrugGNN, - "SimpleNeuralNetwork": SimpleNeuralNetwork, - "MultiViewNeuralNetwork": MultiViewNeuralNetwork, - "MultiViewXGBoost": MultiViewXGBoost, - "MultiViewLightGBM": MultiViewLightGBM, - # Published models - "DIPK": DIPKModel, - "PharmaFormer": PharmaFormerModel, - "SRMF": SRMF, - "Precily": PrecilyModel, - "SparseGO": SparseGOModel, -} +from .construct import construct_model +from .drp_model import DRPModel +from .mixins._persistence_io import load_model -# MODEL_FACTORY is used in the pipeline! -MODEL_FACTORY = MULTI_DRUG_MODEL_FACTORY.copy() -MODEL_FACTORY.update(SINGLE_DRUG_MODEL_FACTORY) +__all__ = [ + "DRPModel", + "construct_model", + "load_model", +] diff --git a/drevalpy/models/_hp_key_grammar.py b/drevalpy/models/_hp_key_grammar.py new file mode 100644 index 000000000..f80d37c1e --- /dev/null +++ b/drevalpy/models/_hp_key_grammar.py @@ -0,0 +1,139 @@ +"""The string grammar of qualified hyperparameter keys. + +A qualified key names one knob on one component of a composed model stack: + +* ``predictor..`` +* ``cell_line_featurizer..`` +* ``drug_featurizer..`` + +where ```` is a featurizer name optionally qualified by a view, as in +``pca[methylation]``. + +This module is a deliberate leaf: it imports nothing from ``drevalpy``, so both +``drevalpy.models.config`` and ``drevalpy.models.tuning`` can build and parse +keys from a single definition instead of each keeping its own copy. +""" + +from __future__ import annotations + +import re + +__all__ = [ + "CELL_LINE_SLOT", + "DRUG_SLOT", + "FEATURIZER_SLOTS", + "PREDICTOR_SLOT", + "REGISTRY_TO_SLOT", + "SLOT_TO_REGISTRY", + "featurizer_prefix", + "is_featurizer_slot_key", + "predictor_prefix", + "reject_indexed_featurizer_key", + "split_predictor_key", + "split_prefixed_key", +] + +CELL_LINE_SLOT = "cell_line_featurizer" +DRUG_SLOT = "drug_featurizer" +PREDICTOR_SLOT = "predictor" + +#: The two featurizer slots, in the order stacks declare them. +FEATURIZER_SLOTS = (CELL_LINE_SLOT, DRUG_SLOT) + +#: Registry name (as used by ``FeaturizerConfig.registry``) to slot name. +REGISTRY_TO_SLOT = { + "cell_line": CELL_LINE_SLOT, + "drug": DRUG_SLOT, +} + +SLOT_TO_REGISTRY = {slot: registry for registry, slot in REGISTRY_TO_SLOT.items()} + +_SLOT_ALTERNATION = "|".join(FEATURIZER_SLOTS) + +_INDEXED_FEATURIZER_KEY_RE = re.compile( + rf"^(?P{_SLOT_ALTERNATION})\.(?P[^.]+)\.(?P\d+)\.(?P.+)$" +) + +_QUALIFIED_FEATURIZER_KEY_RE = re.compile( + rf"^(?P{_SLOT_ALTERNATION})\.(?P[^.]+(?:\[[^\]]+\])?)\.(?P.+)$" +) + + +def featurizer_prefix(registry: str, selector: str, param: str) -> str: + """Build the qualified key for a featurizer parameter. + + :param registry: Registry name, ``cell_line`` or ``drug``. + :param selector: Featurizer name, optionally view-qualified. + :param param: Parameter name. + :returns: ``..``. + """ + return f"{REGISTRY_TO_SLOT[registry]}.{selector}.{param}" + + +def predictor_prefix(name: str, param: str) -> str: + """Build the qualified key for a predictor parameter. + + :param name: Registered predictor name. + :param param: Parameter name. + :returns: ``predictor..``. + """ + return f"{PREDICTOR_SLOT}.{name}.{param}" + + +def is_featurizer_slot_key(key: str) -> bool: + """Report whether *key* is already addressed at a featurizer slot. + + :param key: Candidate hyperparameter key. + :returns: ``True`` when *key* starts with one of :data:`FEATURIZER_SLOTS`. + """ + return any(key.startswith(f"{slot}.") for slot in FEATURIZER_SLOTS) + + +def reject_indexed_featurizer_key(key: str) -> None: + """Refuse the withdrawn ``...`` notation. + + :param key: Candidate hyperparameter key. + :raises ValueError: When *key* uses the indexed notation. + """ + match = _INDEXED_FEATURIZER_KEY_RE.match(key) + if match is None: + return + slot = match.group("slot") + name = match.group("name") + param = match.group("param") + msg = ( + f"Indexed featurizer hyperparameter keys are no longer supported: {key!r}. " + f"Use a qualified selector such as " + f"'{slot}.{name}[].{param}' " + f"or '{slot}.{name}.{param}'." + ) + raise ValueError(msg) + + +def split_prefixed_key(key: str) -> tuple[str, str, str] | None: + """Parse ``..`` into registry, selector, and param. + + :param key: Qualified hyperparameter key from a flat config. + :returns: ``(registry, selector, param)`` tuple, or ``None`` when unparsable. + :raises ValueError: When *key* uses the withdrawn indexed notation. + """ + reject_indexed_featurizer_key(key) + match = _QUALIFIED_FEATURIZER_KEY_RE.match(key) + if match is None: + return None + return SLOT_TO_REGISTRY[match.group("slot")], match.group("selector"), match.group("param") + + +def split_predictor_key(key: str) -> tuple[str, str] | None: + """Parse ``predictor..`` into predictor name and param. + + :param key: Qualified hyperparameter key. + :returns: ``(predictor_name, param)`` tuple, or ``None`` when unparsable. + """ + parts = key.split(".") + if len(parts) < 3 or parts[0] != PREDICTOR_SLOT: + return None + predictor_name, *param_parts = parts[1:] + if not param_parts: + return None + return predictor_name, ".".join(param_parts) diff --git a/drevalpy/models/_model_lookup.py b/drevalpy/models/_model_lookup.py new file mode 100644 index 000000000..37b52da0b --- /dev/null +++ b/drevalpy/models/_model_lookup.py @@ -0,0 +1,24 @@ +"""Internal model-name resolution.""" + +from __future__ import annotations + +from drevalpy.models.zoo import list_zoo_names +from drevalpy.types.enums.model_scope import ModelScope + + +def known_model_names(*, include_external: bool = True) -> list[str]: + """Return sorted zoo model names available for CLI/experiment resolution. + + :param include_external: Include externally registered zoo entries. + :returns: Sorted list of resolvable model names. + """ + return list_zoo_names(include_external=include_external) + + +def single_drug_model_names(*, include_external: bool = True) -> list[str]: + """Return sorted single-drug zoo names. + + :param include_external: Include externally registered zoo entries. + :returns: Sorted list of single-drug preset names. + """ + return list_zoo_names(include_external=include_external, scope=ModelScope.SINGLE_DRUG) diff --git a/drevalpy/models/baselines/__init__.py b/drevalpy/models/baselines/__init__.py deleted file mode 100644 index 6d8f7ca37..000000000 --- a/drevalpy/models/baselines/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Module containing the baseline models.""" diff --git a/drevalpy/models/baselines/hyperparameters.yaml b/drevalpy/models/baselines/hyperparameters.yaml deleted file mode 100644 index 8af4bcf1c..000000000 --- a/drevalpy/models/baselines/hyperparameters.yaml +++ /dev/null @@ -1,296 +0,0 @@ -# Hyperparameters for the baseline models - -# Naive Predictors: no hyperparameters -NaivePredictor: -NaiveDrugMeanPredictor: -NaiveCellLineMeanPredictor: -NaiveMeanEffectsPredictor: -NaiveTissueMeanPredictor: -NaiveTissueDrugMeanPredictor: - -# Global single-omic sklearn models, input can be adjusted to omic of choice -ElasticNet: - cell_line_views: - - gene_expression - - proteomics - drug_views: - - fingerprints - l1_ratio: - - 0 - - 0.5 - - 1 - alpha: - - 1 - - 0.8 - - 0.6 - - 0.4 - - 0.2 - - 0.1 - - 5 - - 10 - - 100 - proteomics_feature_threshold: - - 0.7 - proteomics_n_features: - - 1000 - proteomics_normalization_width: - - 0.3 - proteomics_normalization_downshift: - - 1.8 - -Lasso: - cell_line_views: - - gene_expression - - proteomics - drug_views: - - fingerprints - alpha: - - 0.0001 - - 0.001 - - 0.01 - - 0.1 - - 1 - proteomics_feature_threshold: - - 0.7 - proteomics_n_features: - - 1000 - proteomics_normalization_width: - - 0.3 - proteomics_normalization_downshift: - - 1.8 - -RandomForest: - cell_line_views: - - gene_expression - - proteomics - drug_views: - - fingerprints - n_estimators: - - 100 - max_depth: - - 5 - - 10 - - 30 - max_samples: - - 0.2 - n_jobs: - - -1 - criterion: - - squared_error - -GradientBoosting: - cell_line_views: - - gene_expression - - proteomics - drug_views: - - fingerprints - max_iter: - - 100 - learning_rate: - - 0.1 - - 0.01 - max_depth: - - 5 - - 10 - - 30 - -AdaBoostDecisionTree: - cell_line_views: - - gene_expression - - proteomics - drug_views: - - fingerprints - n_estimators: - - 50 - max_depth: - - 4 - min_samples_split: - - 2 - - 6 - min_samples_leaf: - - 1 - - 3 - -SVR: - cell_line_views: - - gene_expression - - proteomics - drug_views: - - fingerprints - kernel: - - rbf - C: - - 0.001 - - 0.01 - - 0.1 - - 1 - - 10 - - 100 - epsilon: - - 0.001 - - 0.01 - - 0.1 - - 0.5 - - 1 - max_iter: - - 500 - -# Global multi-input sklearn models, input can be adjusted to omic list of choice -MultiViewRandomForest: - cell_line_views: - - - gene_expression - - methylation - - mutations - - copy_number_variation_gistic - drug_views: - - fingerprints - n_estimators: - - 100 - max_depth: - - 5 - - 10 - - 30 - max_samples: - - 0.2 - n_jobs: - - -1 - criterion: - - squared_error - methylation_n_components: - - 100 - proteomics_feature_threshold: - - 0.7 - proteomics_n_features: - - 1000 - proteomics_normalization_width: - - 0.3 - proteomics_normalization_downshift: - - 1.8 - -# Single-drug single-omic sklearn models, input can be adjusted to omic of choice -SingleDrugRandomForest: - cell_line_views: - - gene_expression - - proteomics - n_estimators: - - 100 - max_depth: - - 5 - - 10 - - 30 - max_samples: - - 0.2 - n_jobs: - - -1 - criterion: - - squared_error - proteomics_feature_threshold: - - 0.7 - proteomics_n_features: - - 1000 - proteomics_normalization_width: - - 0.3 - proteomics_normalization_downshift: - - 1.8 - -SingleDrugElasticNet: - cell_line_views: - - gene_expression - - proteomics - l1_ratio: - - 0.2 - - 0.5 - - 0.9 - alpha: - - 1 - - 0.8 - - 0.6 - - 0.4 - - 0.2 - - 0.1 - - 5 - - 10 - - 100 - proteomics_feature_threshold: - - 0.7 - proteomics_n_features: - - 1000 - proteomics_normalization_width: - - 0.3 - proteomics_normalization_downshift: - - 1.8 -KNNRegressor: - cell_line_views: - - gene_expression - - proteomics - drug_views: - - fingerprints - n_neighbors: - - 5 - - 10 - - 3 - weights: - - distance - variance: - - 0.75 - - 0.8 - - 0.85 - - 0.9 - - 0.95 - - 0.7 - - 0.6 - - 0.5 - -MultiViewXGBoost: - cell_line_views: - - - gene_expression - - methylation - - mutations - - copy_number_variation_gistic - - gene_expression - - proteomics - drug_views: - - fingerprints - learning_rate: - - 0.1 - max_depth: - - 10 - subsample: - - 0.8 - colsample_bytree: - - 0.6 - - 0.8 - reg_alpha: - - 0 - - 1 - reg_lambda: - - 0.1 - -MultiViewLightGBM: - cell_line_views: - - - gene_expression - - methylation - - mutations - - copy_number_variation_gistic - - gene_expression - - proteomics - drug_views: - - fingerprints - learning_rate: - - 0.1 - num_leaves: - - 31 - - 63 - - 127 - subsample: - - 0.8 - colsample_bytree: - - 0.6 - - 0.8 - reg_alpha: - - 0 - - 1 - reg_lambda: - - 0 - - 0.1 - - 1 diff --git a/drevalpy/models/baselines/multi_view_lightgbm.py b/drevalpy/models/baselines/multi_view_lightgbm.py deleted file mode 100644 index 4c73d976f..000000000 --- a/drevalpy/models/baselines/multi_view_lightgbm.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Contains the baseline MultiViewLightGBM model.""" - -import json -import os - -import joblib -import numpy as np -from sklearn.decomposition import PCA -from sklearn.preprocessing import StandardScaler - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset - -from ..drp_model import DRPModel -from ..utils import ( - ProteomicsMedianCenterAndImputeTransformer, - _get_view_as_list, - load_multi_cell_line_view, - load_single_drug_view, - prepare_expression_and_methylation, - prepare_proteomics, -) - - -class MultiViewLightGBM(DRPModel): - """LightGBM model with multi-omic cell line features and drug fingerprints.""" - - cell_line_views = [ - "gene_expression", - "methylation", - "mutations", - "copy_number_variation_gistic", - ] - drug_views = ["fingerprints"] - - def __init__(self): - """Initializes the MultiViewLightGBM model.""" - super().__init__() - self.model = None - self.gene_expression_scaler = StandardScaler() - # methylation-specific defaults - self.methylation_scaler = StandardScaler() - self.methylation_pca = None - self.pca_ncomp = 100 - # proteomics-specific defaults - self.proteomics_transformer = None - self.proteomics_feature_threshold = 0.7 - self.proteomics_n_features = 1000 - self.proteomics_normalization_width = 0.3 - self.proteomics_normalization_downshift = 1.8 - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: MultiViewLightGBM - """ - return "MultiViewLightGBM" - - def build_model(self, hyperparameters: dict) -> None: - """ - Builds the model from hyperparameters. - - :param hyperparameters: dictionary containing the hyperparameters. - :raises ImportError: if lightgbm is not installed. - """ - try: - import lightgbm as lgb - except ImportError as e: - raise ImportError( - "MultiViewLightGBM requires the optional 'lightgbm' extra. " - "Install it with: pip install drevalpy[lightgbm] (or `poetry install -E lightgbm`)." - ) from e - - self.log_hyperparameters(hyperparameters) - self.hyperparameters = hyperparameters - self.cell_line_views = _get_view_as_list( - hyperparameters.get( - "cell_line_views", - ["gene_expression", "methylation", "mutations", "copy_number_variation_gistic"], - ) - ) - self.drug_views = _get_view_as_list(hyperparameters.get("drug_views", ["fingerprints"])) - if "methylation" in self.cell_line_views: - self.pca_ncomp = hyperparameters.get("methylation_n_components", 100) - if "proteomics" in self.cell_line_views: - self.proteomics_feature_threshold = hyperparameters.get("proteomics_feature_threshold", 0.7) - self.proteomics_n_features = hyperparameters.get("proteomics_n_features", 1000) - self.proteomics_normalization_width = hyperparameters.get("proteomics_normalization_width", 0.3) - self.proteomics_normalization_downshift = hyperparameters.get("proteomics_normalization_downshift", 1.8) - self.proteomics_transformer = ProteomicsMedianCenterAndImputeTransformer( - feature_threshold=self.proteomics_feature_threshold, - n_features=self.proteomics_n_features, - normalization_downshift=self.proteomics_normalization_downshift, - normalization_width=self.proteomics_normalization_width, - ) - self.model = lgb.LGBMRegressor( - n_estimators=hyperparameters.get("n_estimators", 100), - learning_rate=hyperparameters.get("learning_rate", 0.1), - max_depth=hyperparameters.get("max_depth", 6), - num_leaves=hyperparameters.get("num_leaves", 63), - subsample=hyperparameters.get("subsample", 0.8), - colsample_bytree=hyperparameters.get("colsample_bytree", 0.8), - reg_alpha=hyperparameters.get("reg_alpha", 0.0), - reg_lambda=hyperparameters.get("reg_lambda", 0.0), - random_state=42, - n_jobs=-1, - verbosity=-1, - ) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features. - - :param data_path: data path e.g. data/ - :param dataset_name: dataset name e.g. GDSC1 - :returns: FeatureDataset containing the cell line omics features - """ - return load_multi_cell_line_view(self.cell_line_views, data_path, dataset_name, self.get_model_name()) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset | None: - """ - Loads the drug features. - - :param data_path: path to the drug features, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC1 - :returns: FeatureDataset containing the drug features - """ - return load_single_drug_view(self.drug_views, data_path, dataset_name, self.get_model_name()) - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "", - ) -> None: - """ - Trains the model. - - :param output: training dataset containing the response output - :param cell_line_input: cell line omics features - :param drug_input: drug features - :param output_earlystopping: not used - :param model_checkpoint_dir: not used - """ - if "methylation" in self.cell_line_views: - first_cl_feature = next(iter(cell_line_input.features.values())) - n_met_features = first_cl_feature["methylation"].shape[0] - n_components = min(self.pca_ncomp, n_met_features) - self.methylation_pca = PCA(n_components=n_components) - - if "gene_expression" in self.cell_line_views or "methylation" in self.cell_line_views: - cell_line_input = prepare_expression_and_methylation( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - gene_expression_scaler=self.gene_expression_scaler, - methylation_scaler=self.methylation_scaler, - methylation_pca=self.methylation_pca, - ) - - if "proteomics" in self.cell_line_views: - cell_line_input = prepare_proteomics( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - transformer=self.proteomics_transformer, - ) - - inputs = self.get_feature_matrices( - cell_line_ids=output.cell_line_ids, - drug_ids=output.drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - array_list = [inputs[view] for view in self.cell_line_views + self.drug_views] - x = np.concatenate(array_list, axis=1) - self.model.fit(x, output.response) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the response for the given input. - - :param cell_line_ids: cell line ids - :param drug_ids: drug ids - :param cell_line_input: cell line omics features - :param drug_input: drug features - :returns: predicted response - """ - if "gene_expression" in self.cell_line_views or "methylation" in self.cell_line_views: - cell_line_input = prepare_expression_and_methylation( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - gene_expression_scaler=self.gene_expression_scaler, - methylation_scaler=self.methylation_scaler, - methylation_pca=self.methylation_pca, - ) - - if "proteomics" in self.cell_line_views: - cell_line_input = prepare_proteomics( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - transformer=self.proteomics_transformer, - ) - - inputs = self.get_feature_matrices( - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - array_list = [inputs[view] for view in self.cell_line_views + self.drug_views] - x = np.concatenate(array_list, axis=1) - return self.model.predict(x) - - def save(self, directory: str) -> None: - """ - Saves the model to disk. - - :param directory: target directory - """ - os.makedirs(directory, exist_ok=True) - joblib.dump(self.model, os.path.join(directory, "model.pkl")) - with open(os.path.join(directory, "hyperparameters.json"), "w") as f: - json.dump(self.hyperparameters, f) - if "gene_expression" in self.cell_line_views: - joblib.dump(self.gene_expression_scaler, os.path.join(directory, "gene_scaler.pkl")) - if "methylation" in self.cell_line_views: - joblib.dump(self.methylation_scaler, os.path.join(directory, "methylation_scaler.pkl")) - joblib.dump(self.methylation_pca, os.path.join(directory, "methylation_pca.pkl")) - if self.proteomics_transformer is not None: - joblib.dump(self.proteomics_transformer, os.path.join(directory, "proteomics_transformer.pkl")) - - @classmethod - def load(cls, directory: str) -> "MultiViewLightGBM": - """ - Loads the model from disk. - - :param directory: directory containing the saved model files - :returns: restored MultiViewLightGBM instance - """ - instance = cls() - with open(os.path.join(directory, "hyperparameters.json")) as f: - hyperparameters = json.load(f) - instance.build_model(hyperparameters) - instance.model = joblib.load(os.path.join(directory, "model.pkl")) - if "gene_expression" in instance.cell_line_views: - instance.gene_expression_scaler = joblib.load(os.path.join(directory, "gene_scaler.pkl")) - if "methylation" in instance.cell_line_views: - instance.methylation_scaler = joblib.load(os.path.join(directory, "methylation_scaler.pkl")) - instance.methylation_pca = joblib.load(os.path.join(directory, "methylation_pca.pkl")) - transformer_path = os.path.join(directory, "proteomics_transformer.pkl") - if os.path.exists(transformer_path): - instance.proteomics_transformer = joblib.load(transformer_path) - return instance diff --git a/drevalpy/models/baselines/multi_view_random_forest.py b/drevalpy/models/baselines/multi_view_random_forest.py deleted file mode 100644 index eef3a30c2..000000000 --- a/drevalpy/models/baselines/multi_view_random_forest.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Contains the Multi-OMICS Random Forest model.""" - -import os - -import joblib -import numpy as np -from sklearn.decomposition import PCA - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset - -from ..utils import load_multi_cell_line_view -from .sklearn_models import RandomForest - - -class MultiViewRandomForest(RandomForest): - """Multi-View Random Forest model.""" - - cell_line_views = [ - "gene_expression", - "methylation", - "mutations", - "copy_number_variation_gistic", - ] - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: MultiViewRandomForest - """ - return "MultiViewRandomForest" - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features for a multi-view random forest. - - :param data_path: data path e.g. data/ - :param dataset_name: dataset name e.g. GDSC1 - :returns: FeatureDataset containing the cell line omics features - """ - return load_multi_cell_line_view(self.cell_line_views, data_path, dataset_name, self.get_model_name()) - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Trains the model: the number of features is the number of genes + the number of fingerprints. - - :param output: training dataset containing the response output - :param cell_line_input: training dataset containing the OMICs - :param drug_input: training dataset containing fingerprints data - :param output_earlystopping: not needed - :param model_checkpoint_dir: not needed - """ - inputs = self.get_feature_matrices( - cell_line_ids=output.cell_line_ids, - drug_ids=output.drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - # concatenate in the order of self.cell_line_views - array_list = [] - for view in self.cell_line_views: - feature_mat = inputs[view] - - if view == "methylation": - if feature_mat.shape[1] > self.methylation_n_components: - self.methylation_pca = PCA(n_components=self.methylation_n_components) - else: - self.methylation_pca = PCA(n_components=feature_mat.shape[1]) - feature_mat = self.methylation_pca.fit_transform(feature_mat) - - array_list.append(feature_mat) - - x = np.concatenate(array_list, axis=1) - self.model.fit(x, output.response) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the response for the given input. - - :param cell_line_ids: cell line ids - :param drug_ids: drug ids - :param cell_line_input: cell line input - :param drug_input: drug input - :returns: predicted response - :raises RuntimeError: if PCA has not been fit - """ - if not hasattr(self.methylation_pca, "components_"): - raise RuntimeError("PCA has not been fit. Call train() before predict().") - - inputs = self.get_feature_matrices( - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - # concatenate in the order of self.cell_line_views - array_list = [] - for view in self.cell_line_views: - feature_mat = inputs[view] - - if view == "methylation": - feature_mat = self.methylation_pca.transform(feature_mat) - - array_list.append(feature_mat) - - x = np.concatenate(array_list, axis=1) - - return self.model.predict(x) - - def save(self, directory: str) -> None: - """ - Saves the trained model, hyperparameters, scaler, and PCA transformer to the specified directory. - - :param directory: Path to the directory where model components will be saved. - """ - super().save(directory) - if self.methylation_pca is not None: - joblib.dump(self.methylation_pca, os.path.join(directory, "pca.pkl")) - - @classmethod - def load(cls, directory: str) -> "MultiViewRandomForest": - """ - Loads the trained model, hyperparameters, scaler, and PCA transformer from the specified directory. - - :param directory: Path to the directory where model components are stored. - :returns: An instance of MultiViewRandomForest with restored state. - """ - instance: MultiViewRandomForest = super().load(directory) # type: ignore[assignment] - pca_path = os.path.join(directory, "pca.pkl") - if os.path.exists(pca_path): - instance.methylation_pca = joblib.load(pca_path) - return instance diff --git a/drevalpy/models/baselines/multi_view_xgboost.py b/drevalpy/models/baselines/multi_view_xgboost.py deleted file mode 100644 index c1a3be983..000000000 --- a/drevalpy/models/baselines/multi_view_xgboost.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Contains the baseline MultiViewXGBoost model.""" - -import json -import os - -import joblib -import numpy as np -from sklearn.decomposition import PCA -from sklearn.preprocessing import StandardScaler - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset - -from ..drp_model import DRPModel -from ..utils import ( - ProteomicsMedianCenterAndImputeTransformer, - _get_view_as_list, - load_multi_cell_line_view, - load_single_drug_view, - prepare_expression_and_methylation, - prepare_proteomics, -) - - -class MultiViewXGBoost(DRPModel): - """XGBoost model with multi-omic cell line features and drug fingerprints.""" - - cell_line_views = [ - "gene_expression", - "methylation", - "mutations", - "copy_number_variation_gistic", - ] - drug_views = ["fingerprints"] - - def __init__(self): - """Initializes the MultiViewXGBoost model.""" - super().__init__() - self.model = None - self.gene_expression_scaler = StandardScaler() - # methylation-specific defaults - self.methylation_scaler = StandardScaler() - self.methylation_pca = None - self.pca_ncomp = 100 - # proteomics-specific defaults - self.proteomics_transformer = None - self.proteomics_feature_threshold = 0.7 - self.proteomics_n_features = 1000 - self.proteomics_normalization_width = 0.3 - self.proteomics_normalization_downshift = 1.8 - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: MultiViewXGBoost - """ - return "MultiViewXGBoost" - - def build_model(self, hyperparameters: dict) -> None: - """ - Builds the model from hyperparameters. - - :param hyperparameters: dictionary containing the hyperparameters. - :raises ImportError: if xgboost is not installed. - """ - try: - import xgboost as xgb - except ImportError as e: - raise ImportError( - "MultiViewXGBoost requires the optional 'xgboost' extra. " - "Install it with: pip install drevalpy[xgboost] (or `poetry install -E xgboost`)." - ) from e - - self.log_hyperparameters(hyperparameters) - self.hyperparameters = hyperparameters - self.cell_line_views = _get_view_as_list( - hyperparameters.get( - "cell_line_views", - ["gene_expression", "methylation", "mutations", "copy_number_variation_gistic"], - ) - ) - self.drug_views = _get_view_as_list(hyperparameters.get("drug_views", ["fingerprints"])) - if "methylation" in self.cell_line_views: - self.pca_ncomp = hyperparameters.get("methylation_n_components", 100) - if "proteomics" in self.cell_line_views: - self.proteomics_feature_threshold = hyperparameters.get("proteomics_feature_threshold", 0.7) - self.proteomics_n_features = hyperparameters.get("proteomics_n_features", 1000) - self.proteomics_normalization_width = hyperparameters.get("proteomics_normalization_width", 0.3) - self.proteomics_normalization_downshift = hyperparameters.get("proteomics_normalization_downshift", 1.8) - self.proteomics_transformer = ProteomicsMedianCenterAndImputeTransformer( - feature_threshold=self.proteomics_feature_threshold, - n_features=self.proteomics_n_features, - normalization_downshift=self.proteomics_normalization_downshift, - normalization_width=self.proteomics_normalization_width, - ) - self.model = xgb.XGBRegressor( - n_estimators=hyperparameters.get("n_estimators", 100), - learning_rate=hyperparameters.get("learning_rate", 0.1), - max_depth=hyperparameters.get("max_depth", 6), - subsample=hyperparameters.get("subsample", 0.8), - colsample_bytree=hyperparameters.get("colsample_bytree", 0.8), - reg_alpha=hyperparameters.get("reg_alpha", 0.0), - random_state=42, - ) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features. - - :param data_path: data path e.g. data/ - :param dataset_name: dataset name e.g. GDSC1 - :returns: FeatureDataset containing the cell line omics features - """ - return load_multi_cell_line_view(self.cell_line_views, data_path, dataset_name, self.get_model_name()) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset | None: - """ - Loads the drug features. - - :param data_path: path to the drug features, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC1 - :returns: FeatureDataset containing the drug features - """ - return load_single_drug_view(self.drug_views, data_path, dataset_name, self.get_model_name()) - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "", - ) -> None: - """ - Trains the model. - - :param output: training dataset containing the response output - :param cell_line_input: cell line omics features - :param drug_input: drug features - :param output_earlystopping: not used - :param model_checkpoint_dir: not used - """ - if "methylation" in self.cell_line_views: - first_cl_feature = next(iter(cell_line_input.features.values())) - n_met_features = first_cl_feature["methylation"].shape[0] - n_components = min(self.pca_ncomp, n_met_features) - self.methylation_pca = PCA(n_components=n_components) - - if "gene_expression" in self.cell_line_views or "methylation" in self.cell_line_views: - cell_line_input = prepare_expression_and_methylation( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - gene_expression_scaler=self.gene_expression_scaler, - methylation_scaler=self.methylation_scaler, - methylation_pca=self.methylation_pca, - ) - - if "proteomics" in self.cell_line_views: - cell_line_input = prepare_proteomics( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - transformer=self.proteomics_transformer, - ) - - inputs = self.get_feature_matrices( - cell_line_ids=output.cell_line_ids, - drug_ids=output.drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - array_list = [inputs[view] for view in self.cell_line_views + self.drug_views] - x = np.concatenate(array_list, axis=1) - self.model.fit(x, output.response) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the response for the given input. - - :param cell_line_ids: cell line ids - :param drug_ids: drug ids - :param cell_line_input: cell line omics features - :param drug_input: drug features - :returns: predicted response - """ - if "gene_expression" in self.cell_line_views or "methylation" in self.cell_line_views: - cell_line_input = prepare_expression_and_methylation( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - gene_expression_scaler=self.gene_expression_scaler, - methylation_scaler=self.methylation_scaler, - methylation_pca=self.methylation_pca, - ) - - if "proteomics" in self.cell_line_views: - cell_line_input = prepare_proteomics( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - transformer=self.proteomics_transformer, - ) - - inputs = self.get_feature_matrices( - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - array_list = [inputs[view] for view in self.cell_line_views + self.drug_views] - x = np.concatenate(array_list, axis=1) - return self.model.predict(x) - - def save(self, directory: str) -> None: - """ - Saves the model to disk. - - :param directory: target directory - """ - os.makedirs(directory, exist_ok=True) - joblib.dump(self.model, os.path.join(directory, "model.pkl")) - with open(os.path.join(directory, "hyperparameters.json"), "w") as f: - json.dump(self.hyperparameters, f) - if "gene_expression" in self.cell_line_views: - joblib.dump(self.gene_expression_scaler, os.path.join(directory, "gene_scaler.pkl")) - if "methylation" in self.cell_line_views: - joblib.dump(self.methylation_scaler, os.path.join(directory, "methylation_scaler.pkl")) - joblib.dump(self.methylation_pca, os.path.join(directory, "methylation_pca.pkl")) - if self.proteomics_transformer is not None: - joblib.dump(self.proteomics_transformer, os.path.join(directory, "proteomics_transformer.pkl")) - - @classmethod - def load(cls, directory: str) -> "MultiViewXGBoost": - """ - Loads the model from disk. - - :param directory: directory containing the saved model files - :returns: restored MultiViewXGBoost instance - """ - instance = cls() - with open(os.path.join(directory, "hyperparameters.json")) as f: - hyperparameters = json.load(f) - instance.build_model(hyperparameters) - instance.model = joblib.load(os.path.join(directory, "model.pkl")) - if "gene_expression" in instance.cell_line_views: - instance.gene_expression_scaler = joblib.load(os.path.join(directory, "gene_scaler.pkl")) - if "methylation" in instance.cell_line_views: - instance.methylation_scaler = joblib.load(os.path.join(directory, "methylation_scaler.pkl")) - instance.methylation_pca = joblib.load(os.path.join(directory, "methylation_pca.pkl")) - transformer_path = os.path.join(directory, "proteomics_transformer.pkl") - if os.path.exists(transformer_path): - instance.proteomics_transformer = joblib.load(transformer_path) - return instance diff --git a/drevalpy/models/baselines/naive_pred.py b/drevalpy/models/baselines/naive_pred.py deleted file mode 100644 index 226ff371d..000000000 --- a/drevalpy/models/baselines/naive_pred.py +++ /dev/null @@ -1,829 +0,0 @@ -""" -Implements the naive predictor models. - -The naive predictor models are simple models that predict the mean of the response values. The NaivePredictor -predicts the overall mean of the response, the NaiveCellLineMeanPredictor predicts the mean of the response per cell -line, and the NaiveDrugMeanPredictor predicts the mean of the response per drug. -The NaiveTissueMeanPredictor predicts the mean of the response per tissue. -The NaiveTissueDrugMeanPredictor predicts the mean of the response per tissue-drug combination. -The NaiveMeanEffectsPredictor predicts the response as the overall mean plus tissue effect, -cell line residual effect, and drug effect and should be the strongest naive baseline. - -""" - -import json -import os - -import numpy as np - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER, TISSUE_IDENTIFIER -from drevalpy.models.drp_model import DRPModel -from drevalpy.models.utils import ( - load_cl_ids_and_tissues_from_csv, - load_cl_ids_from_csv, - load_drug_ids_from_csv, - load_tissues_from_csv, - unique, -) - - -class NaiveModel(DRPModel): - """ - Base class for all naive predictor models which are based on simple dataset stats. - - This class provides a shared interface and save/load mechanism for simple statistical models that - predict drug response based on dataset means, stratified by drug, cell line, or tissue. - """ - - def __init__(self): - """Initializes the NaiveModel base class.""" - super().__init__() - self.dataset_mean = None - - def build_model(self, hyperparameters: dict): - """ - Builds the model. - - Naive model do not require any hyperparameter tuning. - - :param hyperparameters: Dictionary of hyperparameters (not used). - """ - pass - - def save(self, directory: str) -> None: - """ - Saves the model parameters to the given directory. - - Serializes dataset_mean and any available subclass-specific attributes to a JSON file - named 'naive_model.json'. Creates the directory if it doesn't exist. - - :param directory: Path to the directory where the model will be saved. - """ - os.makedirs(directory, exist_ok=True) - config = {"dataset_mean": self.dataset_mean} - for attr in [ - "drug_means", - "cell_line_means", - "tissue_means", - "tissue_drug_means", - "cell_line_effects", - "drug_effects", - "tissue_effects", - ]: - if hasattr(self, attr): - config[attr] = getattr(self, attr) - with open(os.path.join(directory, "naive_model.json"), "w") as f: - json.dump(config, f) - - @classmethod - def load(cls, directory: str) -> "NaiveModel": - """ - Loads the model parameters from the given directory. - - Reads the 'naive_model.json' file and initializes a NaiveModel instance with the loaded parameters. - - :param directory: Path to the directory where the model is saved. - :return: An instance of NaiveModel with the loaded parameters. - """ - with open(os.path.join(directory, "naive_model.json")) as f: - config = json.load(f) - instance = cls() - instance.dataset_mean = config["dataset_mean"] - for attr in [ - "drug_means", - "cell_line_means", - "tissue_means", - "tissue_drug_means", - "cell_line_effects", - "drug_effects", - "tissue_effects", - ]: - if attr in config: - setattr(instance, attr, config[attr]) - return instance - - -class NaivePredictor(NaiveModel): - """Naive predictor model that predicts the overall mean of the response.""" - - cell_line_views = [CELL_LINE_IDENTIFIER] - drug_views = [DRUG_IDENTIFIER] - - def __init__(self): - """ - Initializes the model. - - Sets the dataset mean to None, which is initialized in the train method. - """ - super().__init__() - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: NaivePredictor - """ - return "NaivePredictor" - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Computes the overall mean of the output response values and saves them. - - :param output: training dataset containing the response output - :param cell_line_input: not needed - :param drug_input: not needed - :param output_earlystopping: not needed - :param model_checkpoint_dir: not needed - """ - self.dataset_mean = np.mean(output.response) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the dataset mean for each drug-cell line combination. - - :param cell_line_ids: cell line ids - :param drug_ids: not needed - :param cell_line_input: not needed - :param drug_input: not needed - :return: array of the same length as the input cell line id containing the dataset mean - """ - return np.full(cell_line_ids.shape[0], self.dataset_mean) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features, in this case the cell line ids. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the cell line ids - """ - return load_cl_ids_from_csv(data_path, dataset_name) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the drug features, in this case the drug ids. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the drug ids - """ - return load_drug_ids_from_csv(data_path, dataset_name) - - -class NaiveDrugMeanPredictor(NaiveModel): - """Naive predictor model that predicts the mean of the response per drug.""" - - cell_line_views = [CELL_LINE_IDENTIFIER] - drug_views = [DRUG_IDENTIFIER] - - def __init__(self): - """ - Initializes the model. - - Drug means and dataset mean are set to None, which are initialized in the train method. - """ - super().__init__() - self.drug_means = None - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: NaiveDrugMeanPredictor - """ - return "NaiveDrugMeanPredictor" - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "None", - ) -> None: - """ - Computes the mean per drug. If - later on - the drug is not in the training set, the overall mean is used. - - :param output: training dataset containing the response output - :param cell_line_input: not needed - :param drug_input: drug id - :param output_earlystopping: not needed - :param model_checkpoint_dir: not needed - :raises ValueError: If drug_input is None - """ - if drug_input is None: - raise ValueError("drug_input (drug_id) is required for the NaiveDrugMeanPredictor.") - drug_ids = drug_input.get_feature_matrix(view=DRUG_IDENTIFIER, identifiers=output.drug_ids) - self.dataset_mean = np.mean(output.response) - self.drug_means = {} - - for drug_response, drug_feature in zip(unique(output.drug_ids), unique(drug_ids), strict=True): - responses_drug = output.response[drug_feature == output.drug_ids] - if len(responses_drug) > 0: - # prevent nan response - self.drug_means[drug_response] = np.mean(responses_drug) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the drug mean for each drug-cell line combination. - - If the drug is not in the training set, the dataset mean is used. - - :param cell_line_ids: not needed - :param drug_ids: drug ids - :param cell_line_input: not needed - :param drug_input: not needed - :return: array of the same length as the input drug_id containing the drug mean - """ - return np.array([self.predict_drug(drug) for drug in drug_ids]) - - def predict_drug(self, drug_id: str): - """ - Predicts the mean of the response for a given drug. - - If the drug is not in the training set, the dataset mean is used. - - :param drug_id: ID of the drug - :return: predicted response - """ - if drug_id in self.drug_means: - return self.drug_means[drug_id] - return self.dataset_mean - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features. - - :param data_path: Path to the data. - :param dataset_name: Name of the dataset. - :return: FeatureDataset containing the cell line IDs. - """ - return load_cl_ids_from_csv(data_path, dataset_name) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the drug features, in this case the drug ids. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the drug ids - """ - return load_drug_ids_from_csv(data_path, dataset_name) - - -class NaiveCellLineMeanPredictor(NaiveModel): - """Naive predictor model that predicts the mean of the response per cell line.""" - - cell_line_views = [CELL_LINE_IDENTIFIER] - drug_views = [DRUG_IDENTIFIER] - - def __init__(self): - """ - Initializes the model. - - Cell line means and dataset mean are set to None, which are initialized in the train method. - """ - super().__init__() - self.cell_line_means = None - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: NaiveCellLineMeanPredictor - """ - return "NaiveCellLineMeanPredictor" - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "None", - ) -> None: - """ - Computes the mean per cell line. - - If - later on - the cell line is not in the training set, the overall mean is used. - - :param output: training dataset containing the response output - :param cell_line_input: cell line inputs - :param drug_input: not needed - :param output_earlystopping: not needed - :param model_checkpoint_dir: not needed - """ - cell_line_ids = cell_line_input.get_feature_matrix(view=CELL_LINE_IDENTIFIER, identifiers=output.cell_line_ids) - self.dataset_mean = np.mean(output.response) - self.cell_line_means = {} - - for cell_line_response, cell_line_feature in zip( - unique(output.cell_line_ids), unique(cell_line_ids), strict=True - ): - responses_cl = output.response[cell_line_feature == output.cell_line_ids] - if len(responses_cl) > 0: - # prevent nan response - self.cell_line_means[cell_line_response] = np.mean(responses_cl) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the cell line mean for each drug-cell line combination. - - If the cell line is not in the training set, the dataset mean is used. - - :param cell_line_ids: cell line ids - :param drug_ids: not needed - :param cell_line_input: not needed - :param drug_input: not needed - :return: array of the same length as the input cell_line_id containing the cell line mean - """ - return np.array([self.predict_cl(cl) for cl in cell_line_ids]) - - def predict_cl(self, cl_id: str) -> float: - """ - Predicts the mean of the response for a given cell line. - - If the cell line is not in the training set, the dataset mean is used. - - :param cl_id: Cell line ID - :return: predicted response - """ - if cl_id in self.cell_line_means: - return self.cell_line_means[cl_id] - return self.dataset_mean - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features, in this case the cell line ids. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the cell line ids - """ - return load_cl_ids_from_csv(data_path, dataset_name) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the drug features. - - :param data_path: Path to the data. - :param dataset_name: Name of the dataset. - :return: FeatureDataset containing the drug IDs. - """ - return load_drug_ids_from_csv(data_path, dataset_name) - - -class NaiveTissueMeanPredictor(NaiveModel): - """Naive predictor model that predicts the mean of the response per tissue.""" - - cell_line_views = [TISSUE_IDENTIFIER] - drug_views = [] - - def __init__(self): - """ - Initializes the model. - - Tissue means and dataset mean are set to None, which are initialized in the train method. - """ - super().__init__() - self.tissue_means = None - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: NaiveTissueMeanPredictor - """ - return "NaiveTissueMeanPredictor" - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "None", - ) -> None: - """ - Computes the mean per tissue. Falls back to the overall mean for unknown tissues. - - :param output: training dataset with `.response` - :param cell_line_input: tissue features for cell lines - :param drug_input: not needed - :param output_earlystopping: not needed - :param model_checkpoint_dir: not needed - """ - self.dataset_mean = np.mean(output.response) - self.tissue_means = {} - - # Get tissue information from cell_line_input FeatureDataset - tissues = cell_line_input.get_feature_matrix(view=TISSUE_IDENTIFIER, identifiers=output.cell_line_ids) - tissues = np.asarray(tissues).flatten() - for tissue in np.unique(tissues): - mask = tissues == tissue - responses = output.response[mask] - if len(responses) > 0: - self.tissue_means[tissue] = np.mean(responses) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the tissue mean for each drug-cell line combination. - - If the tissue is not in the training set, the dataset mean is used. - - :param cell_line_ids: cell line ids - :param drug_ids: not needed - :param cell_line_input: tissue features - :param drug_input: not needed - :return: array of the same length as the input cell_line_id containing the tissue mean - """ - tissues = cell_line_input.get_feature_matrix(view=TISSUE_IDENTIFIER, identifiers=cell_line_ids) - preds = [] - for tissue in tissues: - key = tissue.item() if isinstance(tissue, np.ndarray) else tissue - preds.append(self.tissue_means.get(key, self.dataset_mean)) - return np.array(preds) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features, in this case the tissue annotations. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the tissue ids - """ - return load_tissues_from_csv(data_path, dataset_name) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the drug features. - - :param data_path: Path to the data. - :param dataset_name: Name of the dataset. - :return: FeatureDataset containing the drug IDs. - """ - return load_drug_ids_from_csv(data_path, dataset_name) - - -class NaiveMeanEffectsPredictor(NaiveModel): - """ - ANOVA-like predictor model. - - Predicts the response as: - response = overall_mean + tissue_effect + cell_line_residual_effect + drug_effect. - - Here: - - tissue_effect = (tissue mean - overall_mean) - - cell_line_residual_effect = (cell line mean - tissue mean for that cell line) - - drug_effect = (drug mean - overall_mean) - - This formulation avoids double-counting tissue signal already captured by cell line means. - For unseen cell lines with a known tissue, the tissue effect provides a fallback. - If tissue information is not available, this model falls back to the previous formulation: - response = overall_mean + cell_line_effect + drug_effect. - """ - - cell_line_views = [CELL_LINE_IDENTIFIER] - drug_views = [DRUG_IDENTIFIER] - - def __init__(self): - """ - Initializes the NaiveMeanEffectsPredictor model. - - The overall dataset mean, tissue effects, cell line residual effects, and drug effects - are initialized to None and empty dictionaries, respectively. - """ - super().__init__() - self.tissue_effects = {} - self.cell_line_effects = {} - self.drug_effects = {} - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the name of the model. - - :return: The name of the model as a string. - """ - return "NaiveMeanEffectsPredictor" - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Trains with overall mean, tissue effects, cell line residual effects, and drug effects. - - :param output: Training dataset containing the response output. - :param cell_line_input: Feature dataset containing cell line IDs and tissue annotations. - :param drug_input: Feature dataset containing drug IDs. Must not be None. - :param output_earlystopping: Not used. - :param model_checkpoint_dir: Not used. - :raises ValueError: If drug_input is None. - """ - if drug_input is None: - raise ValueError("drug_input (drug_id) is required for NaiveMeanEffectsPredictor.") - - self.dataset_mean = np.mean(output.response) - - cell_line_ids = cell_line_input.get_feature_matrix(view=CELL_LINE_IDENTIFIER, identifiers=output.cell_line_ids) - cell_line_means = {} - for cl_output, cl_feature in zip(unique(output.cell_line_ids), unique(cell_line_ids), strict=True): - responses_cl = output.response[cl_feature == output.cell_line_ids] - if len(responses_cl) > 0: - cell_line_means[cl_output] = np.mean(responses_cl) - - if TISSUE_IDENTIFIER in cell_line_input.view_names: - tissues = cell_line_input.get_feature_matrix(view=TISSUE_IDENTIFIER, identifiers=output.cell_line_ids) - tissues = np.asarray(tissues).flatten() - - tissue_means = {} - for tissue in np.unique(tissues): - tissue_key = str(tissue.item() if isinstance(tissue, np.ndarray) else tissue) - mask = tissues == tissue - responses_tissue = output.response[mask] - if len(responses_tissue) > 0: - tissue_means[tissue_key] = np.mean(responses_tissue) - - self.tissue_effects = {tissue: (mean - self.dataset_mean) for tissue, mean in tissue_means.items()} - - cell_line_to_tissue = {} - for cl_output, cl_feature in zip(unique(output.cell_line_ids), unique(cell_line_ids), strict=True): - mask = cl_feature == output.cell_line_ids - tissue = tissues[mask][0] - tissue_key = tissue.item() if isinstance(tissue, np.ndarray) else tissue - cell_line_to_tissue[cl_output] = str(tissue_key) - - self.cell_line_effects = {} - for cl, mean in cell_line_means.items(): - tissue_mean = tissue_means[cell_line_to_tissue[cl]] - self.cell_line_effects[cl] = mean - tissue_mean - else: - self.tissue_effects = {} - self.cell_line_effects = {cl: (mean - self.dataset_mean) for cl, mean in cell_line_means.items()} - - drug_ids = drug_input.get_feature_matrix(view=DRUG_IDENTIFIER, identifiers=output.drug_ids) - drug_means = {} - for drug_output, drug_feature in zip(unique(output.drug_ids), unique(drug_ids), strict=True): - responses_drug = output.response[drug_feature == output.drug_ids] - if len(responses_drug) > 0: - drug_means[drug_output] = np.mean(responses_drug) - - self.drug_effects = {drug: (mean - self.dataset_mean) for drug, mean in drug_means.items()} - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts responses for given cell line and drug pairs. - - The prediction is computed as: - prediction = overall_mean + tissue_effect + cell_line_residual_effect + drug_effect - - If a cell line, tissue, or drug has not been seen during training, their effect is set to zero. - - :param cell_line_ids: Array of cell line IDs. - :param drug_ids: Array of drug IDs. - :param cell_line_input: Feature dataset containing tissue annotations. - :param drug_input: Not used. - :return: NumPy array of predicted responses. - """ - predictions = [] - if self.tissue_effects and TISSUE_IDENTIFIER in cell_line_input.view_names: - tissues = cell_line_input.get_feature_matrix(view=TISSUE_IDENTIFIER, identifiers=cell_line_ids) - for cl, drug, tissue in zip(cell_line_ids, drug_ids, tissues, strict=True): - tissue_key = tissue.item() if isinstance(tissue, np.ndarray) else tissue - effect_tissue = self.tissue_effects.get(str(tissue_key), 0) - effect_cl = self.cell_line_effects.get(cl, 0) - effect_drug = self.drug_effects.get(drug, 0) - predictions.append(self.dataset_mean + effect_tissue + effect_cl + effect_drug) - else: - for cl, drug in zip(cell_line_ids, drug_ids, strict=True): - effect_cl = self.cell_line_effects.get(cl, 0) - effect_drug = self.drug_effects.get(drug, 0) - predictions.append(self.dataset_mean + effect_cl + effect_drug) - return np.array(predictions) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features. - - :param data_path: Path to the data. - :param dataset_name: Name of the dataset. - :return: FeatureDataset containing the cell line IDs and tissue annotations, if available. - """ - return load_cl_ids_and_tissues_from_csv(data_path, dataset_name) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the drug features. - - :param data_path: Path to the data. - :param dataset_name: Name of the dataset. - :return: FeatureDataset containing the drug IDs. - """ - return load_drug_ids_from_csv(data_path, dataset_name) - - -class NaiveTissueDrugMeanPredictor(NaiveModel): - """ - Naive predictor model that predicts the mean of the response per tissue-drug combination. - - This model combines tissue and drug information to predict the mean response aggregated across - all cell lines from the same tissue tested on the same drug. If a (tissue, drug) combination - was not seen during training, it falls back to the overall dataset mean. - """ - - cell_line_views = [TISSUE_IDENTIFIER] - drug_views = [DRUG_IDENTIFIER] - - def __init__(self): - """ - Initializes the model. - - Tissue-drug means and dataset mean are set to None, which are initialized in the train method. - """ - super().__init__() - self.tissue_drug_means = None - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: NaiveTissueDrugMeanPredictor - """ - return "NaiveTissueDrugMeanPredictor" - - def save(self, directory: str) -> None: - """ - Saves the model parameters to the given directory. - - Overrides the base class save method to handle tuple keys in tissue_drug_means - by converting them to JSON-serializable string keys. - - :param directory: Path to the directory where the model will be saved. - """ - os.makedirs(directory, exist_ok=True) - config = {"dataset_mean": self.dataset_mean} - # Convert tuple keys to string keys for JSON serialization - if self.tissue_drug_means is not None: - config["tissue_drug_means"] = {f"{k[0]}|{k[1]}": v for k, v in self.tissue_drug_means.items()} - with open(os.path.join(directory, "naive_model.json"), "w") as f: - json.dump(config, f) - - @classmethod - def load(cls, directory: str) -> "NaiveTissueDrugMeanPredictor": - """ - Loads the model parameters from the given directory. - - Overrides the base class load method to convert string keys back to tuple keys. - - :param directory: Path to the directory where the model is saved. - :return: An instance of NaiveTissueDrugMeanPredictor with the loaded parameters. - """ - with open(os.path.join(directory, "naive_model.json")) as f: - config = json.load(f) - instance = cls() - instance.dataset_mean = config["dataset_mean"] - # Convert string keys back to tuple keys - if "tissue_drug_means" in config: - instance.tissue_drug_means = {tuple(k.split("|")): v for k, v in config["tissue_drug_means"].items()} - return instance - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "None", - ) -> None: - """ - Computes the mean per tissue-drug combination. Falls back to the overall mean for unknown combinations. - - :param output: training dataset with `.response` and `.drug_ids` - :param cell_line_input: tissue features for cell lines - :param drug_input: drug id features - :param output_earlystopping: not needed - :param model_checkpoint_dir: not needed - :raises ValueError: If drug_input is None. - """ - if drug_input is None: - raise ValueError("drug_input (drug_id) is required for the NaiveTissueDrugMeanPredictor.") - - # Get drug features for each drug in the output (following NaiveDrugMeanPredictor pattern) - drug_ids = drug_input.get_feature_matrix(view=DRUG_IDENTIFIER, identifiers=output.drug_ids) - - # Get tissue information from cell_line_input FeatureDataset - tissues = cell_line_input.get_feature_matrix(view=TISSUE_IDENTIFIER, identifiers=output.cell_line_ids) - tissues = np.asarray(tissues).flatten() - - self.dataset_mean = np.mean(output.response) - self.tissue_drug_means = {} - - # Use tissues from cell_line_input FeatureDataset - # and drug_ids from drug_input FeatureDataset (following NaiveDrugMeanPredictor pattern) - for tissue in np.unique(tissues): - tissue_mask = tissues == tissue - for drug_response, drug_feature in zip(unique(output.drug_ids), unique(drug_ids), strict=True): - drug_mask = drug_feature == output.drug_ids - combo_mask = tissue_mask & drug_mask - responses = output.response[combo_mask] - if len(responses) > 0: - combo_key = (str(tissue), str(drug_response)) - self.tissue_drug_means[combo_key] = np.mean(responses) - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the tissue-drug mean for each drug-cell line combination. - - If the (tissue, drug) combination is not in the training set, the dataset mean is used. - - :param cell_line_ids: cell line ids - :param drug_ids: drug ids (used directly, following NaiveDrugMeanPredictor pattern) - :param cell_line_input: tissue features - :param drug_input: not needed - :return: array of the same length as the input containing the tissue-drug mean or dataset mean - """ - # Get tissues from FeatureDataset (following NaiveTissueMeanPredictor pattern) - tissues = cell_line_input.get_feature_matrix(view=TISSUE_IDENTIFIER, identifiers=cell_line_ids) - - # Use drug_ids parameter directly (following NaiveDrugMeanPredictor pattern) - preds = [] - for tissue, drug_id in zip(tissues, drug_ids, strict=True): - tissue_key = tissue.item() if isinstance(tissue, np.ndarray) else tissue - combo_key = (str(tissue_key), str(drug_id)) - preds.append(self.tissue_drug_means.get(combo_key, self.dataset_mean)) - - return np.array(preds) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features, in this case the tissue annotations. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the tissue ids - """ - return load_tissues_from_csv(data_path, dataset_name) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the drug features, in this case the drug ids. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: FeatureDataset containing the drug ids - """ - return load_drug_ids_from_csv(data_path, dataset_name) diff --git a/drevalpy/models/baselines/singledrug_baselines.py b/drevalpy/models/baselines/singledrug_baselines.py deleted file mode 100644 index 191eef95c..000000000 --- a/drevalpy/models/baselines/singledrug_baselines.py +++ /dev/null @@ -1,75 +0,0 @@ -"""SingleDrugElasticNet and SingleDrugRandomForest class. Fit a model for each drug separately.""" - -from .sklearn_models import ElasticNetModel, RandomForest - - -class SingleDrugElasticNet(ElasticNetModel): - """SingleDrugElasticNet class.""" - - is_single_drug_model = True - drug_views = [] - early_stopping = False - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: SingleDrugElasticNet - """ - return "SingleDrugElasticNet" - - def build_model(self, hyperparameters: dict): - """ - Overwrites drug views to be empty. - - :param hyperparameters: hyperparameters - """ - super().build_model(hyperparameters) - self.drug_views = [] - - def load_drug_features(self, data_path, dataset_name): - """ - Load drug features. Not needed for SingleDrugElasticNet. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: None - """ - return None - - -class SingleDrugRandomForest(RandomForest): - """SingleDrugRandomForest class.""" - - is_single_drug_model = True - drug_views = [] - early_stopping = False - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: SingleDrugRandomForest - """ - return "SingleDrugRandomForest" - - def build_model(self, hyperparameters: dict): - """ - Overwrites drug views to be empty. - - :param hyperparameters: hyperparameters - """ - super().build_model(hyperparameters) - self.drug_views = [] - - def load_drug_features(self, data_path, dataset_name): - """ - Load drug features. Not needed for SingleDrugRandomForest. - - :param data_path: path to the data - :param dataset_name: name of the dataset - :returns: None - """ - return None diff --git a/drevalpy/models/baselines/sklearn_models.py b/drevalpy/models/baselines/sklearn_models.py deleted file mode 100644 index 42d91dd2d..000000000 --- a/drevalpy/models/baselines/sklearn_models.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Contains sklearn baseline models: ElasticNet, RandomForest, SVM, AdaBoost.""" - -import json -import os - -import joblib -import numpy as np -from sklearn.ensemble import AdaBoostRegressor, HistGradientBoostingRegressor, RandomForestRegressor -from sklearn.linear_model import ElasticNet, Lasso, Ridge -from sklearn.neighbors import KNeighborsRegressor -from sklearn.preprocessing import StandardScaler -from sklearn.svm import SVR -from sklearn.tree import DecisionTreeRegressor - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.models.drp_model import DRPModel - -from ..utils import ( - ProteomicsMedianCenterAndImputeTransformer, - _get_view_as_list, - load_single_cell_line_view, - load_single_drug_view, - prepare_proteomics, - scale_gene_expression, -) - - -class SklearnModel(DRPModel): - """Parent class that contains the common methods for the sklearn models.""" - - cell_line_views = [] - drug_views = [] - - def __init__(self): - """ - Initializes the model. - - Sets the model to None, which is initialized in the build_model method to the respective sklearn model. - - Initializes omic-specific defaults: - * For gene expression, a StandardScaler is initialized which will standardize the gene expression data. - * For proteomics, default parameters for the ProteomicsMedianCenterAndImputeTransformer are initialized - (feature_threshold=0.7, n_features=1000, normalization_width=0.3, normalization_downshift=1.8). - """ - super().__init__() - self.model = None - self.gene_expression_scaler = StandardScaler() - # proteomics-specific defaults - self.proteomics_transformer = None - self.proteomics_feature_threshold = 0.7 - self.proteomics_n_features = 1000 - self.proteomics_normalization_width = 0.3 - self.proteomics_normalization_downshift = 1.8 - # methylation-specific defaults - self.methylation_pca = None - self.methylation_n_components = 100 - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :raises NotImplementedError: If the method is not implemented in the child class. - """ - raise NotImplementedError("get_model_name method has to be implemented in the child class.") - - def build_model(self, hyperparameters: dict): - """ - Builds the model from hyperparameters. - - Flexible input support: Initializes the cell_line_views and drug_views to the values specified in the - hyperparameters.yaml file. If nothing is specified, gene_expression and fingerprints are used. - - If proteomics is specified in the hyperparameters, the ProteomicsMedianCenterAndImputeTransformer - is initialized. - - :param hyperparameters: Custom hyperparameters for the model, have to be defined in the child class. - """ - # Log hyperparameters to wandb if enabled - self.log_hyperparameters(hyperparameters) - self.hyperparameters = hyperparameters - self.cell_line_views = _get_view_as_list(hyperparameters.get("cell_line_views", ["gene_expression"])) - self.drug_views = _get_view_as_list(hyperparameters.get("drug_views", ["fingerprints"])) - - # proteomics features are not supported for all models - if "proteomics" in self.cell_line_views: - self._init_proteomics_features(hyperparameters) - - # methylation features are not supported for all models - if "methylation" in self.cell_line_views: - self.methylation_n_components = hyperparameters.get("methylation_n_components", 100) - - def _init_proteomics_features(self, hyperparameters: dict): - self.proteomics_feature_threshold = hyperparameters.get("proteomics_feature_threshold", 0.7) - self.proteomics_n_features = hyperparameters.get("proteomics_n_features", 1000) - self.proteomics_normalization_width = hyperparameters.get("proteomics_normalization_width", 0.3) - self.proteomics_normalization_downshift = hyperparameters.get("proteomics_normalization_downshift", 1.8) - self.proteomics_transformer = ProteomicsMedianCenterAndImputeTransformer( - feature_threshold=self.proteomics_feature_threshold, - n_features=self.proteomics_n_features, - normalization_downshift=self.proteomics_normalization_downshift, - normalization_width=self.proteomics_normalization_width, - ) - - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Loads the cell line features for a single-view sklearn model. - - :param data_path: Path to the data - :param dataset_name: Name of the dataset - :returns: FeatureDataset containing the cell line features - """ - return load_single_cell_line_view(self.cell_line_views, data_path, dataset_name, self.get_model_name()) - - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset | None: - """ - Load the drug features for a single-view sklearn model. - - :param data_path: Path to the data - :param dataset_name: Name of the dataset - :returns: FeatureDataset containing the drug features - """ - return load_single_drug_view(self.drug_views, data_path, dataset_name, self.get_model_name()) - - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Trains the model. - - The number of features is the number of genes + the number of fingerprints. - :param output: training dataset containing the response output - :param cell_line_input: training dataset containing gene expression data - :param drug_input: training dataset containing fingerprints data - :param output_earlystopping: not needed - :param model_checkpoint_dir: not needed - """ - if len(output) > 0: - if "gene_expression" in self.cell_line_views: - cell_line_input = scale_gene_expression( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - gene_expression_scaler=self.gene_expression_scaler, - ) - elif "proteomics" in self.cell_line_views: - cell_line_input = prepare_proteomics( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(output.cell_line_ids), - training=True, - transformer=self.proteomics_transformer, - ) - if len(self.drug_views) == 0: - # support for single-drug models - drug_view = None - else: - drug_view = self.drug_views[0] - - x = self.get_concatenated_features( - cell_line_view=self.cell_line_views[0], - drug_view=drug_view, - cell_line_ids_output=output.cell_line_ids, - drug_ids_output=output.drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - self.model.fit(x, output.response) - else: - print("No training data provided, will predict NA.") - self.model = None - - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: - """ - Predicts the response for the given input. - - :param drug_ids: drug ids - :param cell_line_ids: cell line ids - :param drug_input: drug input - :param cell_line_input: cell line input - :returns: predicted drug response - """ - if self.model is None: - print("No training data was available, predicting NA.") - return np.array([np.nan] * len(cell_line_ids)) - - if "gene_expression" in self.cell_line_views: - cell_line_input = scale_gene_expression( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - gene_expression_scaler=self.gene_expression_scaler, - ) - elif "proteomics" in self.cell_line_views: - cell_line_input = prepare_proteomics( - cell_line_input=cell_line_input, - cell_line_ids=np.unique(cell_line_ids), - training=False, - transformer=self.proteomics_transformer, - ) - - if len(self.drug_views) == 0: - # support for single-drug models - drug_view = None - else: - drug_view = self.drug_views[0] - - x = self.get_concatenated_features( - cell_line_view=self.cell_line_views[0], - drug_view=drug_view, - cell_line_ids_output=cell_line_ids, - drug_ids_output=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - return self.model.predict(x) - - def save(self, directory: str) -> None: - """ - Save the trained model and any associated preprocessing components to the given directory. - - Saves: - - model.pkl: the trained sklearn model - - hyperparameters.json: dictionary of model hyperparameters (if present) - - scaler.pkl: fitted gene expression scaler (if present) - - proteomics_transformer.pkl: fitted proteomics transformer (if present) - - :param directory: path to the directory where model files will be stored - :raises ValueError: if the model is not trained - """ - os.makedirs(directory, exist_ok=True) - if self.model is None: - raise ValueError("Cannot save: model is not trained.") - - joblib.dump(self.model, os.path.join(directory, "model.pkl")) - with open(os.path.join(directory, "hyperparameters.json"), "w") as f: - json.dump(getattr(self, "hyperparameters", {}), f) - if self.gene_expression_scaler is not None: - joblib.dump(self.gene_expression_scaler, os.path.join(directory, "scaler.pkl")) - if self.proteomics_transformer is not None: - joblib.dump(self.proteomics_transformer, os.path.join(directory, "proteomics_transformer.pkl")) - - @classmethod - def load(cls, directory: str) -> "SklearnModel": - """ - Load a trained sklearn-based model and its preprocessing components from disk. - - Loads: - - model.pkl: the trained sklearn model - - hyperparameters.json: model hyperparameters (optional) - - scaler.pkl: gene expression scaler (optional) - - proteomics_transformer.pkl: proteomics transformer (optional) - - :param directory: path to the directory where model files are stored - :return: an instance of the model with restored state - :raises FileNotFoundError: if model.pkl is missing - """ - model_path = os.path.join(directory, "model.pkl") - if not os.path.exists(model_path): - raise FileNotFoundError(f"{model_path} not found") - - instance = cls() - - hyperparams_path = os.path.join(directory, "hyperparameters.json") - with open(hyperparams_path) as f: - hyperparameters = json.load(f) - instance.build_model(hyperparameters) - instance.model = joblib.load(model_path) - - scaler_path = os.path.join(directory, "scaler.pkl") - if os.path.exists(scaler_path): - instance.gene_expression_scaler = joblib.load(scaler_path) - - transformer_path = os.path.join(directory, "proteomics_transformer.pkl") - if os.path.exists(transformer_path): - instance.proteomics_transformer = joblib.load(transformer_path) - - return instance - - -class ElasticNetModel(SklearnModel): - """ElasticNet model for drug response prediction.""" - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: ElasticNet - """ - return "ElasticNet" - - def build_model(self, hyperparameters: dict): - """ - Builds the ElasticNet model from hyperparameters. - - :param hyperparameters: Contains L1 ratio and alpha. - """ - super().build_model(hyperparameters) - if self.hyperparameters["l1_ratio"] == 0.0: - self.model = Ridge(alpha=self.hyperparameters["alpha"]) - elif self.hyperparameters["l1_ratio"] == 1.0: - self.model = Lasso(alpha=self.hyperparameters["alpha"]) - else: - self.model = ElasticNet( - alpha=self.hyperparameters["alpha"], - l1_ratio=self.hyperparameters["l1_ratio"], - ) - - -class RandomForest(SklearnModel): - """RandomForest model for drug response prediction.""" - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: RandomForest - """ - return "RandomForest" - - def build_model(self, hyperparameters: dict): - """ - Builds the model from hyperparameters. - - :param hyperparameters: Hyperparameters for the model. Contains n_estimators, criterion, max_samples, - max_depth and n_jobs. - """ - super().build_model(hyperparameters) - if self.hyperparameters["max_depth"] == "None": - self.hyperparameters["max_depth"] = None - self.model = RandomForestRegressor( - n_estimators=self.hyperparameters["n_estimators"], - criterion=self.hyperparameters["criterion"], - max_samples=self.hyperparameters["max_samples"], - max_depth=self.hyperparameters["max_depth"], - n_jobs=self.hyperparameters["n_jobs"], - ) - - -class SVMRegressor(SklearnModel): - """SVM model for drug response prediction.""" - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: SVR (Support Vector Regressor) - """ - return "SVR" - - def build_model(self, hyperparameters: dict): - """ - Builds the model from hyperparameters. - - :param hyperparameters: Hyperparameters for the model. Contains kernel, C, epsilon, and max_iter. - """ - super().build_model(hyperparameters) - self.model = SVR( - kernel=self.hyperparameters["kernel"], - C=self.hyperparameters["C"], - epsilon=self.hyperparameters["epsilon"], - max_iter=self.hyperparameters["max_iter"], - ) - - -class GradientBoosting(SklearnModel): - """Gradient Boosting model for drug response prediction.""" - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: GradientBoosting - """ - return "GradientBoosting" - - def build_model(self, hyperparameters: dict): - """ - Builds the model from hyperparameters. - - :param hyperparameters: Hyperparameters for the model. Contains n_estimators, learning_rate, max_depth, - and subsample - """ - super().build_model(hyperparameters) - if self.hyperparameters["max_depth"] == "None": - self.hyperparameters["max_depth"] = None - self.model = HistGradientBoostingRegressor( - max_iter=self.hyperparameters.get("max_iter", 100), - learning_rate=self.hyperparameters.get("learning_rate", 0.1), - max_depth=self.hyperparameters.get("max_depth", 3), - ) - - -class AdaBoostDecisionTree(SklearnModel): - """AdaBoost model using Decision Trees as week learners for drug response prediction.""" - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: AdaBoostDecisionTree - """ - return "AdaBoostDecisionTree" - - def build_model(self, hyperparameters: dict): - """ - Builds the model from hyperparameters. - - :param hyperparameters: Hyperparameters for the model. Contains n_estimators, max_depth, - min_samples_split and min_samples_leaf. - """ - super().build_model(hyperparameters) - self.model = AdaBoostRegressor( - estimator=DecisionTreeRegressor( - max_depth=self.hyperparameters["max_depth"], - min_samples_split=self.hyperparameters["min_samples_split"], - min_samples_leaf=self.hyperparameters["min_samples_leaf"], - ), - n_estimators=self.hyperparameters["n_estimators"], - ) - - -class LassoModel(SklearnModel): - """Lasso regression model for drug response prediction.""" - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: Lasso - """ - return "Lasso" - - def build_model(self, hyperparameters: dict): - """ - Builds the Lasso model from hyperparameters. - - :param hyperparameters: Contains alpha. - """ - super().build_model(hyperparameters) - self.model = Lasso( - alpha=self.hyperparameters["alpha"], - max_iter=10000, - tol=1e-3, - selection="random", - ) - - -class KNNRegressor(SklearnModel): - """KNNRegressor model for using k-nearest neighbors for drug response prediction.""" - - @classmethod - def get_model_name(cls) -> str: - """ - Returns the model name. - - :returns: KNNRegressor - """ - return "KNNRegressor" - - def build_model(self, hyperparameters: dict): - """ - Builds the model from hyperparameters. - - :param hyperparameters: Hyperparameters for the model. Contains neighbors, weights. - """ - super().build_model(hyperparameters) - self.model = KNeighborsRegressor( - n_neighbors=self.hyperparameters["n_neighbors"], weights=self.hyperparameters.get("weights", "distance") - ) diff --git a/drevalpy/models/component_stack.py b/drevalpy/models/component_stack.py new file mode 100644 index 000000000..a783fa224 --- /dev/null +++ b/drevalpy/models/component_stack.py @@ -0,0 +1,621 @@ +"""Private featurizer/predictor execution stack for DRPModel.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from drevalpy.components.contracts.training_context import TrainingContext +from drevalpy.components.featurizers._featurizer_label import qualified_featurizer_selector +from drevalpy.components.featurizers._matrix import unique_entity_ids +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.models.config import FeaturizerConfig, ModelConfig, PredictionMode +from drevalpy.models.config.resolved import ResolvedModelConfig +from drevalpy.models.tuning.search_space import resolve_model_config +from drevalpy.types import SplitMask +from drevalpy.types.data.batch.feature_block import FeatureBlock +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.batch.model_input_build import build_model_input_batch +from drevalpy.types.data.batch.response_batch import ResponseBatch +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import CellLineFeatureSource, DrugFeatureSource, FeatureSource + +if TYPE_CHECKING: + from sklearn.base import TransformerMixin + + +def _entity_id_only_featurizer(featurizer: Featurizer | None) -> bool: + return getattr(featurizer, "entity_id_only", False) + + +def _instantiate_featurizer( + config: FeaturizerConfig, + resolved: ResolvedModelConfig, +) -> Featurizer: + registry = str(config.registry) + if config.name == "concatFeaturizers": + children = [_instantiate_featurizer(child, resolved) for child in (config.featurizers or ())] + return config.create_instance({"featurizers": children}) + selector = qualified_featurizer_selector(config.name, config.view) + return config.create_instance(resolved.featurizer_values(registry, selector)) + + +def build_component_stack(config: ModelConfig | ResolvedModelConfig) -> _ComponentStack: + """Instantiate featurizers and predictor for a validated config. + + :param config: Template or resolved model configuration. + :returns: Component stack ready for training. + """ + resolved = config if isinstance(config, ResolvedModelConfig) else resolve_model_config(config) + template = resolved.template + cell_line = ( + _instantiate_featurizer(template.cell_line_featurizer, resolved) + if template.cell_line_featurizer is not None + else None + ) + drug = _instantiate_featurizer(template.drug_featurizer, resolved) if template.drug_featurizer is not None else None + predictor_hp: dict[str, Any] = { + **resolved.predictor_values(), + "prediction_mode": template.prediction_mode, + } + predictor = template.predictor.create_instance(predictor_hp) + return _ComponentStack( + cell_line, + drug, + predictor, + prediction_mode=template.prediction_mode, + resolved=resolved, + ) + + +class _ComponentStack: + """Fit featurizers on training entities, then train a predictor on featurized pairs.""" + + def __init__( + self, + cell_line_featurizer: Featurizer | None, + drug_featurizer: Featurizer | None, + predictor: Predictor, + *, + prediction_mode: PredictionMode = PredictionMode.REGRESSION, + resolved: ResolvedModelConfig, + ) -> None: + self._cell_line_featurizer = cell_line_featurizer + self._drug_featurizer = drug_featurizer + self._predictor = predictor + self._prediction_mode = prediction_mode + self._resolved = ResolvedModelConfig.model_validate(resolved.model_dump(mode="python")) + self._cell_line_matrix: np.ndarray | None = None + self._drug_matrix: np.ndarray | None = None + self._cell_line_entity_ids: np.ndarray | None = None + self._drug_entity_ids: np.ndarray | None = None + + @property + def config(self) -> ModelConfig: + """Return the immutable template associated with this stack. + + :returns: Template ``ModelConfig``. + """ + return self._resolved.template + + @property + def resolved(self) -> ResolvedModelConfig: + """Return the resolved instance configuration. + + :returns: ``ResolvedModelConfig``. + """ + return self._resolved + + def _build_batch( + self, + response: ResponseBatch, + *, + cell_line_input: FeatureSource, + drug_input: FeatureSource | None, + cell_line_entity_ids: np.ndarray, + drug_entity_ids: np.ndarray | None, + cell_line_matrix: np.ndarray, + drug_matrix: np.ndarray | None, + output_earlystopping: ResponseBatch | None = None, + training_context: TrainingContext | None = None, + ) -> ModelInputBatch: + cell_line_blocks: dict[str, FeatureBlock] = {} + if self._cell_line_featurizer is not None: + cell_line_blocks = self._cell_line_featurizer.transform_blocks( + cell_line_input, + cell_line_entity_ids, + ) + + drug_blocks: dict[str, FeatureBlock] = {} + if self._drug_featurizer is not None and drug_entity_ids is not None: + if drug_input is None and not _entity_id_only_featurizer(self._drug_featurizer): + msg = "drug_input is required when a drug featurizer is configured" + raise ValueError(msg) + if drug_input is not None or _entity_id_only_featurizer(self._drug_featurizer): + drug_blocks = self._drug_featurizer.transform_blocks(drug_input, drug_entity_ids) + + return build_model_input_batch( + response, + cell_line_entity_ids=cell_line_entity_ids, + drug_entity_ids=drug_entity_ids if self._drug_featurizer is not None else None, + cell_line_features=cell_line_matrix, + drug_features=drug_matrix if self._drug_featurizer is not None else None, + cell_line_blocks=cell_line_blocks, + drug_blocks=drug_blocks, + early_stopping_response=output_earlystopping, + training_context=training_context, + ) + + def _require_drug_input(self, drug_input: FeatureSource | None) -> FeatureSource | None: + if drug_input is None and not _entity_id_only_featurizer(self._drug_featurizer): + msg = "drug_input is required when a drug featurizer is configured" + raise ValueError(msg) + return drug_input + + def _fit_transform_featurizer( + self, + featurizer: Featurizer, + source: FeatureSource | None, + *, + train_entity_ids: np.ndarray, + all_entity_ids: np.ndarray, + pair_expanded_ids: np.ndarray | None = None, + pair_expanded_es_ids: np.ndarray | None = None, + ) -> tuple[np.ndarray, np.ndarray]: + featurizer.fit( + source, + entity_ids=train_entity_ids, + pair_expanded_ids=pair_expanded_ids, + pair_expanded_es_ids=pair_expanded_es_ids, + ) + entity_ids = np.asarray(all_entity_ids, dtype=str) + matrix = featurizer.transform(source, entity_ids) + return entity_ids, matrix + + def _train_cell_line_side( + self, + output: ResponseBatch, + cell_line_input: FeatureSource, + *, + output_earlystopping: ResponseBatch | None = None, + ) -> None: + if self._cell_line_featurizer is None: + self._cell_line_entity_ids = np.array([], dtype=str) + self._cell_line_matrix = np.empty((0, 0), dtype=np.float32) + return + train_cell_lines = unique_entity_ids(output.cell_line_ids) + all_cell_lines = train_cell_lines + if output_earlystopping is not None: + es_cell_lines = unique_entity_ids(output_earlystopping.cell_line_ids) + all_cell_lines = np.unique(np.concatenate([train_cell_lines, es_cell_lines])) + pair_expanded_ids = np.asarray(output.cell_line_ids, dtype=str) + pair_expanded_es_ids = ( + np.asarray(output_earlystopping.cell_line_ids, dtype=str) if output_earlystopping is not None else None + ) + entity_ids, matrix = self._fit_transform_featurizer( + self._cell_line_featurizer, + cell_line_input, + train_entity_ids=train_cell_lines, + all_entity_ids=all_cell_lines, + pair_expanded_ids=pair_expanded_ids, + pair_expanded_es_ids=pair_expanded_es_ids, + ) + self._cell_line_entity_ids = entity_ids + self._cell_line_matrix = matrix + + def _train_drug_side( + self, + output: ResponseBatch, + drug_input: FeatureSource | None, + *, + output_earlystopping: ResponseBatch | None = None, + ) -> None: + if self._drug_featurizer is None: + self._drug_entity_ids = np.array([], dtype=str) + self._drug_matrix = np.empty((0, 0), dtype=np.float32) + return + train_drugs = unique_entity_ids(output.drug_ids) + all_drugs = train_drugs + if output_earlystopping is not None: + es_drugs = unique_entity_ids(output_earlystopping.drug_ids) + all_drugs = np.unique(np.concatenate([train_drugs, es_drugs])) + drug_source = self._require_drug_input(drug_input) + pair_expanded_ids = np.asarray(output.drug_ids, dtype=str) + pair_expanded_es_ids = ( + np.asarray(output_earlystopping.drug_ids, dtype=str) if output_earlystopping is not None else None + ) + entity_ids, matrix = self._fit_transform_featurizer( + self._drug_featurizer, + drug_source, + train_entity_ids=train_drugs, + all_entity_ids=all_drugs, + pair_expanded_ids=pair_expanded_ids, + pair_expanded_es_ids=pair_expanded_es_ids, + ) + self._drug_entity_ids = entity_ids + self._drug_matrix = matrix + + def _fit_featurizers_and_predictor( + self, + output: ResponseBatch, + cell_line_input: FeatureSource, + drug_input: FeatureSource | None = None, + *, + output_earlystopping: ResponseBatch | None = None, + training_context: TrainingContext | None = None, + ) -> _ComponentStack: + """Fit featurizers on entity features and train the predictor on the batch. + + :param output: Training response pairs. + :param cell_line_input: Cell-line feature source. + :param drug_input: Drug feature source, or ``None``. + :param output_earlystopping: Optional early-stopping response pairs. + :param training_context: Optional runtime metadata. + :returns: Self after training. + """ + if len(output) == 0: + return self + + self._train_cell_line_side(output, cell_line_input, output_earlystopping=output_earlystopping) + self._train_drug_side(output, drug_input, output_earlystopping=output_earlystopping) + + cell_line_entity_ids = ( + self._cell_line_entity_ids if self._cell_line_entity_ids is not None else np.array([], dtype=str) + ) + cell_line_matrix = ( + self._cell_line_matrix if self._cell_line_matrix is not None else np.empty((0, 0), dtype=np.float32) + ) + batch = self._build_batch( + output, + cell_line_input=cell_line_input, + drug_input=drug_input, + cell_line_entity_ids=cell_line_entity_ids, + drug_entity_ids=self._drug_entity_ids, + cell_line_matrix=cell_line_matrix, + drug_matrix=self._drug_matrix, + output_earlystopping=output_earlystopping, + training_context=training_context, + ) + self._predictor.fit(batch) + + return self + + def is_fitted(self) -> bool: + """Return whether the predictor has fitted state. + + :returns: ``True`` when the predictor has been fitted. + """ + return self._predictor.is_fitted() + + def component_state(self) -> dict[str, object]: + """Return serializable state owned by the component stack. + + :returns: Mapping with predictor and featurizer state dicts. + """ + return { + "predictor": self._predictor.get_state(), + "cell_line_featurizer": ( + self._cell_line_featurizer.get_state() if self._cell_line_featurizer is not None else {} + ), + "drug_featurizer": self._drug_featurizer.get_state() if self._drug_featurizer is not None else {}, + } + + def restore_component_state(self, state: dict[str, object]) -> None: + """Restore state produced by ``component_state``. + + :param state: Serialized component state mapping. + :raises ValueError: If predictor or featurizer state is not a mapping. + """ + predictor_state = state.get("predictor", {}) + if not isinstance(predictor_state, dict): + raise ValueError("predictor state is not a mapping") + self._predictor.set_state(predictor_state) + for key, featurizer in ( + ("cell_line_featurizer", self._cell_line_featurizer), + ("drug_featurizer", self._drug_featurizer), + ): + value = state.get(key, {}) + if featurizer is not None: + if not isinstance(value, dict): + raise ValueError(f"{key} state is not a mapping") + featurizer.set_state(value) + + def predict_from_features( + self, + cell_line_ids: np.ndarray, + drug_ids: np.ndarray, + cell_line_input: FeatureSource, + drug_input: FeatureSource | None = None, + ) -> np.ndarray: + """Predict using pre-built FeatureSource objects. + + :param cell_line_ids: Cell-line identifiers for each pair. + :param drug_ids: Drug identifiers for each pair. + :param cell_line_input: Cell-line feature source. + :param drug_input: Drug feature source, or ``None``. + :returns: Predicted response values. + :raises RuntimeError: If the predictor has not been fitted. + """ + if not self.is_fitted(): + msg = "Model has not been trained; call train() or load() before predict()" + raise RuntimeError(msg) + if len(cell_line_ids) == 0: + return np.array([]) + + response = ResponseBatch( + response=np.zeros(len(cell_line_ids)), + cell_line_ids=cell_line_ids, + drug_ids=drug_ids, + ) + + cell_line_entity_ids, cell_line_matrix = self._transform_cell_line_features(cell_line_ids, cell_line_input) + drug_entity_ids, drug_matrix = self._transform_drug_features(drug_ids, drug_input) + + if len(response) == 0: + return np.array([]) + + batch = self._build_batch( + response, + cell_line_input=cell_line_input, + drug_input=drug_input, + cell_line_entity_ids=cell_line_entity_ids, + drug_entity_ids=drug_entity_ids, + cell_line_matrix=cell_line_matrix, + drug_matrix=drug_matrix, + ) + return self._predictor.predict(batch) + + def _transform_cell_line_features( + self, + cell_line_ids: np.ndarray, + cell_line_input: FeatureSource, + ) -> tuple[np.ndarray, np.ndarray]: + """Transform cell-line features for prediction.""" + if self._cell_line_featurizer is None: + return np.array([], dtype=str), np.empty((0, 0), dtype=np.float32) + entity_ids = unique_entity_ids(cell_line_ids) + matrix = self._cell_line_featurizer.transform(cell_line_input, entity_ids) + return entity_ids, matrix + + def _transform_drug_features( + self, + drug_ids: np.ndarray, + drug_input: FeatureSource | None, + ) -> tuple[np.ndarray | None, np.ndarray | None]: + """Transform drug features for prediction.""" + if self._drug_featurizer is None: + return None, None + entity_ids = unique_entity_ids(drug_ids) + if drug_input is None and not _entity_id_only_featurizer(self._drug_featurizer): + msg = "drug_input is required when a drug featurizer is configured" + raise ValueError(msg) + if drug_input is not None: + matrix = self._drug_featurizer.transform(drug_input, entity_ids) + else: + matrix = np.empty((0, 0), dtype=np.float32) + return entity_ids, matrix + + # ------------------------------------------------------------------ + # Dataset-backed API + # ------------------------------------------------------------------ + + @staticmethod + def _extract_response_pairs( + mudataset: Dataset, + scope: SplitMask, + response_transformation: TransformerMixin | None = None, + ) -> ResponseBatch: + """Build a ResponseBatch from the Dataset for given pair indices. + + :param mudataset: Source of response values. + :param scope: SplitMask with 2D pair array. + :param response_transformation: Optional *already fitted* transformer applied to + the extracted responses. Pass it only for training-time extractions; the + prediction and evaluation paths must read the raw matrix. + :returns: Flat ResponseBatch of (cell_line, drug, response) triples. + """ + pairs = scope.pairs + if len(pairs) == 0: + return ResponseBatch( + response=np.array([], dtype=np.float64), + cell_line_ids=np.array([], dtype=str), + drug_ids=np.array([], dtype=str), + ) + + cl_ids = mudataset.cell_line_ids + drug_ids = mudataset.drug_ids + response_matrix = mudataset.response_matrix + + cl_idx = pairs[:, 0] + dr_idx = pairs[:, 1] + responses = response_matrix[cl_idx, dr_idx] + + valid = ~np.isnan(responses) + values = responses[valid].astype(np.float64) + if response_transformation is not None: + values = response_transformation.transform(values.reshape(-1, 1)).ravel() + return ResponseBatch( + response=values, + cell_line_ids=cl_ids[cl_idx[valid]], + drug_ids=drug_ids[dr_idx[valid]], + ) + + def _build_features_from_mudataset( + self, + mudataset: Dataset, + cell_line_ids: np.ndarray, + drug_ids: np.ndarray, + ) -> tuple[FeatureSource, FeatureSource | None]: + """Construct FeatureSource adapters from Dataset for the relevant entities. + + :param mudataset: Source of feature data. + :param cell_line_ids: Unique cell-line IDs needed. + :param drug_ids: Unique drug IDs needed. + :returns: Tuple of (cell_line_source, drug_source). + """ + cl_source = CellLineFeatureSource(mudataset, cell_line_ids) + drug_source = DrugFeatureSource(mudataset, drug_ids) if self._drug_featurizer is not None else None + return cl_source, drug_source + + def train( + self, + mudataset: Dataset, + scope: SplitMask, + *, + training_context: TrainingContext | None = None, + response_transformation: TransformerMixin | None = None, + ) -> _ComponentStack: + """Train the component stack using a Dataset and SplitMask. + + Extracts response pairs and features from the Dataset, then fits + featurizers and the predictor. + + :param mudataset: Source of response values and features. + :param scope: Entity scope defining cell-line/drug indices to train on. + :param training_context: Optional runtime metadata. + :param response_transformation: Optional fitted transformer applied to the + training targets. + :returns: Self after training. + """ + output = self._extract_response_pairs(mudataset, scope, response_transformation) + if len(output) == 0: + return self + + output_earlystopping: ResponseBatch | None = None + + all_cl_ids = unique_entity_ids( + np.concatenate( + [ + output.cell_line_ids, + output_earlystopping.cell_line_ids if output_earlystopping else np.array([], dtype=str), + ] + ) + ) + all_drug_ids = unique_entity_ids( + np.concatenate( + [ + output.drug_ids, + output_earlystopping.drug_ids if output_earlystopping else np.array([], dtype=str), + ] + ) + ) + + cell_line_input, drug_input = self._build_features_from_mudataset(mudataset, all_cl_ids, all_drug_ids) + + return self._fit_featurizers_and_predictor( + output, + cell_line_input, + drug_input, + output_earlystopping=output_earlystopping, + training_context=training_context, + ) + + def train_with_early_stopping( + self, + mudataset: Dataset, + scope: SplitMask, + early_stopping_scope: SplitMask, + *, + training_context: TrainingContext | None = None, + response_transformation: TransformerMixin | None = None, + ) -> _ComponentStack: + """Train with an explicit early-stopping scope. + + :param mudataset: Source of response values and features. + :param scope: Entity scope defining cell-line/drug indices to train on. + :param early_stopping_scope: Entity scope for early-stopping samples. + :param training_context: Optional runtime metadata. + :param response_transformation: Optional fitted transformer applied to the + training targets and to the early-stopping targets, which are training-time + supervision and must live in the same space. + :returns: Self after training. + """ + output = self._extract_response_pairs(mudataset, scope, response_transformation) + if len(output) == 0: + return self + + output_earlystopping = self._extract_response_pairs(mudataset, early_stopping_scope, response_transformation) + if len(output_earlystopping) == 0: + output_earlystopping = None + + all_cl_ids = unique_entity_ids( + np.concatenate( + [ + output.cell_line_ids, + output_earlystopping.cell_line_ids if output_earlystopping else np.array([], dtype=str), + ] + ) + ) + all_drug_ids = unique_entity_ids( + np.concatenate( + [ + output.drug_ids, + output_earlystopping.drug_ids if output_earlystopping else np.array([], dtype=str), + ] + ) + ) + + cell_line_input, drug_input = self._build_features_from_mudataset(mudataset, all_cl_ids, all_drug_ids) + + return self._fit_featurizers_and_predictor( + output, + cell_line_input, + drug_input, + output_earlystopping=output_earlystopping, + training_context=training_context, + ) + + def predict( + self, + mudataset: Dataset, + scope: SplitMask, + ) -> np.ndarray: + """Predict responses for the entities defined by an SplitMask. + + Returns one prediction per pair in scope. Pairs with missing features + get NaN predictions (maintaining alignment with scope.pairs). + + :param mudataset: Source of feature data and entity IDs. + :param scope: Entity scope with cell-line/drug indices for prediction. + :returns: Predicted response values aligned to scope.pairs. + :raises RuntimeError: If the predictor has not been fitted. + """ + if not self.is_fitted(): + msg = "Model has not been trained; call train() or load() before predict()" + raise RuntimeError(msg) + + test_response = self._extract_response_pairs(mudataset, scope) + if len(test_response) == 0: + return np.full(len(scope.pairs), np.nan) + + all_cl_ids = unique_entity_ids(test_response.cell_line_ids) + all_drug_ids = unique_entity_ids(test_response.drug_ids) + + cell_line_input, drug_input = self._build_features_from_mudataset(mudataset, all_cl_ids, all_drug_ids) + + raw_predictions = self.predict_from_features( + test_response.cell_line_ids, + test_response.drug_ids, + cell_line_input, + drug_input, + ) + + # Align predictions back to scope.pairs (NaN for filtered pairs) + if len(raw_predictions) == len(scope.pairs): + return raw_predictions + + cl_ids = mudataset.cell_line_ids + drug_ids = mudataset.drug_ids + result = np.full(len(scope.pairs), np.nan) + predicted_pairs = set(zip(test_response.cell_line_ids.tolist(), test_response.drug_ids.tolist(), strict=True)) + + pred_idx = 0 + for i, (cl_i, dr_i) in enumerate(scope.pairs): + pair_key = (cl_ids[cl_i], drug_ids[dr_i]) + if pair_key in predicted_pairs and pred_idx < len(raw_predictions): + result[i] = raw_predictions[pred_idx] + pred_idx += 1 + + return result diff --git a/drevalpy/models/config/__init__.py b/drevalpy/models/config/__init__.py new file mode 100644 index 000000000..8cf00e06c --- /dev/null +++ b/drevalpy/models/config/__init__.py @@ -0,0 +1,32 @@ +"""Declarative configuration for modular featurizer/predictor pairing.""" + +from __future__ import annotations + +from drevalpy.types.enums.model_scope import ModelScope +from drevalpy.types.enums.prediction_mode import PredictionMode + +from .featurizer import ( + CellLineFeaturizerConfig, + DrugFeaturizerConfig, + FeaturizerConfig, +) +from .io import from_dict, from_spec, from_yaml +from .model import ModelConfig +from .predictor import PredictorConfig +from .resolved import ResolvedModelConfig +from .validation import validate + +__all__ = [ + "CellLineFeaturizerConfig", + "DrugFeaturizerConfig", + "FeaturizerConfig", + "ModelConfig", + "ModelScope", + "PredictionMode", + "PredictorConfig", + "ResolvedModelConfig", + "from_dict", + "from_spec", + "from_yaml", + "validate", +] diff --git a/drevalpy/models/config/_block_specs.py b/drevalpy/models/config/_block_specs.py new file mode 100644 index 000000000..694428c70 --- /dev/null +++ b/drevalpy/models/config/_block_specs.py @@ -0,0 +1,54 @@ +"""Derive output block specs from featurizer config trees.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.components.contracts.contracts import featurizer_contract +from drevalpy.components.featurizers._concat import ConcatFeaturizersMixin +from drevalpy.models.config.featurizer import FeaturizerConfig +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from drevalpy.types.data.batch.feature_block import BlockSpec + + +def _lookup_featurizer_class(config: FeaturizerConfig) -> type[Any]: + if config.registry == "cell_line": + return get_cell_line_featurizer(config.name) + return get_drug_featurizer(config.name) + + +def _fallback_block_specs(cls: type[Any], config: FeaturizerConfig) -> tuple[BlockSpec, ...]: + """Resolve declared specs or a single view-named block for non-base classes. + + :param cls: Registered featurizer class. + :param config: Featurizer config node being resolved. + :returns: Block specs emitted by *config*. + """ + declared = getattr(cls, "output_block_specs", ()) + if declared: + return tuple(spec for spec in declared if isinstance(spec, BlockSpec)) + input_views = getattr(cls, "input_views", None) + view = config.view or (input_views[0] if input_views else None) + if isinstance(view, str): + return (BlockSpec(view, featurizer_contract(cls).format),) + return () + + +def resolve_output_block_specs(config: FeaturizerConfig) -> tuple[BlockSpec, ...]: + """Resolve the named blocks emitted by a configured featurizer tree. + + :param config: Featurizer config node to inspect. + :returns: Block specs emitted by the featurizer tree. + """ + cls = _lookup_featurizer_class(config) + if issubclass(cls, ConcatFeaturizersMixin): + specs: list[BlockSpec] = [] + for child in config.featurizers or (): + specs.extend(resolve_output_block_specs(child)) + return tuple(specs) + + hook = getattr(cls, "output_block_specs_for_config", None) + if callable(hook): + return tuple(hook(config)) + return _fallback_block_specs(cls, config) diff --git a/drevalpy/models/config/_featurizer_parse.py b/drevalpy/models/config/_featurizer_parse.py new file mode 100644 index 000000000..0f81eb7f6 --- /dev/null +++ b/drevalpy/models/config/_featurizer_parse.py @@ -0,0 +1,285 @@ +"""Normalize featurizer mappings into canonical config fields. + +Turns the mappings users write (see the YAML tab in the docs) into the plain field mappings +``FeaturizerConfig`` validates. Recipe strings are expanded by +``drevalpy.models.config._recipe`` before they reach here, so by this point a model written +as a recipe and the same model written as YAML are the same mapping and this module needs to +know nothing about recipe notation. Kept next to the config models it feeds rather than in +``drevalpy.components``, since no component consumes it. +""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.components.featurizers._featurizer_label import requires_explicit_view +from drevalpy.models.config._recipe import CONCAT_FEATURIZER_NAME, expand_featurizer_recipe +from drevalpy.models.config._space_defaults import split_space_and_options +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer + +_RESERVED_FEATURIZER_KEYS = frozenset( + { + "name", + "hyperparameters", + "featurizers", + "registry", + "view", + "hyperparameter_space", + "options", + } +) + + +def _finalize_view(config: dict[str, Any]) -> None: + """Settle the ``view`` of a normalized mapping, in place. + + Every notation funnels through here, so this is the single place a view is required and the + single place an alias is resolved. Doing it here rather than while reading a recipe is what + makes ``raw[expression]`` and a spelled-out ``view: expression`` mean the same thing. + + Only featurizers that are parametric in a view are touched. + + :param config: Normalized featurizer mapping, updated in place. + :raises ValueError: If *config* names a view-parametric featurizer but sets no usable view. + """ + name = str(config.get("name", "")) + if not requires_explicit_view(name): + return + view = config.get("view") + if view is None or (isinstance(view, str) and not view.strip()): + msg = f"Featurizer {name!r} requires an explicit view, e.g. {name}[expression]" + raise ValueError(msg) + + +def _featurizer_class(name: str, registry: str) -> type[Any]: + """Look up a featurizer class in the registry the config is written against. + + :param name: Featurizer registry name. + :param registry: ``cell_line`` or ``drug``. + :returns: The registered featurizer class. + """ + if registry == "cell_line": + return get_cell_line_featurizer(name) + return get_drug_featurizer(name) + + +def _assemble_featurizer_dict( + name: str, + *, + default_registry: str, + view: str | None = None, + featurizers: list[Any] | None = None, + hyperparameter_space: dict[str, Any] | None = None, + options: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a canonical featurizer mapping, omitting the fields that were never set. + + :param name: Featurizer registry name. + :param default_registry: Registry recorded on the mapping. + :param view: View to record, when the featurizer takes one. + :param featurizers: Normalized children, for a concat node. + :param hyperparameter_space: Search space to record. + :param options: Fixed constructor options to record. + :returns: Normalized featurizer config mapping. + """ + payload: dict[str, Any] = { + "name": name, + "registry": default_registry, + } + if view is not None: + payload["view"] = view + if featurizers is not None: + payload["featurizers"] = featurizers + if hyperparameter_space is not None: + payload["hyperparameter_space"] = hyperparameter_space + if options is not None: + payload["options"] = options + _finalize_view(payload) + return payload + + +def _normalize_child(child: Any, *, default_registry: str) -> dict[str, Any]: + """Normalize one declared child of a concat node. + + A child may be written as a recipe string, which the recipe layer expands into the mapping + it stands for before normalization proper. + + :param child: Recipe string or mapping for one child. + :param default_registry: Registry the child is resolved against. + :returns: Normalized child mapping. + """ + if isinstance(child, str): + child = expand_featurizer_recipe(child) + return normalize_featurizer_config(child, default_registry=default_registry) + + +def _normalize_child_list(children: Any, *, default_registry: str) -> list[Any]: + """Normalize the children declared under a ``featurizers`` key. + + :param children: Value found under ``featurizers``. + :param default_registry: Registry the children are resolved against. + :returns: List of normalized child mappings. + :raises ValueError: If *children* is not a list or tuple. + """ + if isinstance(children, (str, bytes, bytearray)) or not isinstance(children, (list, tuple)): + msg = "featurizers must be a list when set" + raise ValueError(msg) + return [_normalize_child(child, default_registry=default_registry) for child in children] + + +def _normalize_featurizer_list(data: list[Any], *, default_registry: str) -> dict[str, Any]: + """Normalize a list of featurizers into a concat node over them. + + :param data: List of recipe strings or mappings. + :param default_registry: Target featurizer registry name. + :returns: Normalized concat-featurizer config mapping. + :raises ValueError: If *data* is empty. + """ + if not data: + msg = "Featurizer list must be non-empty" + raise ValueError(msg) + return { + "name": CONCAT_FEATURIZER_NAME, + "featurizers": _normalize_child_list(data, default_registry=default_registry), + "registry": default_registry, + } + + +def _normalize_named_featurizer_dict(data: dict[str, Any], *, default_registry: str) -> dict[str, Any]: + """Normalize a mapping that already names its featurizer. + + :param data: Mapping carrying at least ``name``. + :param default_registry: Registry used when the mapping declares none. + :returns: Normalized featurizer config mapping. + """ + normalized = dict(data) + normalized.setdefault("registry", default_registry) + registry = str(normalized.get("registry", default_registry)) + if "featurizers" in normalized and normalized["featurizers"] is not None: + normalized["featurizers"] = _normalize_child_list( + normalized["featurizers"], + default_registry=registry, + ) + _finalize_view(normalized) + return normalized + + +def _split_one_key_payload( + payload: dict[str, Any], + *, + name: str, + default_registry: str, +) -> tuple[list[Any] | None, dict[str, Any] | None, dict[str, Any] | None]: + """Split a one-key mapping body into template fields. + + Reserved keys are taken as declared. Everything left over is a loose parameter value, + classified against the featurizer's declared space: a tunable moves that entry's + ``default``, anything else becomes a fixed constructor option. Explicitly declared + entries always win over the derived ones. + + :param payload: Body of a one-key featurizer mapping. + :param name: Featurizer registry name. + :param default_registry: ``cell_line`` or ``drug``. + :returns: ``(featurizers, hyperparameter_space, options)``. + """ + body = dict(payload) + featurizers = body.pop("featurizers", None) + hyperparameter_space = body.pop("hyperparameter_space", None) + options = body.pop("options", None) + body.pop("view", None) + if body: + derived_space, derived_options = split_space_and_options( + _featurizer_class(name, default_registry), + body, + ) + if derived_space: + hyperparameter_space = {**derived_space, **(hyperparameter_space or {})} + if derived_options: + options = {**derived_options, **(options or {})} + return featurizers, hyperparameter_space, options + + +def _one_key_name_and_view(name_token: str) -> tuple[str, str | None]: + """Read the key of a one-key mapping, which is written as a single recipe atom. + + A key that is not shaped like a single atom (``"a+b"``, ``"raw["``) is taken verbatim as a + name so it fails with the registry's "unknown featurizer" error, which is what this notation + did before. + + :param name_token: Bare featurizer name or ``name[view]`` atom. + :returns: Featurizer name and the view written in brackets, if any. + """ + try: + payload = expand_featurizer_recipe(name_token) + except ValueError: + return name_token.strip(), None + if payload["name"] == CONCAT_FEATURIZER_NAME: + return name_token.strip(), None + return str(payload["name"]), payload.get("view") + + +def _normalize_one_key_featurizer_dict(data: dict[str, Any], *, default_registry: str) -> dict[str, Any]: + """Normalize the ``{"pca[methylation]": {...}}`` notation. + + :param data: Single-entry mapping of atom to arguments. + :param default_registry: Target featurizer registry name. + :returns: Normalized featurizer config mapping. + :raises ValueError: If the arguments are neither ``None`` nor a mapping. + """ + name_token, body = next(iter(data.items())) + if body is None: + payload: dict[str, Any] = {} + elif isinstance(body, dict): + payload = dict(body) + else: + msg = f"Featurizer {name_token!r} arguments must be a mapping when provided" + raise ValueError(msg) + name, view = _one_key_name_and_view(str(name_token)) + featurizers, hyperparameter_space, options = _split_one_key_payload( + payload, + name=name, + default_registry=default_registry, + ) + if featurizers is not None: + featurizers = _normalize_child_list(featurizers, default_registry=default_registry) + return _assemble_featurizer_dict( + name, + default_registry=default_registry, + view=view, + featurizers=featurizers, + hyperparameter_space=hyperparameter_space, + options=options, + ) + + +def normalize_featurizer_config(data: Any, *, default_registry: str = "cell_line") -> dict[str, Any]: + """Normalize a featurizer mapping into a canonical field mapping. + + Accepts a list of featurizers (equivalent to a concat node), a one-key mapping + (``{"pca[methylation]": {...}}``), or a mapping that already has ``name``. A recipe string + is not a mapping: callers expand one with + ``drevalpy.models.config._recipe.expand_featurizer_recipe`` first, so that a model written + as a recipe arrives here as the same mapping the equivalent YAML would produce. + + :param data: List of featurizers, or a field mapping. + :param default_registry: Registry used to resolve bare names (``cell_line`` or ``drug``). + :returns: Mapping of canonical ``FeaturizerConfig`` fields. + :raises ValueError: If the mapping form is not a one-key shorthand and lacks ``name``. + :raises TypeError: If *data* is not a list or mapping. + """ + if isinstance(data, list): + return _normalize_featurizer_list(data, default_registry=default_registry) + + if not isinstance(data, dict): + msg = f"Featurizer config must be a list or mapping, got {type(data)!r}" + raise TypeError(msg) + + if "name" in data: + return _normalize_named_featurizer_dict(data, default_registry=default_registry) + + if not _RESERVED_FEATURIZER_KEYS.intersection(data.keys()) and len(data) == 1: + return _normalize_one_key_featurizer_dict(data, default_registry=default_registry) + + msg = "Featurizer config must be a list, one-key mapping, or dict with 'name'" + raise ValueError(msg) diff --git a/drevalpy/models/config/_hp_key_validation.py b/drevalpy/models/config/_hp_key_validation.py new file mode 100644 index 000000000..53c5e0167 --- /dev/null +++ b/drevalpy/models/config/_hp_key_validation.py @@ -0,0 +1,88 @@ +"""Validation of resolved hyperparameter mappings against a model config. + +``resolved.py`` calls into this module during construction, so it keeps its +references to ``ModelConfig`` and ``FeaturizerConfig`` annotation-only. The key +grammar itself lives in the dependency-free +:mod:`drevalpy.models._hp_key_grammar` leaf, shared with +``drevalpy.models.tuning``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from drevalpy.components.featurizers._featurizer_label import qualified_featurizer_selector +from drevalpy.components.featurizers._featurizer_tree import iter_featurizer_leaves +from drevalpy.models._hp_key_grammar import ( + featurizer_prefix, + predictor_prefix, + reject_indexed_featurizer_key, +) +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from drevalpy.registry.predictor import get as get_predictor + +if TYPE_CHECKING: + from drevalpy.models.config.featurizer import FeaturizerConfig + from drevalpy.models.config.model import ModelConfig + + +def _leaf_selector(featurizer: FeaturizerConfig) -> str: + return qualified_featurizer_selector(featurizer.name, featurizer.view) + + +def _predictor_accepted_keys(predictor_cls: type[Any]) -> set[str]: + keys = set(predictor_cls.get_default_hyperparameters()) + keys.update(predictor_cls.get_hyperparameter_space()) + non_tunable = getattr(predictor_cls, "non_tunable_hyperparameters", None) + if isinstance(non_tunable, dict): + keys.update(non_tunable) + elif isinstance(non_tunable, (set, frozenset, list, tuple)): + keys.update(str(key) for key in non_tunable) + return keys + + +def _featurizer_accepted_keys(featurizer: FeaturizerConfig, registry: str) -> set[str]: + cls = get_cell_line_featurizer(featurizer.name) if registry == "cell_line" else get_drug_featurizer(featurizer.name) + return set(cls.get_hyperparameter_space()) + + +def _build_validation_index(config: ModelConfig) -> dict[str, Any]: + """Build the set of accepted qualified keys for a model config. + + Returns a dict mapping qualified key -> True for O(1) membership checks. + """ + accepted: dict[str, Any] = {} + + predictor_cls = get_predictor(config.predictor.name) + for param in _predictor_accepted_keys(predictor_cls): + accepted[predictor_prefix(config.predictor.name, param)] = True + + if config.cell_line_featurizer is not None: + for leaf in iter_featurizer_leaves(config.cell_line_featurizer, "cell_line"): + selector = _leaf_selector(leaf) + for param in _featurizer_accepted_keys(leaf, "cell_line"): + accepted[featurizer_prefix("cell_line", selector, param)] = True + + if config.drug_featurizer is not None: + for leaf in iter_featurizer_leaves(config.drug_featurizer, "drug"): + selector = _leaf_selector(leaf) + for param in _featurizer_accepted_keys(leaf, "drug"): + accepted[featurizer_prefix("drug", selector, param)] = True + + return accepted + + +def validate_merged_mapping(config: ModelConfig, merged: dict[str, Any]) -> None: + """Reject unknown or malformed qualified hyperparameter keys. + + :param config: The model configuration template. + :param merged: Merged mapping of qualified keys to values. + :raises ValueError: Raised on invalid input. + """ + accepted = _build_validation_index(config) + for key in merged: + reject_indexed_featurizer_key(key) + if key not in accepted: + msg = f"Unknown hyperparameter {key!r} for this model stack." + raise ValueError(msg) diff --git a/drevalpy/models/config/_predictor_parse.py b/drevalpy/models/config/_predictor_parse.py new file mode 100644 index 000000000..e961f73e3 --- /dev/null +++ b/drevalpy/models/config/_predictor_parse.py @@ -0,0 +1,83 @@ +"""Normalize predictor recipe strings and mappings into canonical config fields.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.models.config._space_defaults import split_space_and_options +from drevalpy.registry.predictor import get as get_predictor + +_RESERVED_PREDICTOR_KEYS = frozenset({"name", "hyperparameters", "hyperparameter_space"}) + + +def _reject_predictor_options(name: str, options: dict[str, Any]) -> None: + """Reject values that the predictor does not declare as tunable. + + A template records a search space, not concrete constructor arguments, so a value that + matches no declared hyperparameter has nowhere to go. + + :param name: Predictor registry name. + :param options: Values that matched no entry in the declared space. + :raises ValueError: If *options* is non-empty. + """ + if not options: + return + option_keys = ", ".join(sorted(repr(key) for key in options)) + msg = f"Predictor {name!r} template configs do not accept non-tunable options ({option_keys})." + raise ValueError(msg) + + +def _normalize_one_key_predictor_dict(data: dict[str, Any]) -> dict[str, Any]: + """Normalize the ``{"randomForest": {"n_estimators": 10}}`` notation. + + Loose values move the ``default`` of the matching entry in the predictor's declared + space. An explicitly declared ``hyperparameter_space`` wins over the derived one. + + :param data: Single-entry mapping of predictor name to arguments. + :returns: Mapping of canonical ``PredictorConfig`` fields. + :raises ValueError: If the arguments are neither ``None`` nor a mapping. + """ + name, body = next(iter(data.items())) + if body is None: + return {"name": str(name)} + if not isinstance(body, dict): + msg = f"Predictor {name!r} arguments must be a mapping when provided" + raise ValueError(msg) + payload = dict(body) + hyperparameter_space = payload.pop("hyperparameter_space", None) + if payload: + derived_space, options = split_space_and_options(get_predictor(str(name)), payload) + _reject_predictor_options(str(name), options) + hyperparameter_space = {**derived_space, **(hyperparameter_space or {})} + result: dict[str, Any] = {"name": str(name)} + if hyperparameter_space is not None: + result["hyperparameter_space"] = hyperparameter_space + return result + + +def normalize_predictor_config(data: Any) -> dict[str, Any]: + """Normalize any accepted predictor notation into a canonical field mapping. + + Accepts a bare name (``"elasticNet"``), a one-key mapping carrying loose parameter + values, or a mapping that already names its predictor, which normalizes to itself. + + :param data: Recipe string or field mapping. + :returns: Mapping of canonical ``PredictorConfig`` fields. + :raises ValueError: If a mapping is neither a one-key shorthand nor has ``name``. + :raises TypeError: If *data* is not a string or mapping. + """ + if isinstance(data, str): + return {"name": data} + + if not isinstance(data, dict): + msg = f"Predictor config must be a string or mapping, got {type(data)!r}" + raise TypeError(msg) + + if "name" in data: + return dict(data) + + if not _RESERVED_PREDICTOR_KEYS.intersection(data.keys()) and len(data) == 1: + return _normalize_one_key_predictor_dict(data) + + msg = "Predictor config must be a string, one-key mapping, or dict with 'name'" + raise ValueError(msg) diff --git a/drevalpy/models/config/_predictor_traits.py b/drevalpy/models/config/_predictor_traits.py new file mode 100644 index 000000000..050da07be --- /dev/null +++ b/drevalpy/models/config/_predictor_traits.py @@ -0,0 +1,52 @@ +"""What a registered predictor implies about the config around it. + +A predictor class is written for one training scope, and a single-drug one fits a separate +estimator per drug. Both facts are properties of the component, not choices a ``ModelConfig`` +makes, so they are read off the registry rather than stored as fields. + +The two readers sit on opposite sides of field validation. ``scope_for_predictor`` takes a +resolved name and is free to raise; ``needs_identity_drug_routing`` runs before validation on +a slot that may be written in any accepted spelling, or be unusable, so it never raises. +""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.models.config.predictor import PredictorConfig +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.types.enums.model_scope import ModelScope + + +def scope(name: str) -> ModelScope: + """Return the training scope the registered predictor is written for. + + :param name: Registry name of the predictor. + :returns: The scope declared by the predictor class. + """ + return get_predictor(name).scope + + +def needs_identity_drug_routing(slot: Any) -> bool: + """Report whether a predictor slot names a predictor that routes per drug by identity. + + True for a single-drug predictor that consumes features: it fits one estimator per drug, so + it needs the drug's identity to dispatch each pair to the right one. Which featurizer that + is was never a choice, so this answers yes or no rather than naming one. + + Written for a ``mode="before"`` validator, so *slot* may be any spelling a predictor slot + accepts -- a bare name, a one-key mapping, a ``name`` mapping, or a ``PredictorConfig`` -- + and may equally be unusable. Anything that cannot be read as a registered predictor is + reported as ``False`` rather than raising, leaving the bad value for normal field validation + to report. Pydantic's ``ValidationError`` is a ``ValueError``, so one ``ValueError`` catch + covers both a malformed slot and an unknown predictor name. + + :param slot: Raw value of a config payload's ``predictor`` slot. + :returns: Whether the slot's predictor needs the identity drug featurizer for routing. + """ + try: + cls = get_predictor(PredictorConfig.model_validate(slot).name) + except (TypeError, ValueError, ImportError): + return False + return cls.scope is ModelScope.SINGLE_DRUG and not issubclass(cls, FeatureFreePredictor) diff --git a/drevalpy/models/config/_recipe.py b/drevalpy/models/config/_recipe.py new file mode 100644 index 000000000..9e4dcaf50 --- /dev/null +++ b/drevalpy/models/config/_recipe.py @@ -0,0 +1,160 @@ +"""The recipe language: one grammar for reading it, one function for writing it. + +A recipe is how a model is named in configs, on the CLI, and in ``ModelConfig.model_id``. +The grammar covers all three layers, so the delimiters are defined in a single place:: + + model := slot ":" slot ":" predictor | slot ":" predictor | predictor + slot := atom ("+" atom)* + atom := NAME ("[" VIEW "]")? + +This module owns the recipe notation end to end: the grammar above, and what its two +constructs *stand for* -- ``+`` a ``concatFeaturizers`` node, brackets a ``view`` field. +Expanding a recipe is a transcription, not an interpretation: it produces the same mapping a +YAML file would have spelled out, so a recipe and the YAML for the same model are the same +input from there on. + +Only *shape* is checked here. Whether a name exists, whether a featurizer takes a view at all, +and whether a view resolves to a matrix are semantic questions, answered downstream for every +notation alike -- a bracket buys no extra scrutiny over a ``view`` key, and no less. +""" + +from __future__ import annotations + +from typing import Any + +import pyparsing as pp + +CONCAT_FEATURIZER_NAME = "concatFeaturizers" +"""Registry name of the node a ``+`` recipe stands for.""" + +_NAME = pp.Regex(r"[^\[\]+:\s]+") +"""Featurizer or predictor name: anything that is not a delimiter or whitespace.""" + +_VIEW = pp.Regex(r"[^\[\]:]+") +"""View token. Deliberately allows ``+`` so ``raw[a+b]`` stays a single atom and the error +names the unusable view instead of a truncated featurizer name.""" + +_SLOT_SEP = ":" +_ATOM_SEP = "+" + +_ATOM = pp.Group(_NAME("name") + pp.Optional(pp.Suppress("[") + _VIEW("view") + pp.Suppress("]"))) +_FEATURIZERS = pp.DelimitedList(_ATOM, delim=_ATOM_SEP) +_SLOT = pp.original_text_for(_FEATURIZERS) +_COLON = pp.Suppress(_SLOT_SEP) + +_FEATURIZER_RECIPE = _FEATURIZERS + pp.StringEnd() +_MODEL_RECIPE = ( + _SLOT("cell_line") + _COLON + _SLOT("drug") + _COLON + _NAME("predictor") + | _SLOT("cell_line") + _COLON + _NAME("predictor") + | _NAME("predictor") +) + pp.StringEnd() + +_FEATURIZER_SYNTAX = "atoms must be non-empty and shaped 'name' or 'name[view]', joined by '+'" +_MODEL_SYNTAX = "expected 'predictor', 'cellLineFeaturizer:predictor', or 'cellLineFeaturizer:drugFeaturizer:predictor'" + + +def parse_featurizer_atoms(token: str) -> list[tuple[str, str | None]]: + """Parse a featurizer recipe into its atoms. + + :param token: Recipe string such as ``"raw[expression]+scaledGeneExpression"``. + :returns: One ``(name, view)`` pair per atom, with *view* ``None`` when unbracketed. + :raises ValueError: If *token* is not a well-formed featurizer recipe. + """ + try: + parsed = _FEATURIZER_RECIPE.parse_string(token, parse_all=True) + except pp.ParseBaseException as exc: + msg = f"Malformed featurizer recipe {token!r}: {_FEATURIZER_SYNTAX}" + raise ValueError(msg) from exc + return [(atom["name"], atom.get("view")) for atom in parsed] + + +def _atom_payload(name: str, view: str | None) -> dict[str, Any]: + """Transcribe one parsed atom into its field mapping. + + A bracketed view becomes a ``view`` field spelled exactly as written, so an atom carries no + more and no less information than the mapping form of the same featurizer. + + :param name: Featurizer registry name. + :param view: View written inside brackets, or ``None`` when unbracketed. + :returns: Field mapping for this atom. + """ + if view is None: + return {"name": name} + return {"name": name, "view": view} + + +def expand_featurizer_recipe(token: str) -> dict[str, Any]: + """Expand a featurizer recipe into the field mapping a YAML file would have spelled out. + + A single atom expands to one mapping; ``+``-joined atoms expand to a concat node over them. + The result names config fields only, so callers can treat it exactly like a mapping that was + written out by hand, and it is checked no more strictly than one. + + :param token: Recipe string such as ``"raw[expression]+scaledGeneExpression"``. + :returns: Featurizer or concat-featurizer field mapping. + :raises ValueError: If *token* is blank or is not a well-formed recipe. + """ + trimmed = token.strip() + if not trimmed: + msg = "Featurizer token must be a non-empty string" + raise ValueError(msg) + payloads = [_atom_payload(name, view) for name, view in parse_featurizer_atoms(trimmed)] + if len(payloads) == 1: + return payloads[0] + return {"name": CONCAT_FEATURIZER_NAME, "featurizers": payloads} + + +def parse_model_recipe(spec: str) -> dict[str, Any]: + """Read a model recipe into the plain field mapping a model config is built from. + + This is to a recipe string what ``yaml.safe_load`` is to a YAML file: source syntax in, + plain mapping out, no registry involved. Each featurizer slot is expanded into the mapping it + stands for, so the result is indistinguishable from the same model written as YAML. Splitting + happens through the grammar rather than on ``:``, so a colon inside a view cannot be + mistaken for a slot separator. + + :param spec: ``predictor``, ``cell:predictor``, or ``cell:drug:predictor``. + :returns: ``cell_line_featurizer``, ``drug_featurizer`` and ``predictor`` entries; the two + featurizer slots are ``None`` when the recipe omits them. + :raises ValueError: If *spec* is empty or not a well-formed model recipe. + """ + if not spec or not spec.strip(): + msg = "model recipe must be a non-empty string" + raise ValueError(msg) + try: + parsed = _MODEL_RECIPE.parse_string(spec, parse_all=True) + except pp.ParseBaseException as exc: + msg = f"Malformed model recipe {spec!r}: {_MODEL_SYNTAX}" + raise ValueError(msg) from exc + cell_line = parsed.get("cell_line") + drug = parsed.get("drug") + return { + "cell_line_featurizer": expand_featurizer_recipe(cell_line) if cell_line is not None else None, + "drug_featurizer": expand_featurizer_recipe(drug) if drug is not None else None, + "predictor": parsed["predictor"], + } + + +def format_model_recipe(cell_line: str | None, drug: str | None, predictor: str) -> str: + """Join component names back into a model recipe. + + Writes the grammar that ``parse_model_recipe`` reads, and the only place the slot + separator is written out. A recipe names its slots left to right, so a drug slot without + a cell-line slot has nowhere to go. + + :param cell_line: Cell-line featurizer name, or ``None`` for feature-free predictors. + :param drug: Drug featurizer name, or ``None`` when omitted. + :param predictor: Predictor name. + :returns: Model recipe of one to three colon-separated parts. + :raises ValueError: If *predictor* is empty, or *drug* is set without *cell_line*. + """ + if not predictor: + msg = "predictor is required" + raise ValueError(msg) + if cell_line is None and drug is None: + return predictor + if cell_line is None: + msg = "cell_line is required when drug is set" + raise ValueError(msg) + parts = [cell_line, predictor] if drug is None else [cell_line, drug, predictor] + return _SLOT_SEP.join(parts) diff --git a/drevalpy/models/config/_space_defaults.py b/drevalpy/models/config/_space_defaults.py new file mode 100644 index 000000000..7510475e6 --- /dev/null +++ b/drevalpy/models/config/_space_defaults.py @@ -0,0 +1,38 @@ +"""Classify loose parameter values against a component's declared hyperparameter space. + +Both compact config notations let a value be written next to the component name rather than +inside a full search-space spec (``{"pca[methylation]": {"n_components": 8}}``). Deciding +what such a value means needs the component's own declared space: a key it declares is a +tunable whose ``default`` moves, anything else is a fixed constructor option. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +def split_space_and_options( + cls: type[Any], + values: Mapping[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + """Split loose parameter values into space-default overrides and fixed options. + + The returned space is the component's full declared space with the matched defaults + replaced, not only the touched entries, so the config records a complete space. Callers + apply their own empty-versus-``None`` convention to both halves. + + :param cls: Component class exposing ``get_hyperparameter_space``. + :param values: Mapping of local parameter name to concrete value. + :returns: ``(hyperparameter_space, options)``. + """ + space = { + key: dict(spec) if isinstance(spec, dict) else spec for key, spec in cls.get_hyperparameter_space().items() + } + options: dict[str, Any] = {} + for key, value in values.items(): + if key in space and isinstance(space[key], dict): + space[key] = {**space[key], "default": value} + else: + options[key] = value + return space, options diff --git a/drevalpy/models/config/featurizer.py b/drevalpy/models/config/featurizer.py new file mode 100644 index 000000000..dd6712f12 --- /dev/null +++ b/drevalpy/models/config/featurizer.py @@ -0,0 +1,231 @@ +"""Declarative featurizer configuration schemas.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Literal, get_args + +from pydantic import BaseModel, ConfigDict, model_validator + +from drevalpy.components.contracts.hyperparameter_space import validate_hyperparameter_space +from drevalpy.components.featurizers._featurizer_label import requires_explicit_view +from drevalpy.components.featurizers._featurizer_tree import ensure_unique_qualified_featurizers +from drevalpy.models.config._featurizer_parse import normalize_featurizer_config +from drevalpy.models.config._recipe import expand_featurizer_recipe +from drevalpy.models.config.immutable import FrozenMapping, thaw_value +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer + + +class FeaturizerConfig(BaseModel): + """Immutable template for a featurizer node in a model stack. + + Describes *which* featurizer to build and how to address it, not the built object: + a ``name`` looked up in one of the two registries (``cell_line`` or ``drug``), an + optional ``view`` selecting the input matrix, ``options`` fixing concrete + constructor values, and ``hyperparameter_space`` declaring what tuning may vary. + ``featurizers`` makes the node a tree, holding children for ``concatFeaturizers``. + Combine several views by nesting one single-``view`` child per view under a concat + node, which is what the ``raw[expression]+raw[mutations]`` shorthand expands to. + + Accepts the same notations users write in the docs' recipe strings and YAML (a bare + name, a ``name[view]`` label, a list, or a one-key mapping) and normalizes them into + these fields. Validation is front-loaded here so a bad recipe fails at load time + instead of mid-run. + + ``tuple`` fields plus ``frozen=True`` make an accidental in-place edit of a shared or + cached config fail loudly. Note this buys *safety*, not hashability: ``options`` and + ``hyperparameter_space`` hold arbitrary nested data, so a config carrying either is + unhashable regardless. Configs are compared and copied by value via ``model_dump``. + + Subclasses pin ``registry`` to a single value; see ``CellLineFeaturizerConfig`` + and ``DrugFeaturizerConfig``. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + name: str + registry: Literal["cell_line", "drug"] = "cell_line" + view: str | None = None + featurizers: tuple[FeaturizerConfig, ...] | None = None + options: FrozenMapping | None = None + hyperparameter_space: FrozenMapping | None = None + + @classmethod + def _pinned_registry(cls) -> str | None: + """Return the single registry this class is locked to, if any. + + Subclasses narrow ``registry`` to a one-value ``Literal``, so that annotation is + the only place each registry name is declared. + + :returns: The sole allowed ``registry`` value, or ``None`` when several are allowed. + """ + allowed = get_args(cls.model_fields["registry"].annotation) + return str(allowed[0]) if len(allowed) == 1 else None + + @model_validator(mode="before") + @classmethod + def _normalize_recipe_input(cls, data: object) -> object: + """Rewrite the various ways of writing one featurizer into this model's own fields. + + A featurizer can be given as a compact recipe string or spelled out as a mapping. + This runs before field validation and reduces every accepted form to the canonical + ``name`` / ``view`` / ``featurizers`` fields:: + + "scaledGeneExpression" name=scaledGeneExpression + "raw[gene_expression]" name=raw, view=gene_expression + "raw[gene_expression]+raw[mutations]" name=concatFeaturizers, two children + ["raw[gene_expression]", "raw[mutations]"] name=concatFeaturizers, two children + {"pca[methylation]": {"n_components": 8}} name=pca, view=methylation, + space + {"name": "raw", "view": "gene_expression"} already canonical, passed through + + A recipe string is expanded into the mapping it stands for first, so the mapping + normalizer sees the same input whichever notation was used. Turning a bare name into a + component needs a registry to look it up in. A pinned subclass uses its own and + overwrites a conflicting ``registry`` key; the base class reads it from the input, + falling back to the field default. + + :param data: A recipe string, a list of them, or a mapping of fields. + :returns: Canonical field mapping, or *data* unchanged if it is none of these forms. + """ + if not isinstance(data, (str, list, dict)): + return data + if isinstance(data, str): + data = expand_featurizer_recipe(data) + pinned = cls._pinned_registry() + if pinned is not None: + if isinstance(data, dict) and "registry" in data: + data = {**data, "registry": pinned} + return normalize_featurizer_config(data, default_registry=pinned) + requested = data.get("registry") if isinstance(data, dict) else None + fallback = cls.model_fields["registry"].default + return normalize_featurizer_config(data, default_registry=str(requested or fallback)) + + @model_validator(mode="after") + def _validate_hyperparameter_space(self) -> FeaturizerConfig: + """Check this node's tuning search space against the shared space schema. + + Catches a malformed space at config load time rather than deep inside a tuning + run. The featurizer name is passed as context so the error names the offender. + + :returns: This config, unchanged. + """ + if self.hyperparameter_space is not None: + validate_hyperparameter_space( + self.hyperparameter_space, + context=f"FeaturizerConfig({self.name!r}).hyperparameter_space", + ) + return self + + @model_validator(mode="after") + def _require_non_empty_view(self) -> FeaturizerConfig: + """Reject a blank ``view``. + + A whitespace-only view would otherwise reach the registry and fail much later + with a far less obvious error. + + :returns: This config, unchanged. + :raises ValueError: If ``view`` is set but blank. + """ + if self.view is not None and not str(self.view).strip(): + msg = "view must be a non-empty string when set" + raise ValueError(msg) + return self + + @model_validator(mode="after") + def _require_explicit_view_for_parametric_featurizers(self) -> FeaturizerConfig: + """Require a view for featurizers that are only meaningful per view. + + Such featurizers are addressed with a parametric label like ``pca[expression]``, + so a bare name is ambiguous rather than defaultable. + + :returns: This config, unchanged. + :raises ValueError: If the featurizer needs an explicit view and none is set. + """ + if requires_explicit_view(self.name) and not self.view: + msg = f"Featurizer {self.name!r} requires an explicit view, e.g. {self.name}[expression]" + raise ValueError(msg) + return self + + @model_validator(mode="after") + def _require_concat_children(self) -> FeaturizerConfig: + """Confine nested ``featurizers`` to ``concatFeaturizers``, which must have some. + + Keeps the tree shape honest: only the concat node combines children, and an empty + concat node would build no features at all. + + :returns: This config, unchanged. + :raises ValueError: If concat has no children, or a non-concat featurizer has any. + """ + if self.name == "concatFeaturizers": + if not self.featurizers: + msg = "concatFeaturizers requires a non-empty featurizers list" + raise ValueError(msg) + elif self.featurizers is not None: + msg = f"Featurizer {self.name!r} does not accept nested featurizers" + raise ValueError(msg) + return self + + @model_validator(mode="after") + def _require_unique_qualified_children(self) -> FeaturizerConfig: + """Reject a concat tree that repeats the same qualified leaf, e.g. ``raw[expression]``. + + Duplicates would silently emit the same feature block twice. The same base name on + different views is fine. + + :returns: This config, unchanged. + """ + if self.name != "concatFeaturizers": + return self + ensure_unique_qualified_featurizers(self, str(self.registry)) + return self + + def create_instance(self, hyperparameters: Mapping[str, Any] | None = None): + """Instantiate the configured featurizer from the registry. + + Turns this declarative template into a live object: resolves ``name`` in the + registry named by ``registry``, then merges constructor arguments so that + *hyperparameters* (typically a tuning trial's picks) override the config's own + ``options``. ``view`` and ``featurizers`` are filled in only when the + caller has not already supplied them. + + :param hyperparameters: Concrete constructor values for this node. Nested + concat children should already be resolved by the caller into instances + or config payloads under the ``featurizers`` key. + :returns: Featurizer instance for this config. + """ + if self.registry == "cell_line": + cls = get_cell_line_featurizer(self.name) + else: + cls = get_drug_featurizer(self.name) + hp = thaw_value(dict(self.options or {})) + hp.update(thaw_value(dict(hyperparameters or {}))) + if self.view is not None: + hp.setdefault("view", self.view) + if self.featurizers is not None and "featurizers" not in hp: + hp["featurizers"] = [child.model_dump(mode="python") for child in self.featurizers] + return cls(**hp) + + +class CellLineFeaturizerConfig(FeaturizerConfig): + """Featurizer config fixed to the cell-line registry. + + Use in a slot that must hold cell-line features: a mismatched ``registry`` in the + payload is corrected to ``cell_line`` rather than accepted. + """ + + registry: Literal["cell_line"] = "cell_line" + + +class DrugFeaturizerConfig(FeaturizerConfig): + """Featurizer config fixed to the drug registry. + + Drug-side counterpart of ``CellLineFeaturizerConfig``. + """ + + registry: Literal["drug"] = "drug" + + +FeaturizerConfig.model_rebuild() +CellLineFeaturizerConfig.model_rebuild() +DrugFeaturizerConfig.model_rebuild() diff --git a/drevalpy/models/config/immutable.py b/drevalpy/models/config/immutable.py new file mode 100644 index 000000000..005ebcd0a --- /dev/null +++ b/drevalpy/models/config/immutable.py @@ -0,0 +1,60 @@ +"""Deep-freeze helpers for immutable model configuration values.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from types import MappingProxyType +from typing import Annotated, Any + +from pydantic import AfterValidator, PlainSerializer + + +def freeze_value(value: Any) -> Any: + """Recursively freeze mappings and sequences into immutable containers. + + :param value: Arbitrary nested value from config construction. + :returns: Immutable view of *value* (scalars unchanged). + """ + if isinstance(value, MappingProxyType): + return value + if isinstance(value, Mapping): + return MappingProxyType({key: freeze_value(item) for key, item in value.items()}) + if isinstance(value, (str, bytes, bytearray)): + return value + if isinstance(value, Sequence): + return tuple(freeze_value(item) for item in value) + if isinstance(value, set): + return frozenset(freeze_value(item) for item in value) + return value + + +def thaw_value(value: Any) -> Any: + """Recursively convert frozen containers back into plain dicts and lists. + + Used by ``model_dump`` serializers so persistence and YAML stay JSON-friendly. + + :param value: Possibly frozen nested value. + :returns: Mutable plain-Python equivalent. + """ + if isinstance(value, Mapping): + return {key: thaw_value(item) for key, item in value.items()} + if isinstance(value, (str, bytes, bytearray)): + return value + if isinstance(value, tuple): + return [thaw_value(item) for item in value] + if isinstance(value, frozenset): + return [thaw_value(item) for item in value] + return value + + +FrozenMapping = Annotated[ + Mapping[str, Any], + AfterValidator(freeze_value), + PlainSerializer(thaw_value, return_type=dict), +] +"""Untyped config mapping that is frozen on validation and thawed on dump. + +Pydantic's ``frozen=True`` is shallow, so the arbitrary nested contents of these +escape-hatch fields need the recursive walk in :func:`freeze_value`; ``model_dump`` +must still hand out plain dicts and lists for YAML and checkpoint persistence. +""" diff --git a/drevalpy/models/config/io.py b/drevalpy/models/config/io.py new file mode 100644 index 000000000..b926815f4 --- /dev/null +++ b/drevalpy/models/config/io.py @@ -0,0 +1,113 @@ +"""Parse declarative model configs from strings, dicts, and YAML files.""" + +from __future__ import annotations + +from typing import Any + +import yaml +from pydantic import ValidationError +from upath import UPath as Path + +from drevalpy.models.config._recipe import parse_model_recipe +from drevalpy.models.config.model import ModelConfig +from drevalpy.models.config.resolved import ResolvedModelConfig +from drevalpy.models.factory import ( + apply_optional_hyperparameters, + reject_unknown_spec, + zoo_config, +) +from drevalpy.types.enums.prediction_mode import PredictionMode + +__all__ = ["from_dict", "from_spec", "from_yaml"] + + +def _format_error_entry(error: Any) -> str: + location = " -> ".join(str(part) for part in error["loc"]) + if not location: + return str(error["msg"]) + return f"{location}: {error['msg']}" + + +def _format_validation_error(exc: ValidationError, *, source: Path | str | None = None) -> str: + prefix = "Invalid model config" + if source is not None: + prefix = f"{prefix} in {source}" + details = "; ".join(_format_error_entry(error) for error in exc.errors()) + return f"{prefix}: {details}" + + +def from_dict(data: dict[str, Any], *, source: Path | str | None = None) -> ModelConfig: + """Build a ``ModelConfig`` from a plain dictionary. + + This is where the registry is consulted: field validation resolves featurizer and + predictor names, and the model-level validator checks that the combination is legal. + Every other entry point in this module reduces its source to a mapping and ends up here. + + :param data: Mapping with featurizer and predictor sections. + :param source: Optional path or label included in validation error messages. + :returns: Validated ``ModelConfig`` instance. + :raises ValueError: If validation fails. + """ + try: + return ModelConfig.model_validate(data) + except ValidationError as exc: + raise ValueError(_format_validation_error(exc, source=source)) from exc + + +def from_spec( + spec: str, + *, + hyperparameters: dict[str, Any] | None = None, + prediction_mode: PredictionMode | str | None = None, +) -> ModelConfig | ResolvedModelConfig: + """Build a ``ModelConfig`` from a zoo preset name or a recipe string. + + A spec is either the name of a registered zoo preset or a recipe naming the parts + directly. Zoo names win, so a preset can shadow a bare predictor name. Recipes take the + same two steps as any other config source: ``parse_model_recipe`` reads the syntax into a + plain field mapping, then ``from_dict`` resolves the names against the registry and checks + that the combination is legal. A bare token is exactly the recipe with no cell-line slot, + so that is where a mistyped zoo name is caught before it is read as a predictor. + + :param spec: Zoo preset name, or a recipe of one to three colon-separated parts. + :param hyperparameters: Optional flat public hyperparameter overrides. + :param prediction_mode: Prediction mode for the predictor; defaults to regression. + :returns: Validated ``ModelConfig`` template, or ``ResolvedModelConfig`` when + *hyperparameters* are provided. + :raises ValueError: If *spec* is unknown or validation fails. + """ + trimmed = spec.strip() + if not trimmed: + msg = "model spec must be a non-empty string" + raise ValueError(msg) + mode = PredictionMode.REGRESSION if prediction_mode is None else PredictionMode(prediction_mode) + + preset = zoo_config(trimmed, hyperparameters, mode) + if preset is not None: + return preset + + payload = parse_model_recipe(trimmed) + if payload["cell_line_featurizer"] is None: + reject_unknown_spec(payload["predictor"]) + config = from_dict({**payload, "prediction_mode": mode}, source=f"recipe {trimmed!r}") + return apply_optional_hyperparameters(config, hyperparameters) + + +def from_yaml(path: Path | str) -> ModelConfig: + """Load a ``ModelConfig`` from a YAML file. + + :param path: Path to a YAML mapping describing the model config. + :returns: Validated ``ModelConfig`` instance. + :raises FileNotFoundError: If ``path`` does not exist. + :raises TypeError: If the YAML top-level node is not a mapping. + """ + yaml_path = Path(path) + if not yaml_path.is_file(): + msg = f"Model config YAML not found: {yaml_path}" + raise FileNotFoundError(msg) + with yaml_path.open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if not isinstance(data, dict): + msg = f"Model config YAML must contain a mapping: {yaml_path}" + raise TypeError(msg) + return from_dict(data, source=yaml_path) diff --git a/drevalpy/models/config/model.py b/drevalpy/models/config/model.py new file mode 100644 index 000000000..60a456e76 --- /dev/null +++ b/drevalpy/models/config/model.py @@ -0,0 +1,135 @@ +"""Full declarative model configuration template.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, ConfigDict, model_validator + +from drevalpy.components.predictors.single_drug_routing import ROUTING_DRUG_FEATURIZER +from drevalpy.models.config._predictor_traits import needs_identity_drug_routing, scope +from drevalpy.models.config._recipe import format_model_recipe +from drevalpy.models.config.featurizer import CellLineFeaturizerConfig, DrugFeaturizerConfig +from drevalpy.models.config.predictor import PredictorConfig +from drevalpy.models.config.validation import validate +from drevalpy.models.config.view_resolution import ( + entity_id_only_from_featurizer_config, + views_from_featurizer_config, +) +from drevalpy.types.enums.model_scope import ModelScope +from drevalpy.types.enums.prediction_mode import PredictionMode + +if TYPE_CHECKING: + from drevalpy.models.config.resolved import ResolvedModelConfig + + +class ModelConfig(BaseModel): + """Immutable class-level template for a composed model. + + Stores architecture (featurizers / predictor), prediction mode, and optional + hyperparameter-space overrides. Concrete selected hyperparameter values live on + ``ResolvedModelConfig``. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + cell_line_featurizer: CellLineFeaturizerConfig | None = None + drug_featurizer: DrugFeaturizerConfig | None = None + predictor: PredictorConfig + prediction_mode: PredictionMode = PredictionMode.REGRESSION + + _validate_semantics = model_validator(mode="after")(validate) + + @model_validator(mode="before") + @classmethod + def _inject_single_drug_identity(cls, data: Any) -> Any: + """Fill the routing drug featurizer for per-drug stacks that omit it. + + Runs before Pydantic parses fields, so ``data`` may not be a dict and the predictor slot + may still be written in any of its accepted spellings. A predictor that cannot be read + contributes no routing featurizer, leaving the payload untouched so that normal field + validation reports the error later. + + :param data: Raw constructor / validation payload. + :returns: Payload with the routing drug featurizer injected when applicable. + """ + if not isinstance(data, dict): + return data + payload = dict(data) + if ( + needs_identity_drug_routing(payload.get("predictor")) + and payload.get("cell_line_featurizer") is not None + and payload.get("drug_featurizer") is None + ): + payload["drug_featurizer"] = DrugFeaturizerConfig(name=ROUTING_DRUG_FEATURIZER) + return payload + + @property + def scope(self) -> ModelScope: + """Whether this stack trains one model per drug or one across all drugs. + + Not a choice a config makes: single-drug variants are separate registered predictors, + so the scope follows from the predictor and the two can never disagree. + + :returns: The scope declared by the configured predictor. + """ + return scope(self.predictor.name) + + @property + def model_id(self) -> str | None: + """Stable identifier for a fully specified combination. + + The identifier is a model recipe, so it is written by the same code that reads one. + A half-specified stack has no name: one featurizer without the other cannot be + expressed as a recipe. + + :returns: Colon-separated featurizer and predictor names, or ``None`` when incomplete. + """ + cell = self.cell_line_featurizer + drug = self.drug_featurizer + if cell is None: + return self.predictor.name if drug is None else None + if drug is None: + return None + # Single-drug stacks route per drug through the identity featurizer rather than + # featurizing it, so naming it would suggest a choice the user never made. + omit_drug = self.scope == ModelScope.SINGLE_DRUG and drug.name == ROUTING_DRUG_FEATURIZER + return format_model_recipe(cell.name, None if omit_drug else drug.name, self.predictor.name) + + def cell_line_views(self, *, resolved: ResolvedModelConfig | None = None) -> list[str]: + """Return the raw view names required by the cell-line featurizer tree. + + :param resolved: Optional resolved instance values that can affect view selection. + :returns: View names required by the cell-line featurizer tree. + """ + if self.cell_line_featurizer is None: + return [] + return views_from_featurizer_config(self.cell_line_featurizer, registry="cell_line", resolved=resolved) + + def drug_views(self, *, resolved: ResolvedModelConfig | None = None) -> list[str]: + """Return the raw view names required by the drug featurizer tree. + + :param resolved: Optional resolved instance values that can affect view selection. + :returns: View names required by the drug featurizer tree. + """ + if self.drug_featurizer is None: + return [] + return views_from_featurizer_config(self.drug_featurizer, registry="drug", resolved=resolved) + + def cell_line_entity_id_only(self) -> bool: + """Return ``True`` when the cell-line featurizer only needs entity identifiers. + + :returns: ``True`` when no cell-line omics views are required. + """ + if self.cell_line_featurizer is None: + return False + return entity_id_only_from_featurizer_config(self.cell_line_featurizer, registry="cell_line") + + def drug_entity_id_only(self) -> bool: + """Return ``True`` when the drug featurizer only needs entity identifiers. + + :returns: ``True`` when no drug feature views are required. + """ + if self.drug_featurizer is None: + return False + return entity_id_only_from_featurizer_config(self.drug_featurizer, registry="drug") diff --git a/drevalpy/models/config/predictor.py b/drevalpy/models/config/predictor.py new file mode 100644 index 000000000..14457090d --- /dev/null +++ b/drevalpy/models/config/predictor.py @@ -0,0 +1,65 @@ +"""Declarative predictor configuration schema.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +from drevalpy.components.contracts.hyperparameter_space import validate_hyperparameter_space +from drevalpy.models.config._predictor_parse import normalize_predictor_config +from drevalpy.models.config.immutable import FrozenMapping, thaw_value +from drevalpy.registry.predictor import get as get_predictor + + +class PredictorConfig(BaseModel): + """Immutable template for a predictor in a model stack.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + name: str + hyperparameter_space: FrozenMapping | None = None + + @field_validator("name", mode="before") + @classmethod + def _coerce_name(cls, value: object) -> object: + return str(value) if value is not None else value + + @model_validator(mode="before") + @classmethod + def _normalize_recipe_input(cls, data: object) -> object: + """Rewrite the various ways of writing a predictor into this model's own fields. + + The featurizer counterpart of this hook, for the predictor slot: accepts a bare + recipe string such as ``"elasticNet"`` or a one-key mapping like + ``{"randomForest": {"n_estimators": 10}}``, and reduces each to ``name`` plus + ``hyperparameter_space``. + + :param data: A recipe string or a mapping of fields. + :returns: Canonical field mapping, or *data* unchanged if it is already canonical. + """ + if isinstance(data, str): + return normalize_predictor_config(data) + if isinstance(data, dict) and "name" not in data: + return normalize_predictor_config(data) + return data + + @model_validator(mode="after") + def _validate_hyperparameter_space(self) -> PredictorConfig: + if self.hyperparameter_space is not None: + validate_hyperparameter_space( + self.hyperparameter_space, + context=f"PredictorConfig({self.name!r}).hyperparameter_space", + ) + return self + + def create_instance(self, hyperparameters: Mapping[str, Any] | None = None): + """Instantiate the configured predictor from the registry. + + :param hyperparameters: Concrete constructor values for this predictor. + :returns: Predictor instance for this config. + """ + cls = get_predictor(self.name) + hp = thaw_value(dict(hyperparameters or {})) + return cls(hyperparameters=hp) diff --git a/drevalpy/models/config/resolved.py b/drevalpy/models/config/resolved.py new file mode 100644 index 000000000..33af19233 --- /dev/null +++ b/drevalpy/models/config/resolved.py @@ -0,0 +1,72 @@ +"""Resolved per-instance model configuration with concrete hyperparameter values.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from drevalpy.models.config._hp_key_validation import validate_merged_mapping +from drevalpy.models.config.immutable import FrozenMapping +from drevalpy.models.config.model import ModelConfig + + +class ResolvedModelConfig(BaseModel): + """Immutable instance config: template plus concrete qualified values. + + The ``template`` is the class-level ``ModelConfig``. ``values`` holds + fully resolved concrete hyperparameters keyed by qualified names such as + ``predictor.elasticNet.alpha`` or + ``cell_line_featurizer.pca[expression].n_components``. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + template: ModelConfig + values: FrozenMapping = Field(default_factory=dict, validate_default=True) + + @model_validator(mode="after") + def _validate_values(self) -> ResolvedModelConfig: + validate_merged_mapping(self.template, dict(self.values)) + return self + + @property + def predictor_name(self) -> str: + """Return the template predictor name. + + :returns: Predictor registry name. + """ + return self.template.predictor.name + + def predictor_values(self) -> dict[str, Any]: + """Return concrete local predictor hyperparameters. + + :returns: Mapping of predictor-local parameter names to values. + """ + prefix = f"predictor.{self.template.predictor.name}." + return {key.removeprefix(prefix): value for key, value in self.values.items() if key.startswith(prefix)} + + def featurizer_values(self, registry: str, selector: str) -> dict[str, Any]: + """Return concrete local hyperparameters for one featurizer leaf. + + :param registry: ``cell_line`` or ``drug``. + :param selector: Qualified featurizer selector (for example ``pca[expression]``). + :returns: Mapping of featurizer-local parameter names to values. + """ + slot = "cell_line_featurizer" if registry == "cell_line" else "drug_featurizer" + prefix = f"{slot}.{selector}." + return {key.removeprefix(prefix): value for key, value in self.values.items() if key.startswith(prefix)} + + def cell_line_views(self) -> list[str]: + """Return the raw view names required by the cell-line featurizer tree. + + :returns: View names required by the cell-line featurizer tree. + """ + return self.template.cell_line_views(resolved=self) + + def drug_views(self) -> list[str]: + """Return the raw view names required by the drug featurizer tree. + + :returns: View names required by the drug featurizer tree. + """ + return self.template.drug_views(resolved=self) diff --git a/drevalpy/models/config/validation.py b/drevalpy/models/config/validation.py new file mode 100644 index 000000000..b55afcb36 --- /dev/null +++ b/drevalpy/models/config/validation.py @@ -0,0 +1,155 @@ +"""Validation logic for `~drevalpy.models.config.ModelConfig`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from drevalpy.components.contracts.contracts import contracts_compatible, featurizer_contract, predictor_contracts +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.components.predictors.single_drug_routing import ROUTING_DRUG_FEATURIZER +from drevalpy.models.config._block_specs import resolve_output_block_specs +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.enums.model_scope import ModelScope + +if TYPE_CHECKING: + from drevalpy.models.config.model import ModelConfig + + +def _validate_prediction_mode(config: ModelConfig, pred_cls: type[Any]) -> None: + supported = getattr(pred_cls, "supported_modes", None) + if supported is not None and config.prediction_mode not in supported: + msg = ( + f"Predictor {config.predictor.name!r} does not support " + f"prediction_mode={config.prediction_mode!r}; " + f"supported_modes={sorted(supported)}" + ) + raise ValueError(msg) + + +def _validate_feature_free_config(config: ModelConfig) -> None: + if config.cell_line_featurizer is not None or config.drug_featurizer is not None: + msg = f"Predictor {config.predictor.name!r} is a feature-free predictor and forbids configured featurizers" + raise ValueError(msg) + + +def _validate_featurizer_presence(config: ModelConfig) -> None: + if config.cell_line_featurizer is None and config.drug_featurizer is None: + msg = ( + f"Predictor {config.predictor.name!r} requires featurizers; " + "set cell_line_featurizer and drug_featurizer, or use a feature-free predictor." + ) + raise ValueError(msg) + if config.drug_featurizer is None: + msg = f"Predictor {config.predictor.name!r} requires a drug_featurizer" + raise ValueError(msg) + if config.cell_line_featurizer is None: + msg = "cell_line_featurizer must be set for feature-based predictors" + raise ValueError(msg) + + +def _validate_single_drug_pairing(config: ModelConfig, pred_cls: type[Any]) -> None: + if pred_cls.scope != ModelScope.SINGLE_DRUG: + return + if config.drug_featurizer is None or config.drug_featurizer.name != ROUTING_DRUG_FEATURIZER: + msg = ( + f"Feature-based single-drug predictor {config.predictor.name!r} requires " + f"drug_featurizer={ROUTING_DRUG_FEATURIZER!r} for per-drug routing" + ) + raise ValueError(msg) + + +def _validate_featurizer_contracts(config: ModelConfig, pred_cls: type[Any]) -> None: + required_cell_line, required_drug = predictor_contracts(pred_cls) + sides = ( + ( + "cell_line", + config.cell_line_featurizer, + required_cell_line, + get_cell_line_featurizer, + "Cell line", + ), + ( + "drug", + config.drug_featurizer, + required_drug, + get_drug_featurizer, + "Drug", + ), + ) + for side, featurizer, required, getter, label in sides: + if featurizer is None: + continue + contract = featurizer_contract(getter(featurizer.name)) + if not contracts_compatible(contract, required): + msg = ( + f"{label} featurizer contract {contract!r} is incompatible with predictor {side}_contract {required!r}" + ) + raise ValueError(msg) + + +def _validate_required_block_specs( + predictor_name: str, + side: str, + emitted: tuple[BlockSpec, ...], + required: tuple[BlockSpec, ...], +) -> None: + actual = ", ".join(f"{spec.name}:{spec.format.value}" for spec in emitted) or "" + for expected in required: + actual_spec = next((spec for spec in emitted if spec.name == expected.name), None) + if actual_spec is None or actual_spec.format != expected.format: + actual_format = actual_spec.format.value if actual_spec is not None else "" + raise ValueError( + f"Predictor {predictor_name!r} {side} block schema mismatch: missing block " + f"{expected.name!r}; expected format={expected.format.value!r}, " + f"actual format={actual_format!r}; emitted blocks=[{actual}]" + ) + + +def _validate_block_schema(config: ModelConfig, pred_cls: type[Any]) -> None: + if pred_cls.input_interface != "block": + return + for side, featurizer in ( + ("cell_line", config.cell_line_featurizer), + ("drug", config.drug_featurizer), + ): + if featurizer is None: + continue + emitted = resolve_output_block_specs(featurizer) + required = tuple(getattr(pred_cls, f"required_{side}_block_specs", ())) + _validate_required_block_specs(config.predictor.name, side, emitted, required) + alternatives = tuple(getattr(pred_cls, f"required_{side}_block_alternatives", ())) + if alternatives and not any( + actual.name == option.name and actual.format == option.format + for actual in emitted + for option in alternatives + ): + expected = " or ".join(f"{item.name}:{item.format.value}" for item in alternatives) + actual = ", ".join(f"{item.name}:{item.format.value}" for item in emitted) or "" + raise ValueError( + f"Predictor {config.predictor.name!r} {side} block schema mismatch: expected one of " + f"[{expected}], actual emitted blocks=[{actual}]" + ) + + +def validate(config: ModelConfig) -> ModelConfig: + """Check registry slots, feature compatibility, and prediction mode. + + :param config: Model configuration to validate. + :returns: The unchanged *config*, so this doubles as a Pydantic ``after`` validator. + """ + try: + pred_cls = get_predictor(config.predictor.name) + except ImportError: + return config + _validate_prediction_mode(config, pred_cls) + if issubclass(pred_cls, FeatureFreePredictor): + _validate_feature_free_config(config) + return config + _validate_featurizer_presence(config) + _validate_single_drug_pairing(config, pred_cls) + _validate_featurizer_contracts(config, pred_cls) + _validate_block_schema(config, pred_cls) + return config diff --git a/drevalpy/models/config/view_resolution.py b/drevalpy/models/config/view_resolution.py new file mode 100644 index 000000000..31193c218 --- /dev/null +++ b/drevalpy/models/config/view_resolution.py @@ -0,0 +1,66 @@ +"""Resolve view names from featurizer configs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from drevalpy.components.featurizers._leaf_kwargs import featurizer_leaf_kwargs +from drevalpy.models.config.featurizer import FeaturizerConfig +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer + +if TYPE_CHECKING: + from drevalpy.models.config.resolved import ResolvedModelConfig + + +def _featurizer_cls(config: FeaturizerConfig, *, registry: str) -> type[Any]: + if registry == "cell_line": + return get_cell_line_featurizer(config.name) + return get_drug_featurizer(config.name) + + +def entity_id_only_from_featurizer_config(config: FeaturizerConfig, *, registry: str) -> bool: + """Return True when the featurizer only needs entity identifiers, not omics or drug views. + + :param config: Featurizer config node to inspect. + :param registry: ``cell_line`` or ``drug`` registry label. + :returns: ``True`` when the featurizer tree is entity-id-only. + """ + if config.name == "concatFeaturizers": + children = config.featurizers or () + if not children: + return False + return all(entity_id_only_from_featurizer_config(child, registry=registry) for child in children) + return bool(getattr(_featurizer_cls(config, registry=registry), "entity_id_only", False)) + + +def _concat_child_views( + config: FeaturizerConfig, + *, + registry: Literal["cell_line", "drug"], + resolved: ResolvedModelConfig | None, +) -> list[str]: + views: list[str] = [] + for child in config.featurizers or (): + views.extend(views_from_featurizer_config(child, registry=registry, resolved=resolved)) + return views + + +def views_from_featurizer_config( + config: FeaturizerConfig, + *, + registry: Literal["cell_line", "drug"], + resolved: ResolvedModelConfig | None = None, +) -> list[str]: + """Ask each featurizer in the tree which raw views it reads. + + :param config: Featurizer config node, possibly a concat parent. + :param registry: ``cell_line`` or ``drug`` registry label. + :param resolved: Optional resolved values that can affect view selection. + :returns: Raw view names required by the featurizer tree. + """ + if config.name == "concatFeaturizers": + return _concat_child_views(config, registry=registry, resolved=resolved) + cls = _featurizer_cls(config, registry=registry) + kwargs = featurizer_leaf_kwargs(config, registry=registry, resolved=resolved) + return list(cls.resolve_input_views(**kwargs)) diff --git a/drevalpy/models/construct.py b/drevalpy/models/construct.py new file mode 100644 index 000000000..0060c5d6c --- /dev/null +++ b/drevalpy/models/construct.py @@ -0,0 +1,61 @@ +"""Public API for constructing DRPModel classes from modular specs.""" + +from __future__ import annotations + +import functools +import json +from typing import Any + +from drevalpy.models.config import ModelConfig, from_spec +from drevalpy.models.config.resolved import ResolvedModelConfig +from drevalpy.models.drp_model import DRPModel + + +def _as_template(config: ModelConfig | ResolvedModelConfig) -> ModelConfig: + if isinstance(config, ResolvedModelConfig): + return config.template + return config + + +def _resolve_base_config(name: str, spec: str | ModelConfig | ResolvedModelConfig | None) -> ModelConfig: + if isinstance(spec, ResolvedModelConfig): + config = ModelConfig.model_validate(spec.template.model_dump(mode="python")) + elif isinstance(spec, ModelConfig): + config = ModelConfig.model_validate(spec.model_dump(mode="python")) + else: + config = from_spec(spec or name) + return _as_template(config) + + +@functools.cache +def _generate_model_class(name: str, config_json: str) -> type[DRPModel]: + config = ModelConfig.model_validate_json(config_json) + attrs: dict[str, Any] = { + "_model_name": name, + "_base_model_config": config, + } + cls = type(name, (DRPModel,), attrs) + cls.__module__ = "drevalpy.models" + return cls + + +def construct_model(name: str, spec: str | ModelConfig | ResolvedModelConfig | None = None) -> type[DRPModel]: + """Return a ``DRPModel`` subclass for a zoo name, recipe, or ``ModelConfig``. + + Call forms: + + - ``construct_model("ElasticNet")`` — resolve a built-in or external zoo preset + - ``construct_model("MyModel", "scaledGeneExpression:fingerprints:elasticNet")`` — + build a custom class with ``get_model_name() == "MyModel"`` + - ``construct_model("MyModel", config)`` — same with an already-built ``ModelConfig`` + + The returned class is a thin metadata-only subclass of the concrete ``DRPModel``. + Instantiating it with optional flat hyperparameters creates a fresh runtime instance. + + :param name: Model identity for the generated class, or a built-in zoo preset name when ``spec`` is omitted. + :param spec: Optional recipe string, ``ModelConfig``, or ``None`` to resolve ``name`` from the zoo. + :returns: Generated ``DRPModel`` subclass bound to the resolved config. + """ + config = _resolve_base_config(name, spec) + config_json = json.dumps(config.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + return _generate_model_class(name, config_json) diff --git a/drevalpy/models/drp_model.py b/drevalpy/models/drp_model.py index 7599be2e9..4429f1fcf 100644 --- a/drevalpy/models/drp_model.py +++ b/drevalpy/models/drp_model.py @@ -1,572 +1,197 @@ -""" -Contains the DRPModel class. - -The DRPModel class is an abstract wrapper class for drug response prediction models. - - -""" - -import inspect -import os -from abc import ABC, abstractmethod -from contextlib import suppress -from typing import Any - -import numpy as np -import wandb -import yaml -from sklearn.model_selection import ParameterGrid - -from ..datasets.dataset import DrugResponseDataset, FeatureDataset -from ..evaluation import AVAILABLE_METRICS, evaluate -from ..pipeline_function import pipeline_function - - -class DRPModel(ABC): - """ - Abstract wrapper class for drug response prediction models. - - The DRPModel class is an abstract wrapper class for drug response prediction models. - It has a boolean attribute is_single_drug_model indicating whether it is a single drug model and a boolean - attribute early_stopping indicating whether early stopping is used. +"""Concrete config-backed drug response prediction model.""" + +from __future__ import annotations + +import copy +from typing import Any, ClassVar + +from drevalpy.models.component_stack import _ComponentStack, build_component_stack +from drevalpy.models.config import ModelConfig, ModelScope +from drevalpy.models.config.resolved import ResolvedModelConfig +from drevalpy.models.mixins._feature_matrix import DRPFeatureMatrixMixin +from drevalpy.models.mixins._hyperparameters import DRPHyperparametersMixin +from drevalpy.models.mixins._logging import _DRPLoggingMixin, _wandb +from drevalpy.models.mixins._persistence import DRPPersistenceMixin +from drevalpy.models.mixins._training import DRPTrainingMixin +from drevalpy.models.tuning.config_resolution import default_config_for_drp_model +from drevalpy.models.tuning.public_flat import config_from_public_hyperparameters, public_hyperparameters_from_config +from drevalpy.models.tuning.search_space import resolve_model_config +from drevalpy.registry.predictor import get as get_predictor + + +class DRPModel( + DRPHyperparametersMixin, + _DRPLoggingMixin, + DRPPersistenceMixin, + DRPTrainingMixin, + DRPFeatureMatrixMixin, +): + """Experiment-facing drug response model backed by a resolved config. + + Subclasses are generated by ``construct_model``; do not + subclass directly. Typical lifecycle: instantiate, load features, ``train``, + ``predict``, then ``save`` / ``load``. + + What is left in this class is the config and identity surface - which + ``ModelConfig`` the class was built from, what the resolved instance made of + it, and which views it therefore requires. The behaviour hangs off the mixins: + ``train`` / ``predict`` in ``mixins/_training.py``, checkpoint I/O in + ``mixins/_persistence.py``, feature-matrix assembly in + ``mixins/_feature_matrix.py``, wandb in ``mixins/_logging.py`` and + hyperparameter resolution in ``mixins/_hyperparameters.py``. """ - # Used in the pipeline! - early_stopping = False - # Then, the model is trained per drug - is_single_drug_model = False + _model_name: ClassVar[str] = "DRPModel" + _base_model_config: ClassVar[ModelConfig | None] = None - def __init__(self): - """Initialize the DRPModel instance.""" - self.wandb_project: str | None = None - self.wandb_run: Any = None - self.wandb_config: dict[str, Any] | None = None - self.hyperparameters: dict[str, Any] = {} - self._in_hyperparameter_tuning: bool = False # Flag to track if we're in hyperparameter tuning - - def init_wandb( - self, - project: str, - config: dict[str, Any] | None = None, - name: str | None = None, - tags: list[str] | None = None, - finish_previous: bool = True, - ) -> None: - """ - Initialize wandb logging for this model instance. + def __init__(self, hyperparameters: dict[str, Any] | None = None) -> None: + """Materialize a fresh component stack from class defaults or flat overrides. - :param project: wandb project name - :param config: dictionary of configuration to log (e.g., hyperparameters, dataset info) - :param name: run name (defaults to model name) - :param tags: list of tags for the run - :param finish_previous: whether to finish any existing wandb run before starting a new one + :param hyperparameters: Optional flat public hyperparameter overrides. ``None`` uses the class default config. + :raises ValueError: If ``hyperparameters`` cannot be applied to this model class. """ - self.wandb_project = project - self.wandb_config = config or {} - - if finish_previous: - wandb.finish() - - run_name = name or self.get_model_name() - wandb.init( - project=project, - config=self.wandb_config, - name=run_name, - tags=tags, - ) - self.wandb_run = wandb.run - - # Define common metric summaries so final/best values are tracked automatically - with suppress(Exception): # pragma: no cover - wandb may not support define_metric in all contexts - wandb.define_metric("epoch", summary="max") - wandb.define_metric("train_loss", summary="min") - wandb.define_metric("val_loss", summary="min") - wandb.define_metric("train_R^2", summary="max") - wandb.define_metric("val_R^2", summary="max") - wandb.define_metric("train_Pearson", summary="max") - wandb.define_metric("val_Pearson", summary="max") + self._init_runtime_fields() - def log_hyperparameters(self, hyperparameters: dict[str, Any]) -> None: - """ - Log hyperparameters to wandb. - - This method is called automatically by build_model when wandb is enabled. - Subclasses can override this to add additional hyperparameter logging. - - During hyperparameter tuning, config updates are skipped to avoid overwriting. - Only the final best hyperparameters are logged to wandb.config. - - :param hyperparameters: dictionary of hyperparameters to log - """ - if not self.is_wandb_enabled(): + if hyperparameters is None: + config = default_config_for_drp_model(type(self)) + if config is None: + self._apply_model_config(resolve_model_config(self.model_config())) + else: + self._apply_model_config(config) return + config = config_from_public_hyperparameters(type(self), hyperparameters) + if config is None: + msg = f"Cannot apply hyperparameters for model {self.get_model_name()!r}" + raise ValueError(msg) + self._apply_model_config(config) - self.hyperparameters = hyperparameters - # Only update wandb.config if we're not in hyperparameter tuning phase - # During tuning, trial hyperparameters are stored in config.hyperparameters - # Nest hyperparameters under a single key to prevent them from appearing as separate table columns - if not self._in_hyperparameter_tuning: - wandb.config.update({"hyperparameters": hyperparameters}) - - def is_wandb_enabled(self) -> bool: - """ - Check if wandb logging is enabled for this model instance. - - :returns: True if wandb is initialized and active, False otherwise - """ - # Check both self.wandb_run and wandb.run to handle cases where - # PyTorch Lightning's WandbLogger might have affected the run state - return self.wandb_project is not None and (self.wandb_run is not None or wandb.run is not None) - - def get_wandb_logger(self) -> Any | None: - """ - Get a WandbLogger for PyTorch Lightning integration. - - This method creates a WandbLogger that uses the existing wandb run. - Returns None if wandb is not enabled. - - :returns: WandbLogger instance or None - """ - if not self.is_wandb_enabled() or self.wandb_project is None: - return None - - from pytorch_lightning.loggers import WandbLogger - - return WandbLogger(project=self.wandb_project, log_model=False) - - def log_metrics(self, metrics: dict[str, float], step: int | None = None) -> None: - """ - Log metrics to wandb. - - Subclasses can call this method to log custom metrics during training. - - :param metrics: dictionary of metric names to values - :param step: optional step number for the metrics - """ - if not self.is_wandb_enabled(): - return + def _init_runtime_fields(self) -> None: + self.wandb_project: str | None = None + self.wandb_run: Any = None + self.wandb_config: dict[str, Any] | None = None + self._in_hyperparameter_tuning = False + self._stack: _ComponentStack | None = None + self._empty_training = False + self._hyperparameters: dict[str, Any] = {} + self._resolved_model_config: ResolvedModelConfig | None = None - if step is not None: - wandb.log(metrics, step=step) - else: - wandb.log(metrics) + @classmethod + def _unmaterialized(cls) -> DRPModel: + """Return an empty instance without materializing a default stack. - def compute_performance_metrics( - self, predictions: np.ndarray, targets: np.ndarray, prefix: str = "" - ) -> dict[str, float]: + :returns: Runtime instance with uninitialized stack fields. """ - Compute R^2 and PCC metrics from predictions and targets. + instance = object.__new__(cls) + instance._init_runtime_fields() + return instance - This is a convenience method for computing performance metrics consistently - across all models. It always computes R^2 and PCC in addition to any other - metrics that may be needed. + @classmethod + def _from_resolved_config(cls, config: ModelConfig | ResolvedModelConfig) -> DRPModel: + """Construct an instance from a template or already-resolved config. - :param predictions: model predictions array - :param targets: ground truth targets array - :param prefix: optional prefix for metric keys (e.g., ``val_``, ``train_``) - :returns: dictionary of computed metrics with optional prefix + :param config: Template or resolved model configuration to bind. + :returns: Instance with stack materialized from ``config``. """ - try: - # Always compute R^2 and PCC - metrics = { - "R^2": AVAILABLE_METRICS["R^2"](y_pred=predictions, y_true=targets), - "Pearson": AVAILABLE_METRICS["Pearson"](y_pred=predictions, y_true=targets), - } - - # Add prefix if provided - if prefix: - metrics = {f"{prefix}{k}": v for k, v in metrics.items()} - - return metrics - except Exception: - # Return empty dict if computation fails - return {} - - def compute_and_log_final_metrics( - self, - dataset: DrugResponseDataset, - additional_metrics: list[str] | None = None, - prefix: str = "val_", - ) -> dict[str, float]: - r""" - Compute final performance metrics from a dataset and log them to wandb. - - This method computes R^2 and PCC (always), plus any additional metrics specified. - The metrics are both logged to wandb history and stored in the run summary. - - :param dataset: DrugResponseDataset with predictions and response - :param additional_metrics: optional list of additional metrics to compute (e.g., ["RMSE", "MAE"]) - :param prefix: metric name prefix indicating which split the metrics belong to - (for example, use ``"val"`` for validation and ``"test"`` for test metrics) - :returns: dictionary of computed metrics - """ - if dataset.predictions is None: - return {} - - # Always compute R^2 and PCC - metrics_to_compute = ["R^2", "Pearson"] - if additional_metrics: - metrics_to_compute.extend(additional_metrics) - - results = evaluate(dataset, metric=metrics_to_compute) + instance = cls._unmaterialized() + resolved = config if isinstance(config, ResolvedModelConfig) else resolve_model_config(config) + instance._apply_model_config(resolved) + return instance - # Log to wandb if enabled - # Check both is_wandb_enabled() and wandb.run to ensure the run is active - if self.is_wandb_enabled() and wandb.run is not None: - # Prefix indicates which split the metrics belong to (e.g. \"val\" or \"test\") - wandb_metrics = {f"{prefix}{k}": v for k, v in results.items()} - # Log to summary only (not history) since these are final metrics logged once - self.log_final_metrics(wandb_metrics) - - return results + @classmethod + def get_model_name(cls) -> str: + """Return the model identity for this class. - def log_final_metrics(self, metrics: dict[str, float]) -> None: + :returns: Model name bound to the generated class. """ - Store final metrics in the wandb run summary. + return cls._model_name - This method is used to record final metrics (e.g., after validation - or after a hyperparameter trial). Metrics are stored with their original - names (e.g., val_RMSE, test_RMSE) without additional prefixes. + @classmethod + def model_config(cls) -> ModelConfig: + """Return a defensive deep copy of the class base config. - :param metrics: dictionary of metric names to values + :returns: Deep copy of the ``ModelConfig`` bound to this class. + :raises RuntimeError: If the class was not produced by ``construct_model``. """ - if not self.is_wandb_enabled(): - return - - # Ensure wandb.run is active before logging - if wandb.run is None: - return - - for key, value in metrics.items(): - # Store metrics directly without adding "final_" prefix - # The prefix (val_ or test_) already indicates the split - wandb.run.summary[key] = value - - def finish_wandb(self) -> None: - """Finish the wandb run. Call this when training is complete.""" - if not self.is_wandb_enabled(): - return - - wandb.finish() - self.wandb_run = None + if cls._base_model_config is None: + msg = f"{cls.__name__} has no base ModelConfig; use construct_model(...)" + raise RuntimeError(msg) + return ModelConfig.model_validate(cls._base_model_config.model_dump(mode="python")) @classmethod - @abstractmethod - @pipeline_function - def get_model_name(cls) -> str: - """ - Returns the name of the model. + def supports_early_stopping(cls) -> bool: + """Return whether the configured predictor supports early stopping. - :return: model name + :returns: ``True`` when the predictor exposes early-stopping support. """ + predictor_class = get_predictor(cls.model_config().predictor.name) + return bool(getattr(predictor_class, "supports_early_stopping", False)) @classmethod - @pipeline_function - def get_hyperparameter_set(cls) -> list[dict[str, Any]]: - """ - Loads the hyperparameters from a yaml file which is located in the same directory as the model. + def is_single_drug(cls) -> bool: + """Return whether this model is scoped to a single drug. - :returns: list of hyperparameter sets - :raises ValueError: if the hyperparameters are not in the correct format - :raises KeyError: if the model is not found in the hyperparameters file + :returns: ``True`` when the model scope is single-drug. """ - hyperparameter_file = os.path.join(os.path.dirname(inspect.getfile(cls)), "hyperparameters.yaml") - - with open(hyperparameter_file, encoding="utf-8") as f: - try: - hpams = yaml.safe_load(f)[cls.get_model_name()] - except yaml.YAMLError as exc: - raise ValueError(f"Error in hyperparameters.yaml: {exc}") from exc - except KeyError as key_exc: - raise KeyError(f"Model {cls.get_model_name()} not found in hyperparameters.yaml") from key_exc - - if hpams is None: - return [{}] - # each param should be a list - for hp in hpams: - if not isinstance(hpams[hp], list): - hpams[hp] = [hpams[hp]] - grid = list(ParameterGrid(hpams)) - return grid + return cls.model_config().scope == ModelScope.SINGLE_DRUG @property - @abstractmethod - def cell_line_views(self) -> list[str]: - """ - Returns the sources the model needs as input for describing the cell line. + def hyperparameters(self) -> dict[str, Any]: + """Return a defensive copy of the instance hyperparameters. - :return: cell line views, e.g., ["methylation", "gene_expression", "mirna_expression", - "mutation"]. If the model does not use cell line features, return an empty list. + :returns: Copy of flat public hyperparameters for this instance. """ + return copy.deepcopy(self._hyperparameters) @property - @abstractmethod - def drug_views(self) -> list[str]: - """ - Returns the sources the model needs as input for describing the drug. - - :return: drug views, e.g., ["descriptors", "fingerprints", "targets"]. If the model does not use drug features, - return an empty list. - """ - - @abstractmethod - def build_model(self, hyperparameters: dict[str, Any]) -> None: - """ - Builds the model, for models that use hyperparameters. - - Subclasses should call self.log_hyperparameters(hyperparameters) at the beginning - of this method to ensure hyperparameters are logged to wandb if enabled. - - :param hyperparameters: hyperparameters for the model - - Example:: - - def build_model(self, hyperparameters: dict[str, Any]) -> None: - self.log_hyperparameters(hyperparameters) # Log to wandb - self.model = ElasticNet(alpha=hyperparameters["alpha"], l1_ratio=hyperparameters["l1_ratio"]) - """ - - @pipeline_function - @abstractmethod - def train( - self, - output: DrugResponseDataset, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - output_earlystopping: DrugResponseDataset | None = None, - model_checkpoint_dir: str = "checkpoints", - ) -> None: - """ - Trains the model. - - :param output: training data associated with the response output - :param cell_line_input: input associated with the cell line, required for all models - :param drug_input: input associated with the drug, optional because single drug models do not use drug features - :param output_earlystopping: optional early stopping dataset - :param model_checkpoint_dir: directory to save the model checkpoints - """ + def early_stopping(self) -> bool: + """Instance convenience accessor for early-stopping support. - @abstractmethod - def predict( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset, - drug_input: FeatureDataset | None = None, - ) -> np.ndarray: + :returns: ``True`` when the configured predictor supports early stopping. """ - Predicts the response for the given input. - - :param drug_ids: list of drug ids, also used for single drug models, there it is just an array containing the - same drug id - :param cell_line_ids: list of cell line ids - :param cell_line_input: input associated with the cell line, required for all models - :param drug_input: input associated with the drug, optional because single drug models do not use drug features - :returns: predicted response - """ - - @pipeline_function - @abstractmethod - def load_cell_line_features(self, data_path: str, dataset_name: str) -> FeatureDataset: - """ - Load the cell line features before the train/predict method is called. - - Required to implement for all models. Could, e.g., call get_multiomics_feature_dataset() or - load_and_select_gene_features() from models/utils.py. + return type(self).supports_early_stopping() - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., "GDSC2" - :returns: FeatureDataset with the cell line features - """ + @property + def is_single_drug_model(self) -> bool: + """Instance convenience accessor for single-drug scope. - @pipeline_function - @abstractmethod - def load_drug_features(self, data_path: str, dataset_name: str) -> FeatureDataset | None: + :returns: ``True`` when this model is scoped to a single drug. """ - Load the drug features before the train/predict method is called. - - Required to implement for all models that use drug features. Could, e.g., - call load_drug_fingerprint_features() or load_drug_ids_from_csv() from models/utils.py. + return type(self).is_single_drug() - For single drug models, this method can return None. - - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., "GDSC2" - :returns: FeatureDataset or None - """ + @property + def cell_line_views(self) -> list[str]: + """Return required cell-line views derived from the resolved config. - @pipeline_function - def save(self, directory: str) -> None: + :returns: Cell-line view names required by the model config. """ - Save the model, including trainable parameters, hyperparameters, scalars, encoders. + if self._resolved_model_config is not None: + return self._resolved_model_config.cell_line_views() + return self.model_config().cell_line_views() - This method should serialize all necessary components to allow - full reconstruction of the model later via the `load` method. - - Only needs to be implemented for the DrEval evaluation framework, if a final production model should be saved. - - :param directory: Target directory where the model and metadata should be saved - :raises NotImplementedError: if the method is not implemented by the subclass - """ - raise NotImplementedError(f"{self.get_model_name()} does not implement model saving.") + @property + def drug_views(self) -> list[str]: + """Return required drug views derived from the resolved config. - @classmethod - def load(cls, directory: str) -> "DRPModel": + :returns: Drug view names required by the model config. """ - Load a model, including trainable parameters, hyperparameters, scalars, encoders. - - This method should fully reconstruct an instance of the model using - the files in the specified directory. - - Only needs to be implemented for the DrEval evaluation framework, if a final production model should be saved. + if self._resolved_model_config is not None: + return self._resolved_model_config.drug_views() + return self.model_config().drug_views() + def log_hyperparameters(self, hyperparameters: dict[str, Any]) -> None: + """Store a copy of hyperparameters and optionally log them to wandb. - :param directory: Source directory containing the saved model files - :raises NotImplementedError: if the method is not implemented by the subclass - """ - raise NotImplementedError(f"{cls.get_model_name()} does not implement model loading.") - - def get_concatenated_features( - self, - cell_line_view: str | None, - drug_view: str | None, - cell_line_ids_output: np.ndarray, - drug_ids_output: np.ndarray, - cell_line_input: FeatureDataset | None, - drug_input: FeatureDataset | None, - ) -> np.ndarray: + :param hyperparameters: Flat public hyperparameters for this instance. """ - Concatenates the features to an input matrix X for the given cell line and drug views. - - :param cell_line_view: gene expression, methylation, etc. - :param drug_view: ids, fingerprints, etc. - :param cell_line_ids_output: cell line ids - :param drug_ids_output: drug ids - :param cell_line_input: input associated with the cell line - :param drug_input: input associated with the drug - :returns: X, the feature matrix needed for, e.g., sklearn models - :raises ValueError: if no features are provided - - This can, e.g., be done in the training method to produce a large input feature matrix for the model where - the rows are the samples and the columns are the cell line and drug features concatenated. This method is an - alternative to using DataLoaders. It is used for models operating on the whole input matrix at once. - - Example:: - - x = self.get_concatenated_features( - cell_line_view="gene_expression", - drug_view="fingerprints", - cell_line_ids_output=output.cell_line_ids, - drug_ids_output=output.drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - self.model.fit(x, output.response) - """ - inputs = self.get_feature_matrices( - cell_line_ids=cell_line_ids_output, - drug_ids=drug_ids_output, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - if drug_view is not None: - if drug_view not in inputs: - raise ValueError(f"Expected drug_view '{drug_view}' to be in inputs, but it was not. Inputs: {inputs}") - if cell_line_view is not None: - if cell_line_view not in inputs: - raise ValueError( - f"Expected cell_line_view '{cell_line_view}' to be in inputs, but it was not. Inputs: {inputs}" - ) - - cell_line_features = None if cell_line_view is None else inputs.get(cell_line_view) - drug_features = None if drug_view is None else inputs.get(drug_view) - - if cell_line_features is not None and drug_features is not None: - x = np.concatenate((cell_line_features, drug_features), axis=1) - elif cell_line_features is not None: - x = cell_line_features - elif drug_features is not None: - x = drug_features - else: - raise ValueError("No features provided.") - return x - - def get_feature_matrices( - self, - cell_line_ids: np.ndarray, - drug_ids: np.ndarray, - cell_line_input: FeatureDataset | None, - drug_input: FeatureDataset | None, - ) -> dict[str, np.ndarray]: - """ - Returns the feature matrices for the given cell line and drug ids by retrieving the correct views. - - :param cell_line_ids: cell line identifiers - :param drug_ids: drug identifiers - :param cell_line_input: cell line omics features - :param drug_input: drug omics features - :returns: dictionary with the feature matrices - :raises ValueError: if the input does not contain the correct views - - This can e.g., done to produce the input for the predict() method for deep learning models: - Example:: - - input_data = self.get_feature_matrices( - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - ( - gene_expression, - mutations, - cnvs - ) = ( - input_data["gene_expression"], - input_data["mutations"], - input_data["copy_number_variation_gistic"] - ) - return self.model.predict(gene_expression, mutations, cnvs) - - Or to produce separate inputs for the train()/predict() method for other models if the model does not operate - on the concatenated input matrix:: - - inputs = self.get_feature_matrices( - cell_line_ids=output.cell_line_ids, - drug_ids=output.drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - ( - gene_expression, - methylation, - mutations, - copy_number_variation_gistic, - fingerprints, - ) = ( - inputs["gene_expression"], - inputs["methylation"], - inputs["mutations"], - inputs["copy_number_variation_gistic"], - inputs["fingerprints"], - ) - self.model.fit( - gene_expression, methylation, mutations, copy_number_variation_gistic, fingerprints, output.response - ) - """ - cell_line_feature_matrices = {} - if cell_line_input is not None: - for cell_line_view in self.cell_line_views: - if cell_line_view not in cell_line_input.view_names: - raise ValueError(f"Cell line input does not contain view {cell_line_view}") - cell_line_feature_matrices[cell_line_view] = cell_line_input.get_feature_matrix( - view=cell_line_view, identifiers=cell_line_ids - ) - drug_feature_matrices = {} - if drug_input is not None: - for drug_view in self.drug_views: - if drug_view not in drug_input.view_names: - raise ValueError(f"Drug input does not contain view {drug_view}") - drug_feature_matrices[drug_view] = drug_input.get_feature_matrix(view=drug_view, identifiers=drug_ids) - - return {**cell_line_feature_matrices, **drug_feature_matrices} + self._hyperparameters = copy.deepcopy(hyperparameters) + if not self.is_wandb_enabled(): + return + if not self._in_hyperparameter_tuning: + _wandb().config.update({"hyperparameters": self._hyperparameters}) + + def _apply_model_config(self, config: ModelConfig | ResolvedModelConfig) -> None: + resolved = config if isinstance(config, ResolvedModelConfig) else resolve_model_config(config) + self._resolved_model_config = ResolvedModelConfig.model_validate(resolved.model_dump(mode="python")) + self.log_hyperparameters(public_hyperparameters_from_config(self._resolved_model_config)) + self._stack = build_component_stack(self._resolved_model_config) + self._empty_training = False diff --git a/drevalpy/models/factory.py b/drevalpy/models/factory.py new file mode 100644 index 000000000..24551812f --- /dev/null +++ b/drevalpy/models/factory.py @@ -0,0 +1,106 @@ +"""Resolve zoo/spec names to `~drevalpy.models.config.ModelConfig` objects. + +Also contains the typo guard and hyperparameter-application helper that +``drevalpy.models.config.io.from_spec`` composes around recipe parsing. +""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.models.config.model import ModelConfig +from drevalpy.models.config.resolved import ResolvedModelConfig +from drevalpy.registry._builtins import is_known_builtin_predictor +from drevalpy.registry.predictor import list as list_predictors +from drevalpy.types.enums.prediction_mode import PredictionMode + + +def model_config_for_name( + model_name: str, + hyperparameters: dict[str, Any] | None = None, + *, + prediction_mode: PredictionMode | None = None, +) -> ModelConfig | ResolvedModelConfig: + """Resolve a factory/zoo name to a modular config with public flat HP applied. + + :param model_name: Built-in or external zoo preset name. + :param hyperparameters: Optional flat public hyperparameter overrides. + :param prediction_mode: Optional prediction mode overriding the preset's own value. + :returns: Template ``ModelConfig``, or ``ResolvedModelConfig`` when overrides are given. + :raises KeyError: If ``model_name`` is not a known zoo entry. + """ + from drevalpy.models.zoo import list_zoo_names, zoo_model_config + + if model_name not in list_zoo_names(include_external=True): + msg = f"Unknown model name: {model_name}" + raise KeyError(msg) + return zoo_model_config(model_name, hyperparameters, prediction_mode=prediction_mode) + + +def apply_optional_hyperparameters( + config: ModelConfig, + hyperparameters: dict[str, Any] | None, +) -> ModelConfig | ResolvedModelConfig: + """Apply public hyperparameters by returning a resolved config when needed. + + Recipe builders historically returned ``ModelConfig``. When hyperparameters + are provided, return the resolved object so callers that only need a template + without overrides still receive ``ModelConfig``, while override paths receive + ``ResolvedModelConfig`` via the public-flat helper. + + :param config: Template config. + :param hyperparameters: Optional public overrides. + :returns: Template or resolved config. + """ + if not hyperparameters: + return config + from drevalpy.models.tuning.public_flat import apply_public_hyperparameters_to_config + + return apply_public_hyperparameters_to_config(config, hyperparameters) + + +def reject_unknown_spec(token: str) -> None: + """Report a bare token that names neither a zoo preset nor a predictor drevalpy knows. + + Such a token is most likely a mistyped zoo name, so it is reported in terms of both + options rather than as a predictor-shaped config error. A name in the built-in catalog or + in the registry is passed through instead, so that ``from_dict`` reaches ``get_predictor`` + and the far more useful "is unavailable; its optional/literature dependency was not + registered" message, or the underlying ``ImportError``, survives. + + The catalog is consulted first because ``list_predictors`` registers every built-in on the + way, so a built-in token would otherwise be answered by whichever unrelated optional + dependency happened to fail during that sweep. + + :param token: The single-part recipe, already known not to be a zoo preset. + :raises ValueError: If *token* names no known built-in or registered predictor. + """ + if is_known_builtin_predictor(token) or token in list_predictors(): + return + msg = ( + f"Unknown model spec {token!r}. Use a recipe triple " + "(cellLine:drug:predictor), zoo name, or feature-free predictor token." + ) + raise ValueError(msg) + + +def zoo_config( + name: str, + hyperparameters: dict[str, Any] | None, + prediction_mode: PredictionMode | str, +) -> ModelConfig | ResolvedModelConfig | None: + """Resolve a registered zoo preset, or report that *name* is not one. + + :param name: Candidate zoo preset name. + :param hyperparameters: Optional flat public hyperparameter overrides. + :param prediction_mode: Mode to apply, honoured only when no overrides are given. + :returns: The preset's config, or ``None`` when *name* is not a zoo entry. + """ + try: + return model_config_for_name( + name, + hyperparameters, + prediction_mode=None if hyperparameters else PredictionMode(prediction_mode), + ) + except KeyError: + return None diff --git a/drevalpy/models/lightning_metrics_mixin.py b/drevalpy/models/lightning_metrics_mixin.py deleted file mode 100644 index 6139de757..000000000 --- a/drevalpy/models/lightning_metrics_mixin.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Mixin class for PyTorch Lightning modules to add R^2 and PCC metrics logging.""" - -import torch - -from ..evaluation import AVAILABLE_METRICS - - -class RegressionMetricsMixin: - """ - Mixin class for PyTorch Lightning modules to automatically compute and log R^2 and PCC metrics. - - This mixin provides: - - Storage for predictions and targets during training/validation steps - - Automatic computation of R^2 and PCC at epoch end - - Consistent logging to wandb via PyTorch Lightning's logging system - - Usage: - class MyModel(RegressionMetricsMixin, pl.LightningModule): - def __init__(self, ...): - super().__init__() - # Initialize your model... - self._init_metrics_storage() # Call this in __init__ - - def training_step(self, batch, batch_idx): - # ... your training logic ... - predictions = self.forward(...) - loss = self.criterion(predictions, targets) - self.log("train_loss", loss, ...) - self._store_predictions(predictions, targets, is_training=True) - return loss - - def validation_step(self, batch, batch_idx): - # ... your validation logic ... - predictions = self.forward(...) - loss = self.criterion(predictions, targets) - self.log("val_loss", loss, ...) - self._store_predictions(predictions, targets, is_training=False) - return loss - """ - - def _init_metrics_storage(self) -> None: - """Initialize storage for predictions and targets.""" - self.train_predictions: list[torch.Tensor] = [] - self.train_targets: list[torch.Tensor] = [] - self.val_predictions: list[torch.Tensor] = [] - self.val_targets: list[torch.Tensor] = [] - - def _store_predictions(self, predictions: torch.Tensor, targets: torch.Tensor, is_training: bool = True) -> None: - """ - Store predictions and targets for epoch-end metric computation. - - :param predictions: model predictions tensor - :param targets: ground truth targets tensor - :param is_training: whether this is from training (True) or validation (False) - """ - # Ensure tensors are detached and on CPU for numpy conversion - preds_cpu = predictions.detach().cpu() - targets_cpu = targets.detach().cpu() - - if is_training: - self.train_predictions.append(preds_cpu) - self.train_targets.append(targets_cpu) - else: - self.val_predictions.append(preds_cpu) - self.val_targets.append(targets_cpu) - - def _compute_epoch_metrics(self, predictions: list[torch.Tensor], targets: list[torch.Tensor]) -> dict[str, float]: - """ - Compute R^2 and PCC metrics from stored predictions and targets. - - :param predictions: list of prediction tensors from the epoch - :param targets: list of target tensors from the epoch - :returns: dictionary with "R^2" and "Pearson" keys, or empty dict if computation fails - """ - if len(predictions) == 0: - return {} - - try: - # Concatenate all predictions and targets from the epoch - all_preds = torch.cat(predictions).numpy() - all_targets = torch.cat(targets).numpy() - - # Compute metrics - r2 = AVAILABLE_METRICS["R^2"](y_pred=all_preds, y_true=all_targets) - pcc = AVAILABLE_METRICS["Pearson"](y_pred=all_preds, y_true=all_targets) - - return {"R^2": r2, "Pearson": pcc} - except Exception: - # If computation fails (e.g., NaN values, insufficient data), return empty dict - return {} - - def on_train_epoch_end(self) -> None: - """ - Epoch-end hook for training. - - Intentionally does NOT log R^2/Pearson per epoch anymore. We only keep - these buffers to allow optional debugging or future extensions. - """ - # Clear stored predictions/targets for next epoch - self.train_predictions.clear() - self.train_targets.clear() - - def on_validation_epoch_end(self) -> None: - """ - Epoch-end hook for validation. - - Intentionally does NOT log R^2/Pearson per epoch anymore. Final metrics - are logged once at the end via DRPModel.compute_and_log_final_metrics(). - """ - # Clear stored predictions/targets for next epoch - self.val_predictions.clear() - self.val_targets.clear() diff --git a/drevalpy/models/mixins/__init__.py b/drevalpy/models/mixins/__init__.py new file mode 100644 index 000000000..92064c045 --- /dev/null +++ b/drevalpy/models/mixins/__init__.py @@ -0,0 +1,22 @@ +"""DRPModel mixins for hyperparameter resolution, logging, training and persistence.""" + +from ._feature_matrix import DRPFeatureMatrixMixin +from ._hyperparameters import DRPHyperparametersMixin +from ._logging import _DRPLoggingMixin +from ._persistence import DRPPersistenceMixin +from ._persistence_io import load_model, load_model_payload, save_model +from ._train_args import TrainCallArgs, resolve_train_args +from ._training import DRPTrainingMixin + +__all__ = [ + "DRPFeatureMatrixMixin", + "DRPHyperparametersMixin", + "DRPPersistenceMixin", + "DRPTrainingMixin", + "TrainCallArgs", + "_DRPLoggingMixin", + "load_model", + "load_model_payload", + "resolve_train_args", + "save_model", +] diff --git a/drevalpy/models/mixins/_feature_matrix.py b/drevalpy/models/mixins/_feature_matrix.py new file mode 100644 index 000000000..a29d00895 --- /dev/null +++ b/drevalpy/models/mixins/_feature_matrix.py @@ -0,0 +1,140 @@ +"""Raw feature-matrix assembly for hand-rolled ``DRPModel`` subclasses. + +Neither method here touches ``_stack``: they read only the view names the resolved +config asks for, and pull those views straight out of a pair of feature sources. +That is what separates them from the fit/predict path in ``_training.py``, which +goes through the component stack and never assembles a matrix itself. + +A model built by ``construct_model`` never calls either - the stack featurizes - +so these exist for callers that drive featurization by hand. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +class DRPFeatureMatrixMixin: + """Assemble feature matrices for the views a model's config requires.""" + + @property + def cell_line_views(self) -> list[str]: + """Return required cell-line views; implemented by ``DRPModel``. + + :raises NotImplementedError: If the subclass does not implement this hook. + """ + raise NotImplementedError + + @property + def drug_views(self) -> list[str]: + """Return required drug views; implemented by ``DRPModel``. + + :raises NotImplementedError: If the subclass does not implement this hook. + """ + raise NotImplementedError + + def get_concatenated_features( + self, + cell_line_view: str | None, + drug_view: str | None, + cell_line_ids_output: np.ndarray, + drug_ids_output: np.ndarray, + cell_line_input: Any, + drug_input: Any, + ) -> np.ndarray: + """Concatenate selected cell-line and drug feature views into matrix ``X``. + + :param cell_line_view: Cell-line view name, or ``None`` to omit cell-line features. + :param drug_view: Drug view name, or ``None`` to omit drug features. + :param cell_line_ids_output: Cell-line identifiers for the output pairs. + :param drug_ids_output: Drug identifiers for the output pairs. + :param cell_line_input: Cell-line feature source, or ``None``. + :param drug_input: Drug feature source, or ``None``. + :returns: Feature matrix with one row per output pair. + :raises ValueError: If a requested view is missing from the inputs, or if + neither side requested one. + """ + inputs = self.get_feature_matrices( + cell_line_ids=cell_line_ids_output, + drug_ids=drug_ids_output, + cell_line_input=cell_line_input, + drug_input=drug_input, + ) + drug_features = _require_view(inputs, drug_view, "drug_view") + cell_line_features = _require_view(inputs, cell_line_view, "cell_line_view") + + if cell_line_features is not None and drug_features is not None: + return np.concatenate((cell_line_features, drug_features), axis=1) + if cell_line_features is not None: + return cell_line_features + if drug_features is not None: + return drug_features + raise ValueError("No features provided.") + + def get_feature_matrices( + self, + cell_line_ids: np.ndarray, + drug_ids: np.ndarray, + cell_line_input: Any, + drug_input: Any, + ) -> dict[str, np.ndarray]: + """Return feature matrices for the model's required views. + + :param cell_line_ids: Cell-line identifiers, one per pair. + :param drug_ids: Drug identifiers, one per pair. + :param cell_line_input: Cell-line feature source, or ``None``. + :param drug_input: Drug feature source, or ``None``. + :returns: Mapping from view name to feature matrix aligned with the ids. + :raises ValueError: If a required view is missing from the inputs. + """ + return { + **_matrices_for_side(cell_line_input, self.cell_line_views, cell_line_ids, "Cell line"), + **_matrices_for_side(drug_input, self.drug_views, drug_ids, "Drug"), + } + + +def _matrices_for_side( + source: Any, + views: list[str], + identifiers: np.ndarray, + label: str, +) -> dict[str, np.ndarray]: + """Pull every required view of one entity side out of its feature source. + + :param source: Feature source for the side, or ``None`` to contribute nothing. + :param views: View names the model config requires from this side. + :param identifiers: Entity identifiers, one per pair. + :param label: Side name used in the error message. + :returns: Mapping from view name to matrix, empty when *source* is ``None``. + :raises ValueError: If *source* does not carry one of *views*. + """ + if source is None: + return {} + matrices = {} + for view in views: + if view not in source.view_names: + raise ValueError(f"{label} input does not contain view {view}") + matrices[view] = source.get_feature_matrix(view=view, identifiers=identifiers) + return matrices + + +def _require_view( + inputs: dict[str, np.ndarray], + view: str | None, + label: str, +) -> np.ndarray | None: + """Look up one requested view, insisting it was actually assembled. + + :param inputs: Assembled matrices, keyed by view name. + :param view: Requested view name, or ``None`` to skip this side. + :param label: Parameter name used in the error message. + :returns: The matrix for *view*, or ``None`` when *view* is ``None``. + :raises ValueError: If *view* was requested but is absent from *inputs*. + """ + if view is None: + return None + if view not in inputs: + raise ValueError(f"Expected {label} '{view}' to be in inputs, but it was not. Inputs: {inputs}") + return inputs[view] diff --git a/drevalpy/models/mixins/_hyperparameters.py b/drevalpy/models/mixins/_hyperparameters.py new file mode 100644 index 000000000..678f41c09 --- /dev/null +++ b/drevalpy/models/mixins/_hyperparameters.py @@ -0,0 +1,84 @@ +"""Hyperparameter resolution mixin for DRPModel subclasses.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.models.config import ModelConfig +from drevalpy.models.factory import model_config_for_name +from drevalpy.models.tuning.public_flat import public_hyperparameters_from_config +from drevalpy.models.tuning.search_space import merge_model_config_spaces, resolve_model_config + + +class DRPHyperparametersMixin: + """Mixin providing hyperparameter resolution for DRPModel subclasses. + + Resolves the base ModelConfig for the model class and uses it to derive + structured search spaces and default hyperparameter mappings. + """ + + @classmethod + def _resolve_base_config(cls) -> ModelConfig | None: + """Resolve the base modular config for this model class. + + Resolution order: + 1. ``_base_model_config`` class attribute (deep-copied). + 2. ``model_config()`` class method. + 3. Zoo lookup via ``get_model_name()``. + + :returns: A fresh ModelConfig template, or None if unresolvable. + """ + base = getattr(cls, "_base_model_config", None) + if isinstance(base, ModelConfig): + return ModelConfig.model_validate(base.model_dump(mode="python")) + + model_config_fn = getattr(cls, "model_config", None) + if callable(model_config_fn): + try: + config = model_config_fn() + except RuntimeError: + config = None + if isinstance(config, ModelConfig): + return config + + get_model_name = getattr(cls, "get_model_name", None) + if not callable(get_model_name): + return None + model_name = get_model_name() + + try: + config = model_config_for_name(model_name, None) + except KeyError: + return None + return config if isinstance(config, ModelConfig) else None + + @classmethod + def get_structured_hyperparameter_space(cls) -> dict[str, Any]: + """Return the merged structured hyperparameter space for this model. + + :returns: Structured hyperparameter search space for tuning. + """ + config = cls._resolve_base_config() + if config is None: + return {} + return merge_model_config_spaces(config) + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, Any]: + """Return default hyperparameters used by ``cls()``. + + :returns: Default flat public hyperparameters for a new instance. + """ + config = cls._resolve_base_config() + if config is None: + return {} + resolved = resolve_model_config(config) + return public_hyperparameters_from_config(resolved) + + @classmethod + def get_hyperparameter_set(cls) -> list[dict[str, Any]]: + """Return the default hyperparameter configuration for this model. + + :returns: Single-element list containing default hyperparameters. + """ + return [cls.get_default_hyperparameters()] diff --git a/drevalpy/models/mixins/_logging.py b/drevalpy/models/mixins/_logging.py new file mode 100644 index 000000000..cf73a0bb4 --- /dev/null +++ b/drevalpy/models/mixins/_logging.py @@ -0,0 +1,123 @@ +"""Weights & Biases and metric helpers mixed into DRPModel. + +``wandb`` is imported inside the methods that use it. ``DRPModel`` is on the +registration path of ``import drevalpy``, and importing ``wandb`` costs ~0.11s +even for the overwhelming majority of runs that never enable it. See +``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from contextlib import suppress +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from types import ModuleType + + +def _wandb() -> ModuleType: + """Import and return the ``wandb`` module. + + Every ``wandb`` reference in this file goes through here, so the dependency + is paid for on first use rather than at ``import drevalpy`` time, and so + tests have a single seam to patch. + + :returns: The imported ``wandb`` module. + """ + import wandb + + return wandb + + +class _DRPLoggingMixin: + """Shared wandb / evaluation helpers for concrete DRPModel instances.""" + + wandb_project: str | None + wandb_run: Any + wandb_config: dict[str, Any] | None + _in_hyperparameter_tuning: bool + + @classmethod + def get_model_name(cls) -> str: + """Return the model identity; implemented by ``DRPModel``. + + :raises NotImplementedError: If the subclass does not implement this hook. + """ + raise NotImplementedError + + @property + def hyperparameters(self) -> dict[str, Any]: + """Return instance hyperparameters; implemented by ``DRPModel``. + + :raises NotImplementedError: If the subclass does not implement this hook. + """ + raise NotImplementedError + + def init_wandb( + self, + project: str, + config: dict[str, Any] | None = None, + name: str | None = None, + tags: list[str] | None = None, + finish_previous: bool = True, + ) -> None: + """Initialize wandb logging for this model instance. + + :param project: Weights & Biases project name. + :param config: Optional run configuration dict. + :param name: Optional run display name; defaults to the model name. + :param tags: Optional run tags. + :param finish_previous: Finish any active wandb run before starting a new one. + """ + self.wandb_project = project + run_config = dict(config or {}) + if self.hyperparameters and "hyperparameters" not in run_config: + run_config["hyperparameters"] = self.hyperparameters + self.wandb_config = run_config + + wandb = _wandb() + if finish_previous: + wandb.finish() + + run_name = name or self.get_model_name() + wandb.init( + project=project, + config=self.wandb_config, + name=run_name, + tags=tags, + ) + self.wandb_run = wandb.run + + with suppress(Exception): + wandb.define_metric("epoch", summary="max") + wandb.define_metric("train_loss", summary="min") + wandb.define_metric("val_loss", summary="min") + wandb.define_metric("train_R^2", summary="max") + wandb.define_metric("val_R^2", summary="max") + wandb.define_metric("train_Pearson", summary="max") + wandb.define_metric("val_Pearson", summary="max") + + def is_wandb_enabled(self) -> bool: + """Return whether wandb logging is active for this instance. + + :returns: ``True`` when a wandb project and run are active. + """ + return self.wandb_project is not None and (self.wandb_run is not None or _wandb().run is not None) + + def log_final_metrics(self, metrics: dict[str, float]) -> None: + """Store final metrics in the wandb run summary. + + :param metrics: Final scalar metrics to persist in the run summary. + """ + wandb = _wandb() + if not self.is_wandb_enabled() or wandb.run is None: + return + for key, value in metrics.items(): + wandb.run.summary[key] = value + + def finish_wandb(self) -> None: + """Finish the wandb run for this model instance.""" + if not self.is_wandb_enabled(): + return + _wandb().finish() + self.wandb_run = None diff --git a/drevalpy/models/mixins/_persistence.py b/drevalpy/models/mixins/_persistence.py new file mode 100644 index 000000000..84d6d847e --- /dev/null +++ b/drevalpy/models/mixins/_persistence.py @@ -0,0 +1,59 @@ +"""Checkpoint persistence mixin for DRPModel subclasses.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from upath import UPath as Path + +from drevalpy.models.mixins._persistence_io import ( + CorruptedCheckpointError, + IncompatibleModelCheckpointError, + load_model_payload, + save_model, +) + +if TYPE_CHECKING: + from drevalpy.models.drp_model import DRPModel + + +class DRPPersistenceMixin: + """Mixin providing save/load checkpoint operations for DRPModel subclasses. + + Delegates low-level archive I/O to ``drevalpy.models.mixins._persistence_io``. + """ + + def save(self, path: str | Path) -> None: + """Persist model identity, config, and fitted component state. + + :param path: Archive file path; ``.zip`` is appended when missing. + """ + save_model(self, path) # type: ignore[arg-type] + + @classmethod + def load(cls, path: str | Path) -> DRPModel: + """Load a fitted model checkpoint into a new instance of this class. + + :param path: Archive file path; ``.zip`` is appended when missing. + :returns: Fitted model instance with restored component state. + :raises IncompatibleModelCheckpointError: If the stored model name does not match this class. + :raises CorruptedCheckpointError: If the archive payload is invalid or incomplete. + """ + model_name, config, state = load_model_payload(path) + if model_name != cls.get_model_name(): # type: ignore[attr-defined] + raise IncompatibleModelCheckpointError( + f"checkpoint model_name {model_name!r} does not match {cls.get_model_name()!r}" # type: ignore[attr-defined] + ) + instance: Any = cls._from_resolved_config(config) # type: ignore[attr-defined] + if instance._stack is None: + raise CorruptedCheckpointError("failed to materialize component stack from checkpoint") + try: + instance._stack.restore_component_state(state) + except (ValueError, RuntimeError) as exc: + raise CorruptedCheckpointError( + f"checkpoint component state is invalid: {exc}" if str(exc) else "checkpoint component state is invalid" + ) from exc + if not instance._stack.is_fitted(): + raise CorruptedCheckpointError("checkpoint did not restore a fitted predictor") + instance._empty_training = False + return instance diff --git a/drevalpy/models/mixins/_persistence_io.py b/drevalpy/models/mixins/_persistence_io.py new file mode 100644 index 000000000..e5c33cad3 --- /dev/null +++ b/drevalpy/models/mixins/_persistence_io.py @@ -0,0 +1,199 @@ +"""Low-level versioned checkpoint I/O for DRPModel instances. + +Checkpoints are a single ZIP archive written atomically to an archive file path. +Callers must only load artifacts they created with ``save_model`` in the same +drevalpy version family. +""" + +from __future__ import annotations + +import io +import os +import tempfile +import zipfile +from typing import TYPE_CHECKING, Any + +import joblib +from upath import UPath as Path + +from drevalpy.models.config.resolved import ResolvedModelConfig + +if TYPE_CHECKING: + from drevalpy.models.drp_model import DRPModel + +FORMAT_NAME = "drevalpy-model" +FORMAT_VERSION = 2 +PAYLOAD_MEMBER = "payload.joblib" + + +class ModelCheckpointError(Exception): + """Base error for DRPModel checkpoint problems.""" + + +class UnsupportedCheckpointFormatError(ModelCheckpointError, ValueError): + """Raised when checkpoint format or version is not supported.""" + + +class CorruptedCheckpointError(ModelCheckpointError, ValueError): + """Raised when checkpoint payload structure or content is invalid.""" + + +class IncompatibleModelCheckpointError(ModelCheckpointError, ValueError): + """Raised when checkpoint model identity does not match the loader class.""" + + +def _as_path(path: str | Path) -> Path: + """Normalize a user path to ``Path``, rejecting trailing separators. + + :param path: Checkpoint archive path string or ``Path``. + :returns: Normalized path without a trailing directory separator. + :raises ValueError: If ``path`` ends with a directory separator. + """ + if isinstance(path, str) and path.endswith(("/", "\\")): + msg = f"Checkpoint path must be an archive file path, not a directory: {path}" + raise ValueError(msg) + return Path(path) + + +def resolve_checkpoint_path(path: str | Path) -> Path: + """Return the archive file path, appending ``.zip`` when missing. + + :param path: Checkpoint archive path; ``.zip`` is appended when missing. + :returns: Normalized archive ``Path``. + """ + target = _as_path(path) + if target.name.lower().endswith(".zip"): + return target + return target.with_name(f"{target.name}.zip") + + +def _reject_directory_path(path: Path) -> None: + if path.exists() and path.is_dir(): + msg = f"Checkpoint path must be an archive file path, not a directory: {path}" + raise ValueError(msg) + + +def _write_archive_atomically(archive_path: Path, payload: dict[str, Any]) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=f".{archive_path.name}.", suffix=".tmp", dir=archive_path.parent) + os.close(fd) + tmp_path = Path(tmp_name) + try: + buffer = io.BytesIO() + joblib.dump(payload, buffer) + with zipfile.ZipFile(tmp_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr(PAYLOAD_MEMBER, buffer.getvalue()) + os.replace(tmp_path, archive_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + +def _read_payload_from_archive(archive_path: Path) -> Any: + try: + with zipfile.ZipFile(archive_path, mode="r") as archive: + try: + info = archive.getinfo(PAYLOAD_MEMBER) + except KeyError as exc: + raise CorruptedCheckpointError( + f"checkpoint archive {archive_path} is missing {PAYLOAD_MEMBER!r}" + ) from exc + with archive.open(info) as handle: + return joblib.load(handle) + except zipfile.BadZipFile as exc: + raise CorruptedCheckpointError(f"checkpoint archive {archive_path} is not a valid zip file") from exc + except CorruptedCheckpointError: + raise + except Exception as exc: + raise CorruptedCheckpointError(f"Failed to deserialize checkpoint {archive_path}: {exc}") from exc + + +def save_model(model: DRPModel, path: str | Path) -> None: + """Save model identity, config, and component state as one ZIP archive. + + :param model: Trained ``DRPModel`` instance to persist. + :param path: Archive file path; ``.zip`` is appended when missing. + :raises RuntimeError: If the model is not trained or lacks a ``ModelConfig``. + """ + stack = model._stack + if stack is None or not stack.is_fitted(): + raise RuntimeError("Cannot save: component stack is not trained") + config = model._resolved_model_config + if config is None: + raise RuntimeError("Cannot save a model without its ResolvedModelConfig") + target = _as_path(path) + _reject_directory_path(target) + payload = { + "format": FORMAT_NAME, + "version": FORMAT_VERSION, + "model_name": model.get_model_name(), + "config": config.model_dump(mode="json"), + "state": stack.component_state(), + } + _write_archive_atomically(resolve_checkpoint_path(target), payload) + + +def _resolved_config_from_checkpoint_payload(payload: dict[str, Any]) -> ResolvedModelConfig: + version = payload.get("version") + if version == 2: + return ResolvedModelConfig.model_validate(payload["config"]) + raise UnsupportedCheckpointFormatError( + f"unsupported checkpoint format/version: {payload.get('format')!r}/{payload.get('version')!r}" + ) + + +def load_model_payload(path: str | Path) -> tuple[str, ResolvedModelConfig, dict[str, object]]: + """Load and validate a ``DRPModel`` checkpoint payload from an archive path. + + :param path: Archive file path; ``.zip`` is appended when missing. + :returns: Tuple of ``(model_name, resolved_config, component_state)``. + :raises FileNotFoundError: If the archive does not exist. + :raises UnsupportedCheckpointFormatError: If the format or version is unsupported. + :raises CorruptedCheckpointError: If the payload structure is invalid. + """ + target = _as_path(path) + _reject_directory_path(target) + archive_path = resolve_checkpoint_path(target) + if not archive_path.is_file(): + raise FileNotFoundError(f"Missing model checkpoint: {archive_path}") + + payload = _read_payload_from_archive(archive_path) + if not isinstance(payload, dict): + raise CorruptedCheckpointError("checkpoint payload is not a mapping") + if payload.get("format") != FORMAT_NAME: + raise UnsupportedCheckpointFormatError( + f"unsupported checkpoint format/version: {payload.get('format')!r}/{payload.get('version')!r}" + ) + model_name = payload.get("model_name") + if not isinstance(model_name, str) or not model_name: + raise CorruptedCheckpointError("checkpoint model_name is missing or invalid") + try: + config = _resolved_config_from_checkpoint_payload(payload) + except UnsupportedCheckpointFormatError: + raise + except Exception as exc: + raise CorruptedCheckpointError("checkpoint config is invalid") from exc + state = payload.get("state") + if not isinstance(state, dict): + raise CorruptedCheckpointError("checkpoint state is not a mapping") + return model_name, config, state + + +def load_model(path: str | Path) -> DRPModel: + """Reconstruct a fitted ``DRPModel`` from a checkpoint archive path. + + Reads the stored model name and ``ModelConfig``, builds the matching class + via ``construct_model``, then restores fitted state. Use this when you do not + already have a class handle for ``ModelClass.load(path)``. + + Custom featurizers and predictors must already be registered (same as for + training). Load only artifacts created with ``save_model`` in the same + drevalpy version family. + + :param path: Archive file path; ``.zip`` is appended when missing. + :returns: Fitted ``DRPModel`` instance. + """ + from drevalpy.models.construct import construct_model + + model_name, config, _state = load_model_payload(path) + return construct_model(model_name, config).load(path) diff --git a/drevalpy/models/mixins/_train_args.py b/drevalpy/models/mixins/_train_args.py new file mode 100644 index 000000000..312078153 --- /dev/null +++ b/drevalpy/models/mixins/_train_args.py @@ -0,0 +1,132 @@ +"""Resolution of the two accepted call shapes of ``DRPModel.train``. + +``train`` accepts either ``(mudataset, scope)`` - the Dataset path every caller +inside the library uses - or ``(output, cell_line_input, drug_input)``, the +ResponseBatch path kept for hand-rolled models. Both may be passed positionally +or by keyword, and the second positional slot additionally accepts a +``SplitMasks`` in place of a ``SplitMask``. + +Untangling that lives here rather than on ``DRPModel`` because it reads no +instance state: it is a pure function from a call site's arguments to a +:class:`TrainCallArgs`, which is what lets ``train`` itself be a two-branch +dispatch instead of a run of ``isinstance`` checks. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from drevalpy.types import SplitMask, SplitMasks +from drevalpy.types.data.dataset import Dataset + + +@dataclass(frozen=True, slots=True) +class TrainCallArgs: + """One resolved ``train`` call, with the two input forms told apart. + + Exactly one of :attr:`is_dataset_form` / :attr:`is_feature_source_form` is + true for a well-formed call; neither is for a call missing its inputs, which + is what ``train`` reports as a ``TypeError``. + """ + + mudataset: Dataset | None = None + scope: SplitMask | None = None + early_stopping_scope: SplitMask | None = None + output: Any = None + cell_line_input: Any = None + drug_input: Any = None + + @property + def is_dataset_form(self) -> bool: + """Whether the call carries a ``Dataset`` and the scope to train on. + + :returns: ``True`` when the Dataset path applies. + """ + return self.mudataset is not None and self.scope is not None + + @property + def is_feature_source_form(self) -> bool: + """Whether the call carries a response batch and a cell-line source. + + :returns: ``True`` when the ResponseBatch path applies. + """ + return self.output is not None and self.cell_line_input is not None + + +def _first_positional(value: Any, output: Any) -> tuple[Dataset | None, Any]: + """Assign the first positional argument to whichever form it belongs to. + + :param value: The first positional argument. + :param output: The ``output`` keyword, returned unchanged for a ``Dataset``. + :returns: Pair of ``(mudataset, output)``. + """ + if isinstance(value, Dataset): + return value, output + return None, value + + +def _second_positional( + value: Any, + split: SplitMasks | None, + cell_line_input: Any, +) -> tuple[SplitMask | None, SplitMasks | None, Any]: + """Assign the second positional argument to whichever form it belongs to. + + :param value: The second positional argument. + :param split: The ``split`` keyword, returned unchanged unless *value* is one. + :param cell_line_input: The ``cell_line_input`` keyword, likewise. + :returns: Triple of ``(scope, split, cell_line_input)``. + """ + if isinstance(value, SplitMask): + return value, split, cell_line_input + if isinstance(value, SplitMasks): + return None, value, cell_line_input + return None, split, value + + +def resolve_train_args( + first_positional: Any = None, + second_positional: Any = None, + drug_input: Any = None, + *, + mudataset: Dataset | None = None, + split: SplitMasks | None = None, + scope: SplitMask | None = None, + early_stopping_scope: SplitMask | None = None, + output: Any = None, + cell_line_input: Any = None, +) -> TrainCallArgs: + """Resolve one ``train`` call into named, form-tagged arguments. + + A keyword always wins over the positional slot it duplicates. A ``SplitMasks`` + is narrowed to its ``train`` mask, and its ``val`` mask becomes the + early-stopping scope when it selects anything. + + :param first_positional: ``mudataset`` or ``output``, by type. + :param second_positional: ``scope``, ``split`` or ``cell_line_input``, by type. + :param drug_input: Drug feature source for the ResponseBatch form. + :param mudataset: Dataset carrying responses and features. + :param split: Full set of split masks; narrowed to ``scope`` here. + :param scope: Mask selecting the training pairs. + :param early_stopping_scope: Mask selecting the early-stopping pairs. + :param output: Response batch for the ResponseBatch form. + :param cell_line_input: Cell-line feature source for the ResponseBatch form. + :returns: The resolved call arguments. + """ + if mudataset is None and first_positional is not None: + mudataset, output = _first_positional(first_positional, output) + if scope is None and second_positional is not None: + scope, split, cell_line_input = _second_positional(second_positional, split, cell_line_input) + if scope is None and split is not None: + scope = split.train + if split.val.any(): + early_stopping_scope = split.val + return TrainCallArgs( + mudataset=mudataset, + scope=scope, + early_stopping_scope=early_stopping_scope, + output=output, + cell_line_input=cell_line_input, + drug_input=drug_input, + ) diff --git a/drevalpy/models/mixins/_training.py b/drevalpy/models/mixins/_training.py new file mode 100644 index 000000000..faf55b4a3 --- /dev/null +++ b/drevalpy/models/mixins/_training.py @@ -0,0 +1,244 @@ +"""Train / predict over a materialized component stack. + +The two methods here are what makes a ``DRPModel`` a model rather than a +configuration object, and they are the only ones that read and write +``_stack`` / ``_empty_training``. Keeping them apart from the config and identity +surface on ``DRPModel`` is the same separation ``_persistence.py`` already draws +for checkpoint I/O. + +Call-shape resolution lives one module over, in ``_train_args.py``, so ``train`` +below is a dispatch between two input forms rather than a run of ``isinstance`` +checks. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np +from upath import UPath as Path + +from drevalpy.components.contracts.training_context import TrainingContext +from drevalpy.models.component_stack import _ComponentStack +from drevalpy.models.mixins._train_args import TrainCallArgs, resolve_train_args +from drevalpy.types import SplitMask, SplitMasks + +if TYPE_CHECKING: + from sklearn.base import TransformerMixin + + from drevalpy.types.data.dataset import Dataset + + +class DRPTrainingMixin: + """Fit and score the component stack a ``DRPModel`` was materialized with.""" + + _stack: _ComponentStack | None + _empty_training: bool + + @classmethod + def get_model_name(cls) -> str: + """Return the model identity; implemented by ``DRPModel``. + + :raises NotImplementedError: If the subclass does not implement this hook. + """ + raise NotImplementedError + + def _require_stack(self) -> _ComponentStack: + """Return the component stack, refusing an unmaterialized model. + + :returns: The materialized component stack. + :raises RuntimeError: If the model was constructed without a stack. + """ + if self._stack is None: + raise RuntimeError("Model has not been constructed with a component stack") + return self._stack + + def _training_context(self, model_checkpoint_dir: str | Path) -> TrainingContext: + """Build the context predictors read for checkpoints and log labels. + + :param model_checkpoint_dir: Directory predictors may write checkpoints to. + :returns: Training context for this model. + """ + return TrainingContext( + checkpoint_dir=Path(model_checkpoint_dir), + logging_metadata={"model_name": self.get_model_name()}, + ) + + def train( + self, + mudataset_or_output=None, + split_or_cell_line_input=None, + drug_input=None, + *, + mudataset: Dataset | None = None, + split: SplitMasks | None = None, + scope: SplitMask | None = None, + early_stopping_scope: SplitMask | None = None, + output=None, + cell_line_input=None, + output_earlystopping=None, + model_checkpoint_dir: str | Path = "checkpoints", + response_transformation: TransformerMixin | None = None, + ) -> None: + """Train the component stack. + + Supports the Dataset path (positional: mudataset, scope/split) and the + internal path (output, cell_line_input, drug_input). + + :param mudataset_or_output: First positional slot; a ``Dataset`` selects the + Dataset path, anything else is taken as ``output``. + :param split_or_cell_line_input: Second positional slot; a ``SplitMask`` or + ``SplitMasks`` selects the Dataset path, anything else is ``cell_line_input``. + :param mudataset: Dataset containing response data and all features. + :param scope: SplitMask defining train indices for this fold. + :param split: (compat) SplitMasks; converted to SplitMask internally. + :param early_stopping_scope: Optional SplitMask for early stopping. + :param output: ResponseBatch for training pairs. + :param cell_line_input: FeatureSource for cell lines. + :param drug_input: FeatureSource for drugs, or None. + :param output_earlystopping: Optional early-stopping dataset. + :param model_checkpoint_dir: Directory for predictor checkpoints. + :param response_transformation: Optional fitted transformer applied to the + training targets. Predictions therefore live in the transformed space and the + caller is responsible for inverse-transforming them. + :raises RuntimeError: If the model lacks a component stack. + :raises TypeError: If neither accepted set of inputs was passed. + """ + stack = self._require_stack() + args = resolve_train_args( + mudataset_or_output, + split_or_cell_line_input, + drug_input, + mudataset=mudataset, + split=split, + scope=scope, + early_stopping_scope=early_stopping_scope, + output=output, + cell_line_input=cell_line_input, + ) + context = self._training_context(model_checkpoint_dir) + + if args.is_dataset_form: + self._train_on_dataset(stack, args, context, response_transformation) + return + if args.is_feature_source_form: + self._train_on_feature_sources(stack, args, context, output_earlystopping) + return + raise TypeError("train() requires either (mudataset, scope) or (output, cell_line_input)") + + def _train_on_dataset( + self, + stack: _ComponentStack, + args: TrainCallArgs, + context: TrainingContext, + response_transformation: TransformerMixin | None, + ) -> None: + """Fit the stack from a ``Dataset`` and a training scope. + + A scope selecting no non-NaN response leaves the model in the empty-training + state, where ``predict`` answers NaN instead of raising. + + :param stack: Materialized component stack. + :param args: Resolved training arguments in the Dataset form. + :param context: Training context for checkpoints and log labels. + :param response_transformation: Optional fitted transformer for the targets. + """ + train_response = _ComponentStack._extract_response_pairs(args.mudataset, args.scope, response_transformation) + if len(train_response) == 0: + self._empty_training = True + return + self._empty_training = False + if args.early_stopping_scope is not None: + stack.train_with_early_stopping( + args.mudataset, + args.scope, + args.early_stopping_scope, + training_context=context, + response_transformation=response_transformation, + ) + return + stack.train( + args.mudataset, + args.scope, + training_context=context, + response_transformation=response_transformation, + ) + + def _train_on_feature_sources( + self, + stack: _ComponentStack, + args: TrainCallArgs, + context: TrainingContext, + output_earlystopping: Any, + ) -> None: + """Fit the stack from a response batch and raw feature sources. + + :param stack: Materialized component stack. + :param args: Resolved training arguments in the ResponseBatch form. + :param context: Training context for checkpoints and log labels. + :param output_earlystopping: Optional early-stopping response batch. + """ + self._empty_training = len(args.output) == 0 + if self._empty_training: + return + stack._fit_featurizers_and_predictor( + args.output, + args.cell_line_input, + args.drug_input, + output_earlystopping=output_earlystopping, + training_context=context, + ) + + def predict( + self, + mudataset: Dataset | None = None, + scope_or_split=None, + *, + scope: SplitMask | None = None, + split: SplitMasks | None = None, + ) -> np.ndarray: + """Predict responses for the test pairs in a split. + + :param mudataset: Dataset containing all features. + :param scope_or_split: Positional SplitMask or SplitMasks (compat). + :param scope: SplitMask with indices to predict on. + :param split: SplitMasks; test indices used as scope. + :returns: Predicted response values. + :raises RuntimeError: If the model is untrained or lacks a component stack. + :raises TypeError: If required arguments are missing. + """ + stack = self._require_stack() + scope = _resolve_predict_scope(scope_or_split, scope=scope, split=split) + if mudataset is None or scope is None: + raise TypeError("predict() requires (mudataset, scope) or (mudataset, split)") + + if self._empty_training: + test_response = _ComponentStack._extract_response_pairs(mudataset, scope) + return np.full(len(test_response), np.nan) + if not stack.is_fitted(): + raise RuntimeError("Model has not been trained; call train() or load() before predict()") + return stack.predict(mudataset, scope) + + +def _resolve_predict_scope( + scope_or_split: Any, + *, + scope: SplitMask | None, + split: SplitMasks | None, +) -> SplitMask | None: + """Pick the mask ``predict`` scores on out of its three accepted spellings. + + :param scope_or_split: Positional ``SplitMask`` or ``SplitMasks``. + :param scope: Explicit scope keyword, which wins over both others. + :param split: Full split masks, whose ``test`` mask is used. + :returns: The mask to predict on, or ``None`` when none was given. + """ + if scope is not None: + return scope + if isinstance(scope_or_split, SplitMask): + return scope_or_split + if isinstance(scope_or_split, SplitMasks): + return scope_or_split.test + if split is not None: + return split.test + return None diff --git a/drevalpy/models/tuning/__init__.py b/drevalpy/models/tuning/__init__.py new file mode 100644 index 000000000..0e4b67a91 --- /dev/null +++ b/drevalpy/models/tuning/__init__.py @@ -0,0 +1,47 @@ +"""Internal hyperparameter helpers for modular composition.""" + +from .config import HPOConfig, build_experiment_hpo_config, validate_hpo_metric +from .config_resolution import ( + assert_component_local_hyperparameters, + construct_drp_model_from_config, + default_config_for_drp_model, + default_hyperparameters_for_drp_model, + has_tunable_hyperparameters, + structured_space_for_drp_model, + tuned_config_for_drp_model, +) +from .public_flat import ( + config_from_public_hyperparameters, + public_hyperparameters_from_config, +) +from .search_space import ( + apply_merged_to_model_config, + defaults_from_merged_space, + extract_defaults, + merge_model_config_spaces, + merge_search_spaces, + sample_from_optuna_trial, + split_hyperparameters, +) + +__all__ = [ + "HPOConfig", + "apply_merged_to_model_config", + "assert_component_local_hyperparameters", + "build_experiment_hpo_config", + "config_from_public_hyperparameters", + "construct_drp_model_from_config", + "default_config_for_drp_model", + "default_hyperparameters_for_drp_model", + "defaults_from_merged_space", + "extract_defaults", + "has_tunable_hyperparameters", + "merge_model_config_spaces", + "merge_search_spaces", + "public_hyperparameters_from_config", + "sample_from_optuna_trial", + "split_hyperparameters", + "structured_space_for_drp_model", + "tuned_config_for_drp_model", + "validate_hpo_metric", +] diff --git a/drevalpy/models/tuning/compatibility_keys.py b/drevalpy/models/tuning/compatibility_keys.py new file mode 100644 index 000000000..915c436d2 --- /dev/null +++ b/drevalpy/models/tuning/compatibility_keys.py @@ -0,0 +1,69 @@ +"""Config-to-public featurizer translation helpers.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.components.featurizers._featurizer_tree import iter_featurizer_leaves +from drevalpy.models.config import FeaturizerConfig +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer + + +def _featurizer_space_keys(featurizer: FeaturizerConfig, registry: str) -> set[str]: + cls = get_cell_line_featurizer(featurizer.name) if registry == "cell_line" else get_drug_featurizer(featurizer.name) + space = ( + dict(featurizer.hyperparameter_space) + if featurizer.hyperparameter_space is not None + else dict(cls.get_hyperparameter_space()) + ) + return set(space) + + +def _is_exportable_space_flat_key(key: str, flat: dict[str, Any]) -> bool: + if key in {"featurizers", "view", "views"}: + return False + if "." in key or key.startswith(("cell_line_featurizer.", "drug_featurizer.", "predictor.")): + return False + return key not in flat + + +def _append_space_default_flat_keys( + flat: dict[str, Any], + featurizer: FeaturizerConfig, + registry: str, +) -> None: + cls = get_cell_line_featurizer(featurizer.name) if registry == "cell_line" else get_drug_featurizer(featurizer.name) + space = ( + dict(featurizer.hyperparameter_space) + if featurizer.hyperparameter_space is not None + else dict(cls.get_hyperparameter_space()) + ) + for key, spec in space.items(): + if not _is_exportable_space_flat_key(key, flat): + continue + flat.setdefault(key, spec["default"]) + if featurizer.name == "pca" and featurizer.view == "methylation" and key == "n_components": + flat["methylation_n_components"] = spec["default"] + flat.setdefault("methylation_pca_components", spec["default"]) + + +def append_featurizer_flat_keys( + flat: dict[str, Any], + featurizer: FeaturizerConfig | None, + registry: str, +) -> None: + """Append tunable featurizer defaults into a public flat dict. + + Architecture-only featurizer kwargs stay on the config tree and are not flattened. + Concrete selected values live on ``ResolvedModelConfig`` and are exported elsewhere. + + :param flat: Mutable public flat hyperparameter mapping to extend in place. + :param featurizer: Featurizer config subtree to flatten, or ``None``. + :param registry: Registry slot name (``cell_line`` or ``drug``). + """ + if featurizer is None: + return + for leaf in iter_featurizer_leaves(featurizer, registry): + _append_space_default_flat_keys(flat, leaf, registry) + _ = _featurizer_space_keys(leaf, registry) diff --git a/drevalpy/models/tuning/config.py b/drevalpy/models/tuning/config.py new file mode 100644 index 000000000..033905db8 --- /dev/null +++ b/drevalpy/models/tuning/config.py @@ -0,0 +1,65 @@ +"""Optuna search configuration for DRP experiment hyperparameter tuning.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from drevalpy.evaluation import AVAILABLE_METRICS, get_mode + + +def validate_hpo_metric(metric: str) -> None: + """Raise ``ValueError`` when *metric* is not a supported HPO objective. + + :param metric: Evaluation metric name, for example ``"RMSE"``. + :raises ValueError: If *metric* is not registered in ``AVAILABLE_METRICS``. + """ + if metric not in AVAILABLE_METRICS: + msg = f"Invalid HPO metric {metric!r}. Choose from {list(AVAILABLE_METRICS.keys())}" + raise ValueError(msg) + + +@dataclass +class HPOConfig: + """Configuration for Optuna hyperparameter search in drevalpy experiments.""" + + n_trials: int = 16 + metric: str = "RMSE" + mode: str = "min" + random_state: int = 42 + + @classmethod + def from_metric(cls, metric: str, *, n_trials: int = 16, **kwargs: Any) -> HPOConfig: + """Build an HPO config with ``mode`` inferred from the evaluation metric. + + :param metric: Evaluation metric name used as the Optuna objective. + :param n_trials: Number of search trials; ``0`` selects defaults only. + :param kwargs: Additional ``HPOConfig`` field overrides. + :returns: Configured ``HPOConfig`` instance. + :raises ValueError: If *metric* is invalid or *n_trials* is negative. + """ + validate_hpo_metric(metric) + if n_trials < 0: + msg = f"n_trials must be >= 0 (got {n_trials}); use 0 for default-only tuning" + raise ValueError(msg) + return cls(n_trials=n_trials, metric=metric, mode=get_mode(metric), **kwargs) + + +def build_experiment_hpo_config( + metric: str, + *, + n_trials: int = 16, + random_state: int = 42, +) -> HPOConfig: + """Build shared Optuna settings for CV and final-model tuning. + + :param metric: Evaluation metric name used as the Optuna objective. + :param n_trials: Number of search trials per tuning run. + :param random_state: Random seed forwarded to the sampler. + :returns: Configured ``HPOConfig`` instance. + """ + return HPOConfig.from_metric( + metric, + n_trials=n_trials, + random_state=random_state, + ) diff --git a/drevalpy/models/tuning/config_resolution.py b/drevalpy/models/tuning/config_resolution.py new file mode 100644 index 000000000..1b9937349 --- /dev/null +++ b/drevalpy/models/tuning/config_resolution.py @@ -0,0 +1,121 @@ +"""Resolve structured defaults and search spaces for DRPModel classes.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +from drevalpy.components.featurizers._featurizer_tree import iter_featurizer_leaves +from drevalpy.models.config import FeaturizerConfig, ModelConfig +from drevalpy.models.config.resolved import ResolvedModelConfig + +from .search_space import ( + resolve_model_config, +) + + +def iter_featurizer_configs(config: ModelConfig) -> Iterator[FeaturizerConfig]: + """Yield every leaf featurizer config in a model config.""" + for featurizer in (config.cell_line_featurizer, config.drug_featurizer): + if featurizer is None: + continue + yield from iter_featurizer_leaves(featurizer, str(featurizer.registry)) + + +def default_config_for_drp_model(model_class: type[Any]) -> ResolvedModelConfig | None: + """Return a resolved config with structured defaults applied. + + :param model_class: Public ``DRPModel`` subclass. + + :returns: Resolved config with component defaults, or ``None`` when the model has no modular config. + """ + config = model_class._resolve_base_config() + if config is None: + return None + return resolve_model_config(config) + + +def tuned_config_for_drp_model( + model_class: type[Any], + merged_sample: dict[str, Any], +) -> ResolvedModelConfig | None: + """Apply a structured Ray/Optuna sample onto the base model template. + + :param model_class: Public ``DRPModel`` subclass. + :param merged_sample: Flat structured hyperparameter sample from Ray Tune. + + :returns: Resolved ``ResolvedModelConfig``, or ``None`` when the model has no modular config. + """ + config = model_class._resolve_base_config() + if config is None: + return None + return resolve_model_config(config, merged_sample) + + +def construct_drp_model_from_config(model_class: type[Any], config: ModelConfig | ResolvedModelConfig) -> Any: + """Construct a public DRPModel instance from a template or resolved config. + + :param model_class: Public ``DRPModel`` subclass. + :param config: Template or resolved modular configuration. + + :returns: Instantiated model object. + """ + from_resolved = getattr(model_class, "_from_resolved_config", None) + if callable(from_resolved): + if isinstance(config, ModelConfig): + return from_resolved(resolve_model_config(config)) + return from_resolved(config) + from .public_flat import public_hyperparameters_from_config + + return model_class(public_hyperparameters_from_config(config)) + + +def structured_space_for_drp_model(model_class: type[Any]) -> dict[str, Any]: + """Return the merged structured search space for a DRPModel class. + + :param model_class: Public ``DRPModel`` subclass. + + :returns: Flat search-space dict with prefixed component keys. + """ + return model_class.get_structured_hyperparameter_space() + + +def default_hyperparameters_for_drp_model(model_class: type[Any]) -> dict[str, Any]: + """Return default hyperparameters used by ``model_class()``. + + :param model_class: Public ``DRPModel`` subclass. + + :returns: Public flat hyperparameter mapping for the model's default config. + """ + return model_class.get_default_hyperparameters() + + +def has_tunable_hyperparameters(model_class: type[Any]) -> bool: + """Return whether the model exposes a non-empty structured search space. + + :param model_class: Public ``DRPModel`` subclass. + + :returns: ``True`` when at least one tunable parameter is declared. + """ + return bool(model_class.get_structured_hyperparameter_space()) + + +def assert_component_local_hyperparameters(config: ModelConfig | ResolvedModelConfig) -> None: + """Raise if namespaced keys leaked into component-local hyperparameter dicts. + + For templates this is a no-op (templates store no concrete values). For + resolved configs, qualified keys in ``values`` are expected. + + :param config: Model configuration to validate. + + :raises AssertionError: If a featurizer or predictor hyperparameter dict contains + """ + if isinstance(config, ResolvedModelConfig): + for key in config.values: + if key.count(".") < 2: + msg = f"resolved hyperparameter key {key!r} must be qualified" + raise AssertionError(msg) + return + # Templates have no concrete hyperparameters by design. + for _featurizer in iter_featurizer_configs(config): + pass diff --git a/drevalpy/models/tuning/hpo.py b/drevalpy/models/tuning/hpo.py new file mode 100644 index 000000000..724f4f4d5 --- /dev/null +++ b/drevalpy/models/tuning/hpo.py @@ -0,0 +1,248 @@ +"""Optuna hyperparameter optimization for DRPModel experiments.""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import numpy as np +from upath import UPath as Path + +from drevalpy.log import get_logger +from drevalpy.models.drp_model import DRPModel +from drevalpy.models.tuning.config import HPOConfig, validate_hpo_metric +from drevalpy.models.tuning.config_resolution import ( + has_tunable_hyperparameters, + tuned_config_for_drp_model, +) +from drevalpy.models.tuning.hpo_runtime import ( + _construct_trial_model, + _mu_evaluate_trial_all_metrics, + run_optuna_study, +) +from drevalpy.models.tuning.public_flat import public_hyperparameters_from_config +from drevalpy.models.tuning.search_space import sample_from_optuna_trial +from drevalpy.types import SplitMask +from drevalpy.types.data.dataset import Dataset + +if TYPE_CHECKING: + from sklearn.base import TransformerMixin + +logger = get_logger(__name__) + + +class HPOTrialsFailedError(RuntimeError): + """Every hyperparameter trial raised, so tuning produced no information. + + Falling back to defaults here would report a tuning result for a run where + nothing was tuned, and bury the real cause - a missing native library, a bad + search space, a bug in the model - behind a later, unrelated traceback. + """ + + +@dataclass +class _TrialFailures: + """Count of trials that raised, plus the first exception, for reporting.""" + + count: int = 0 + first: BaseException | None = None + + def record(self, exc: BaseException) -> None: + """Record a raising trial, keeping the first exception as the cause. + + :param exc: Exception raised by the trial. + """ + self.count += 1 + if self.first is None: + self.first = exc + + +def _is_valid_score(value: float) -> bool: + return bool(np.isfinite(value)) + + +def hpam_tune( + *, + model_class: type[DRPModel], + mudataset: Dataset, + train_scope: SplitMask, + val_scope: SplitMask, + early_stopping_scope: SplitMask | None, + response_transformation: TransformerMixin | None = None, + metric: str = "RMSE", + model_checkpoint_dir: str | Path | None = None, + hpo_config: HPOConfig | None = None, + split_index: int | None = None, + wandb_project: str | None = None, + wandb_base_config: dict[str, Any] | None = None, + precomputed_only: bool = False, +) -> tuple[dict[str, Any], list[tuple[dict[str, Any], dict[str, float], np.ndarray]]]: + """Tune hyperparameters using Dataset + SplitMask with Optuna. + + Returns the best hyperparameter mapping and per-trial results with all metrics. + + :param model_class: Model class to tune. + :param mudataset: Full dataset with all features. + :param train_scope: Training SplitMask. + :param val_scope: Validation SplitMask for scoring. + :param early_stopping_scope: Optional early-stopping scope. + :param response_transformation: Optional response transformer. + :param metric: Metric to optimize. + :param model_checkpoint_dir: Directory for model checkpoints. + :param hpo_config: HPO configuration. + :param split_index: CV fold index for W&B logging. + :param wandb_project: W&B project name. + :param wandb_base_config: Base W&B config merged per trial. + :param precomputed_only: When True, restrict fixed featurizer HP params to + stored variants. (Search space restriction is a TODO; plumbing is in place.) + :returns: Tuple of (best_params, trial_results) where trial_results is a + list of (hyperparameters, metrics_dict, predictions) tuples for each completed trial. + :raises ValueError: Raised when ``hpo_config.metric`` disagrees with ``metric``. + :raises HPOTrialsFailedError: Raised when every trial raised an exception, so no + tuning information was produced. + """ + validate_hpo_metric(metric) + cfg = hpo_config or HPOConfig.from_metric(metric) + if cfg.metric != metric: + msg = f"HPOConfig.metric ({cfg.metric!r}) must match metric argument ({metric!r})" + raise ValueError(msg) + + structured_space = model_class.get_structured_hyperparameter_space() + if not structured_space or not has_tunable_hyperparameters(model_class): + return model_class.get_default_hyperparameters(), [] + if cfg.n_trials == 0: + return model_class.get_default_hyperparameters(), [] + + # TODO: When precomputed_only is True, query list_stored_variants(mdata) for each + # precomputable featurizer in the model config and restrict its HP params in + # structured_space to categorical choices over stored variant values. + _ = precomputed_only + + model_name = model_class.get_model_name() + all_trial_data: list[tuple[dict[str, Any], dict[str, float], np.ndarray]] = [] + failures = _TrialFailures() + + def objective_with_metrics(trial: Any) -> float: + sampled = sample_from_optuna_trial(trial, structured_space) + trial_model = _construct_trial_model(model_class, sampled) + + try: + metrics, predictions = _mu_evaluate_trial_all_metrics( + trial_model, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=early_stopping_scope, + response_transformation=response_transformation, + model_checkpoint_dir=model_checkpoint_dir, + trial_number=trial.number, + ) + except Exception as exc: + # Returning NaN rather than re-raising keeps the study running, so a + # search space with a few invalid corners still tunes. Whether the + # whole run was a fault is decided once, in _report_trial_failures. + failures.record(exc) + logger.exception("Optuna trial %d failed", trial.number) + return float("nan") + + if metrics: + all_trial_data.append((sampled, metrics, predictions)) + + if wandb_project: + _log_trial_to_wandb( + wandb_project=wandb_project, + wandb_base_config=wandb_base_config, + model_name=model_name, + split_index=split_index, + trial_number=trial.number, + sampled=sampled, + metrics=metrics, + metric=metric, + ) + + target = metrics.get(metric, float("nan")) + return target if _is_valid_score(target) else float("nan") + + study = run_optuna_study(objective=objective_with_metrics, cfg=cfg) + _report_trial_failures(study, failures) + best_params = _resolve_best_params(study, model_class) + return best_params, all_trial_data + + +def _report_trial_failures(study, failures: _TrialFailures) -> None: + """Raise when no trial survived; warn when only some did. + + :param study: Completed Optuna study, used for the number of trials actually run. + :param failures: Recorded trial failures. + :raises HPOTrialsFailedError: Raised when every trial raised, chaining the first + exception so the original cause heads the traceback. + """ + if failures.count == 0: + return + + total = len(study.trials) + if failures.count < total: + logger.warning( + "%d of %d hyperparameter trials failed; tuning used the %d that survived", + failures.count, + total, + total - failures.count, + ) + return + + msg = f"All {total} hyperparameter trials failed with {type(failures.first).__name__}: {failures.first}" + raise HPOTrialsFailedError(msg) from failures.first + + +def _resolve_best_params(study, model_class: type[DRPModel]) -> dict[str, Any]: + """Extract best hyperparameters from a completed Optuna study.""" + try: + best_trial = study.best_trial + except ValueError: + best_trial = None + + if best_trial is None or not _is_valid_score(best_trial.value): + warnings.warn( + "Optuna tuning did not find a valid configuration; using defaults.", + stacklevel=2, + ) + return model_class.get_default_hyperparameters() + + best_config = best_trial.params + best_model_config = tuned_config_for_drp_model(model_class, best_config) + if best_model_config is None: + return dict(best_config) + return public_hyperparameters_from_config(best_model_config) + + +def _log_trial_to_wandb( + *, + wandb_project: str, + wandb_base_config: dict[str, Any] | None, + model_name: str, + split_index: int | None, + trial_number: int, + sampled: dict[str, Any], + metrics: dict[str, float], + metric: str, +) -> None: + """Log a single HPO trial to W&B.""" + try: + import wandb + except ImportError: + return + + run_name = f"{model_name}_split{split_index}_trial{trial_number}" + run_config = dict(wandb_base_config or {}) + run_config.update(sampled) + run = wandb.init( + project=wandb_project, + name=run_name, + config=run_config, + reinit=True, + ) + if run is not None: + target_value = metrics.get(metric, float("nan")) + wandb.log({"hpo_metric": target_value, **metrics}) + run.finish() diff --git a/drevalpy/models/tuning/hpo_runtime.py b/drevalpy/models/tuning/hpo_runtime.py new file mode 100644 index 000000000..4ebf97e2d --- /dev/null +++ b/drevalpy/models/tuning/hpo_runtime.py @@ -0,0 +1,316 @@ +"""Optuna-based hyperparameter optimization runtime for component HPO.""" + +from __future__ import annotations + +import tempfile +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +import numpy as np +from upath import UPath as Path + +from drevalpy.evaluation import AVAILABLE_METRICS +from drevalpy.log import get_logger +from drevalpy.models.tuning.config import HPOConfig +from drevalpy.models.tuning.config_resolution import ( + construct_drp_model_from_config, + tuned_config_for_drp_model, +) +from drevalpy.models.tuning.search_space import sample_from_optuna_trial +from drevalpy.types import SplitMask +from drevalpy.types.data.dataset import Dataset +from drevalpy.utils.response_transform import fit_response_transformation + +if TYPE_CHECKING: + import optuna + from sklearn.base import TransformerMixin + + from drevalpy.models.drp_model import DRPModel + +logger = get_logger(__name__) + + +def _trial_checkpoint_dir(base_dir: str | Path | None, trial_number: int) -> Path | None: + """Return a per-trial subdirectory of *base_dir*. + + :param base_dir: Root checkpoint directory, or ``None`` for a temporary one. + :param trial_number: Optuna trial number. + :returns: The trial subdirectory, or ``None`` when a temporary one should be used. + """ + if base_dir is None: + return None + path = Path(base_dir) / f"trial_{trial_number}" + path.mkdir(parents=True, exist_ok=True) + return path + + +def _construct_trial_model(model_class: type[DRPModel], sampled: dict[str, Any]) -> DRPModel: + trial_config = tuned_config_for_drp_model(model_class, sampled) + if trial_config is None: + return model_class(sampled) + return construct_drp_model_from_config(model_class, trial_config) + + +def _extract_ground_truth(mudataset: Dataset, scope: SplitMask) -> np.ndarray: + """Extract ground truth response values from Dataset for the given scope. + + :param mudataset: Source of response values. + :param scope: SplitMask with 2D pair array. + :returns: 1-D array of non-NaN ground-truth response values. + """ + response_matrix = mudataset.response_matrix + pairs = scope.pairs + cl_idx = pairs[:, 0] + dr_idx = pairs[:, 1] + + responses = response_matrix[cl_idx, dr_idx] + values = responses[~np.isnan(responses)] + + return values.astype(np.float64) + + +def _mu_evaluate_trial_model( + trial_model: DRPModel, + *, + metric: str, + mudataset: Dataset, + train_scope: SplitMask, + val_scope: SplitMask, + early_stopping_scope: SplitMask | None, + response_transformation: TransformerMixin | None, + model_checkpoint_dir: str | Path | None, + trial_number: int = 0, +) -> float: + """Train a trial model and compute a validation metric using Dataset + SplitMask.""" + fitted_transform = fit_response_transformation(response_transformation, mudataset, train_scope) + trial_dir = _trial_checkpoint_dir(model_checkpoint_dir, trial_number) + if trial_dir is not None: + trial_model.train( + mudataset=mudataset, + scope=train_scope, + early_stopping_scope=early_stopping_scope, + model_checkpoint_dir=str(trial_dir), + response_transformation=fitted_transform, + ) + else: + with tempfile.TemporaryDirectory() as checkpoint_dir: + trial_model.train( + mudataset=mudataset, + scope=train_scope, + early_stopping_scope=early_stopping_scope, + model_checkpoint_dir=checkpoint_dir, + response_transformation=fitted_transform, + ) + + predictions = trial_model.predict(mudataset=mudataset, scope=val_scope) + + if fitted_transform is not None: + predictions = fitted_transform.inverse_transform(predictions.reshape(-1, 1)).ravel() + + ground_truth = _extract_ground_truth(mudataset, val_scope) + + if len(predictions) != len(ground_truth): + min_len = min(len(predictions), len(ground_truth)) + predictions = predictions[:min_len] + ground_truth = ground_truth[:min_len] + + if len(predictions) == 0: + return float("nan") + + # Filter out NaN predictions (from pairs with missing features) + valid = ~np.isnan(predictions) & ~np.isnan(ground_truth) + if not valid.any(): + return float("nan") + predictions = predictions[valid] + ground_truth = ground_truth[valid] + + metric_fn = AVAILABLE_METRICS.get(metric) + if metric_fn is None: + return float("nan") + return float(metric_fn(y_pred=predictions, y_true=ground_truth)) + + +def _mu_evaluate_trial_all_metrics( + trial_model: DRPModel, + *, + mudataset: Dataset, + train_scope: SplitMask, + val_scope: SplitMask, + early_stopping_scope: SplitMask | None, + response_transformation: TransformerMixin | None, + model_checkpoint_dir: str | Path | None, + trial_number: int = 0, +) -> tuple[dict[str, float], np.ndarray]: + """Train a trial model and compute all validation metrics. + + :returns: Tuple of (metrics_dict, predictions_array). + """ + fitted_transform = fit_response_transformation(response_transformation, mudataset, train_scope) + trial_dir = _trial_checkpoint_dir(model_checkpoint_dir, trial_number) + if trial_dir is not None: + trial_model.train( + mudataset=mudataset, + scope=train_scope, + early_stopping_scope=early_stopping_scope, + model_checkpoint_dir=str(trial_dir), + response_transformation=fitted_transform, + ) + else: + with tempfile.TemporaryDirectory() as checkpoint_dir: + trial_model.train( + mudataset=mudataset, + scope=train_scope, + early_stopping_scope=early_stopping_scope, + model_checkpoint_dir=checkpoint_dir, + response_transformation=fitted_transform, + ) + + predictions = trial_model.predict(mudataset=mudataset, scope=val_scope) + + if fitted_transform is not None: + predictions = fitted_transform.inverse_transform(predictions.reshape(-1, 1)).ravel() + + ground_truth = _extract_ground_truth(mudataset, val_scope) + + if len(predictions) != len(ground_truth): + min_len = min(len(predictions), len(ground_truth)) + predictions = predictions[:min_len] + ground_truth = ground_truth[:min_len] + + if len(predictions) == 0: + return {}, predictions + + valid = ~np.isnan(predictions) & ~np.isnan(ground_truth) + if not valid.any(): + return {}, predictions + + metrics: dict[str, float] = {} + for name, fn in AVAILABLE_METRICS.items(): + metrics[name] = float(fn(y_pred=predictions[valid], y_true=ground_truth[valid])) + return metrics, predictions + + +def _wandb_trial_run_config( + *, + trial_model: DRPModel, + cfg: HPOConfig, + wandb_base_config: dict[str, Any] | None, + trial_number: int, +) -> dict[str, Any]: + trial_run_config: dict[str, Any] = { + "phase": "hyperparameter_tuning", + "hpo_backend": "optuna", + "hpo_num_samples": cfg.n_trials, + "hyperparameters": trial_model.hyperparameters, + "trial_number": trial_number, + } + if wandb_base_config is not None: + trial_run_config = {**wandb_base_config, **trial_run_config} + return trial_run_config + + +def _wandb_trial_run_name(*, model_name: str, split_index: int | None, trial_number: int) -> str: + trial_run_name = model_name + if split_index is not None: + trial_run_name += f"_split_{split_index}" + return f"{trial_run_name}_trial_{trial_number}" + + +def _init_trial_wandb( + trial_model: DRPModel, + *, + wandb_project: str, + wandb_base_config: dict[str, Any] | None, + cfg: HPOConfig, + model_name: str, + split_index: int | None, + trial_number: int, +) -> None: + trial_model.init_wandb( + project=wandb_project, + config=_wandb_trial_run_config( + trial_model=trial_model, + cfg=cfg, + wandb_base_config=wandb_base_config, + trial_number=trial_number, + ), + name=_wandb_trial_run_name(model_name=model_name, split_index=split_index, trial_number=trial_number), + tags=[model_name, "hpam_tuning", "optuna"], + finish_previous=True, + ) + + +def _optuna_objective( + trial: optuna.Trial, + *, + model_class: type[DRPModel], + mudataset: Dataset, + train_scope: SplitMask, + val_scope: SplitMask, + early_stopping_scope: SplitMask | None, + response_transformation: TransformerMixin | None, + metric: str, + structured_space: dict[str, Any], + model_checkpoint_dir: str | Path | None, + cfg: HPOConfig, + wandb_project: str | None, + wandb_base_config: dict[str, Any] | None, + split_index: int | None, + model_name: str, +) -> float: + """Optuna objective function: sample params, train, evaluate, return score.""" + sampled = sample_from_optuna_trial(trial, structured_space) + trial_model = _construct_trial_model(model_class, sampled) + + if wandb_project is not None: + trial_model._in_hyperparameter_tuning = True + _init_trial_wandb( + trial_model, + wandb_project=wandb_project, + wandb_base_config=wandb_base_config, + cfg=cfg, + model_name=model_name, + split_index=split_index, + trial_number=trial.number, + ) + + try: + score = _mu_evaluate_trial_model( + trial_model, + metric=metric, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=early_stopping_scope, + response_transformation=response_transformation, + model_checkpoint_dir=model_checkpoint_dir, + trial_number=trial.number, + ) + except Exception: + logger.exception("Optuna trial %d failed", trial.number) + score = float("nan") + finally: + if wandb_project is not None and trial_model.is_wandb_enabled(): + trial_model.finish_wandb() + + return score + + +def run_optuna_study( + *, + objective: Callable[[optuna.Trial], float], + cfg: HPOConfig, +) -> optuna.Study: + """Create and run an Optuna study. + + :param objective: The objective function. + :param cfg: HPO configuration. + :returns: Completed Optuna study. + """ + import optuna + + direction = "minimize" if cfg.mode == "min" else "maximize" + sampler = optuna.samplers.TPESampler(seed=cfg.random_state) + study = optuna.create_study(direction=direction, sampler=sampler) + study.optimize(objective, n_trials=cfg.n_trials) + return study diff --git a/drevalpy/models/tuning/hyperparameter_export.py b/drevalpy/models/tuning/hyperparameter_export.py new file mode 100644 index 000000000..2a9880d3e --- /dev/null +++ b/drevalpy/models/tuning/hyperparameter_export.py @@ -0,0 +1,184 @@ +"""Export public hyperparameter mappings from model configurations.""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any + +from drevalpy.components.featurizers._featurizer_tree import iter_featurizer_leaves +from drevalpy.models._hp_key_grammar import featurizer_prefix, predictor_prefix +from drevalpy.models.config import FeaturizerConfig, ModelConfig +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from drevalpy.registry.predictor import get as get_predictor + +from .hyperparameter_keys import ( + HyperparameterOwnershipIndex, + HyperparameterTarget, + _leaf_selector, + build_ownership_index, +) + + +def _predictor_value(config: ModelConfig, param: str, predictor_cls: type[Any]) -> Any | None: + space = ( + dict(config.predictor.hyperparameter_space) + if config.predictor.hyperparameter_space is not None + else dict(predictor_cls.get_hyperparameter_space()) + ) + if param in space: + return space[param]["default"] + defaults = predictor_cls.get_default_hyperparameters() + if param in defaults: + return defaults[param] + return None + + +def _predictor_export_params(config: ModelConfig, predictor_cls: type[Any]) -> list[str]: + keys = set(predictor_cls.get_default_hyperparameters()) + keys.update(predictor_cls.get_hyperparameter_space()) + if config.predictor.hyperparameter_space is not None: + keys.update(config.predictor.hyperparameter_space) + return sorted(keys) + + +def _featurizer_export_params(featurizer: FeaturizerConfig, registry: str) -> list[str]: + cls = get_cell_line_featurizer(featurizer.name) if registry == "cell_line" else get_drug_featurizer(featurizer.name) + space = ( + dict(featurizer.hyperparameter_space) + if featurizer.hyperparameter_space is not None + else dict(cls.get_hyperparameter_space()) + ) + return sorted(space) + + +def _featurizer_value(featurizer: FeaturizerConfig, param: str, registry: str) -> Any | None: + cls = get_cell_line_featurizer(featurizer.name) if registry == "cell_line" else get_drug_featurizer(featurizer.name) + space = ( + dict(featurizer.hyperparameter_space) + if featurizer.hyperparameter_space is not None + else dict(cls.get_hyperparameter_space()) + ) + if param not in space: + return None + return space[param]["default"] + + +def _append_export_entry( + entries: list[tuple[HyperparameterTarget, Any]], + *, + qualified: str, + index: HyperparameterOwnershipIndex, + concrete: dict[str, Any], + default_value: Any | None, +) -> None: + target = index.qualified_to_target[qualified] + if qualified in concrete: + entries.append((target, concrete[qualified])) + return + if default_value is not None: + entries.append((target, default_value)) + + +def _collect_predictor_export_entries( + config: ModelConfig, + index: HyperparameterOwnershipIndex, + concrete: dict[str, Any], +) -> list[tuple[HyperparameterTarget, Any]]: + entries: list[tuple[HyperparameterTarget, Any]] = [] + predictor_cls = get_predictor(config.predictor.name) + for param in _predictor_export_params(config, predictor_cls): + qualified = predictor_prefix(config.predictor.name, param) + _append_export_entry( + entries, + qualified=qualified, + index=index, + concrete=concrete, + default_value=_predictor_value(config, param, predictor_cls), + ) + return entries + + +def _collect_featurizer_export_entries( + config: ModelConfig, + index: HyperparameterOwnershipIndex, + concrete: dict[str, Any], +) -> list[tuple[HyperparameterTarget, Any]]: + entries: list[tuple[HyperparameterTarget, Any]] = [] + for registry, slot_config in ( + ("cell_line", config.cell_line_featurizer), + ("drug", config.drug_featurizer), + ): + if slot_config is None: + continue + for leaf in iter_featurizer_leaves(slot_config, registry): + selector = _leaf_selector(leaf) + for param in _featurizer_export_params(leaf, registry): + qualified = featurizer_prefix(registry, selector, param) + _append_export_entry( + entries, + qualified=qualified, + index=index, + concrete=concrete, + default_value=_featurizer_value(leaf, param, registry), + ) + return entries + + +def _collect_export_entries( + config: ModelConfig, + index: HyperparameterOwnershipIndex, + *, + values: dict[str, Any] | None = None, +) -> list[tuple[HyperparameterTarget, Any]]: + concrete = values or {} + entries = _collect_predictor_export_entries(config, index, concrete) + entries.extend(_collect_featurizer_export_entries(config, index, concrete)) + return entries + + +def _compact_export_entries( + entries: list[tuple[HyperparameterTarget, Any]], +) -> dict[str, Any]: + grouped: dict[str, list[tuple[HyperparameterTarget, Any]]] = defaultdict(list) + for target, value in entries: + grouped[target.param].append((target, value)) + + exported: dict[str, Any] = {} + for param in sorted(grouped): + owners = grouped[param] + if len(owners) == 1: + exported[param] = owners[0][1] + continue + for target, value in sorted(owners, key=lambda item: item[0].qualified_key): + exported[target.qualified_key] = value + return exported + + +def export_public_mapping( + config: ModelConfig, + *, + values: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Export a deterministic collision-aware public hyperparameter mapping. + + :param config: Template model configuration. + :param values: Optional concrete qualified values from a resolved config. + :returns: Result. + """ + index = build_ownership_index(config) + return _compact_export_entries(_collect_export_entries(config, index, values=values)) + + +def export_public_mapping_from_resolved( + resolved: Any, +) -> dict[str, Any]: + """Export public hyperparameters from a resolved instance config. + + :param resolved: ``ResolvedModelConfig`` instance. + :returns: Compact public hyperparameter mapping. + """ + return export_public_mapping( + resolved.template, + values=dict(resolved.values), + ) diff --git a/drevalpy/models/tuning/hyperparameter_keys.py b/drevalpy/models/tuning/hyperparameter_keys.py new file mode 100644 index 000000000..0652a9588 --- /dev/null +++ b/drevalpy/models/tuning/hyperparameter_keys.py @@ -0,0 +1,237 @@ +"""Ownership indexes for public and structured hyperparameter keys.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import Any + +from drevalpy.components.featurizers._featurizer_tree import iter_featurizer_leaves +from drevalpy.models._hp_key_grammar import ( + CELL_LINE_SLOT, + DRUG_SLOT, + PREDICTOR_SLOT, + featurizer_prefix, + predictor_prefix, + split_predictor_key, + split_prefixed_key, +) +from drevalpy.models.config import FeaturizerConfig, ModelConfig +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from drevalpy.registry.predictor import get as get_predictor + + +@dataclass(frozen=True, slots=True) +class HyperparameterTarget: + """One public hyperparameter slot on a composed model stack.""" + + slot: str + selector: str + param: str + + @property + def qualified_key(self) -> str: + """Return the fully qualified public key for this target. + + :returns: Result. + """ + if self.slot == PREDICTOR_SLOT: + return predictor_prefix(self.selector, self.param) + return featurizer_prefix( + "cell_line" if self.slot == CELL_LINE_SLOT else "drug", + self.selector, + self.param, + ) + + +@dataclass(frozen=True, slots=True) +class HyperparameterOwnershipIndex: + """Maps short, qualified, and alias keys to component targets.""" + + targets: tuple[HyperparameterTarget, ...] + qualified_to_target: dict[str, HyperparameterTarget] + short_to_targets: dict[str, tuple[HyperparameterTarget, ...]] + alias_to_qualified: dict[str, str] + + +def _leaf_selector(featurizer: FeaturizerConfig) -> str: + from drevalpy.components.featurizers._featurizer_label import qualified_featurizer_selector + + return qualified_featurizer_selector(featurizer.name, featurizer.view) + + +def _predictor_accepted_keys(predictor_cls: type[Any]) -> set[str]: + keys = set(predictor_cls.get_default_hyperparameters()) + keys.update(predictor_cls.get_hyperparameter_space()) + non_tunable = getattr(predictor_cls, "non_tunable_hyperparameters", None) + if isinstance(non_tunable, dict): + keys.update(non_tunable) + elif isinstance(non_tunable, (set, frozenset, list, tuple)): + keys.update(str(key) for key in non_tunable) + return keys + + +def _featurizer_accepted_keys(featurizer: FeaturizerConfig, registry: str) -> set[str]: + cls = get_cell_line_featurizer(featurizer.name) if registry == "cell_line" else get_drug_featurizer(featurizer.name) + return set(cls.get_hyperparameter_space()) + + +def _append_featurizer_targets( + targets: list[HyperparameterTarget], + featurizer: FeaturizerConfig, + *, + slot: str, + registry: str, +) -> None: + selector = _leaf_selector(featurizer) + for param in sorted(_featurizer_accepted_keys(featurizer, registry)): + targets.append(HyperparameterTarget(slot=slot, selector=selector, param=param)) + + +def _alias_targets(targets: tuple[HyperparameterTarget, ...]) -> dict[str, str]: + aliases: dict[str, str] = {} + qualified_set = {target.qualified_key for target in targets} + + methylation_key = featurizer_prefix("cell_line", "pca[methylation]", "n_components") + if methylation_key in qualified_set: + aliases["methylation_n_components"] = methylation_key + aliases["methylation_pca_components"] = methylation_key + + return aliases + + +def build_ownership_index(config: ModelConfig) -> HyperparameterOwnershipIndex: + """Build ownership indexes for every accepted public hyperparameter. + + :param config: config. + :returns: Result. + """ + targets: list[HyperparameterTarget] = [] + + predictor_cls = get_predictor(config.predictor.name) + for param in sorted(_predictor_accepted_keys(predictor_cls)): + targets.append( + HyperparameterTarget( + slot=PREDICTOR_SLOT, + selector=config.predictor.name, + param=param, + ), + ) + + if config.cell_line_featurizer is not None: + for leaf in iter_featurizer_leaves(config.cell_line_featurizer, "cell_line"): + _append_featurizer_targets( + targets, + leaf, + slot=CELL_LINE_SLOT, + registry="cell_line", + ) + + if config.drug_featurizer is not None: + for leaf in iter_featurizer_leaves(config.drug_featurizer, "drug"): + _append_featurizer_targets( + targets, + leaf, + slot=DRUG_SLOT, + registry="drug", + ) + + qualified_to_target = {target.qualified_key: target for target in targets} + short_groups: dict[str, list[HyperparameterTarget]] = defaultdict(list) + for target in targets: + short_groups[target.param].append(target) + + target_tuple = tuple(targets) + return HyperparameterOwnershipIndex( + targets=target_tuple, + qualified_to_target=qualified_to_target, + short_to_targets={key: tuple(group) for key, group in short_groups.items()}, + alias_to_qualified=_alias_targets(target_tuple), + ) + + +def _parse_qualified_key(key: str, config: ModelConfig) -> str | None: + predictor_parsed = split_predictor_key(key) + if predictor_parsed is not None: + predictor_name, _param = predictor_parsed + if predictor_name != config.predictor.name: + msg = ( + f"Unknown hyperparameter {key!r}: predictor {predictor_name!r} " + f"does not match stack predictor {config.predictor.name!r}." + ) + raise ValueError(msg) + return key + + featurizer_parsed = split_prefixed_key(key) + if featurizer_parsed is not None: + registry, selector, param = featurizer_parsed + return featurizer_prefix(registry, selector, param) + + if key.startswith(f"{PREDICTOR_SLOT}."): + msg = f"Unknown hyperparameter {key!r}: expected predictor..." + raise ValueError(msg) + if key.startswith((CELL_LINE_SLOT + ".", DRUG_SLOT + ".")): + msg = f"Unknown hyperparameter {key!r}: expected ..." + raise ValueError(msg) + return None + + +def _resolve_one_public_key( + key: str, + config: ModelConfig, + index: HyperparameterOwnershipIndex, +) -> str: + if key in index.alias_to_qualified: + return index.alias_to_qualified[key] + if key in index.qualified_to_target: + return key + if "." in key: + parsed = _parse_qualified_key(key, config) + if parsed is None or parsed not in index.qualified_to_target: + msg = f"Unknown hyperparameter {key!r} for this model stack." + raise ValueError(msg) + return parsed + if key in index.short_to_targets: + owners = index.short_to_targets[key] + if len(owners) > 1: + alternatives = ", ".join(repr(owner.qualified_key) for owner in owners) + msg = f"Ambiguous hyperparameter {key!r}. Use one of: {alternatives}." + raise ValueError(msg) + return owners[0].qualified_key + msg = f"Unknown hyperparameter {key!r} for this model stack." + raise ValueError(msg) + + +def resolve_to_qualified_mapping( + config: ModelConfig, + mapping: dict[str, Any], + index: HyperparameterOwnershipIndex, + *, + reserved_keys: frozenset[str], +) -> dict[str, Any]: + """Resolve a public mapping to qualified keys with strict collision checks. + + :param config: config. + :param mapping: mapping. + :param index: index. + :param reserved_keys: reserved keys. + :returns: Result. + :raises ValueError: Raised on invalid input. + """ + qualified: dict[str, Any] = {} + seen_targets: dict[HyperparameterTarget, str] = {} + + for key, value in mapping.items(): + if key in reserved_keys: + continue + qualified_key = _resolve_one_public_key(key, config, index) + target = index.qualified_to_target[qualified_key] + if target in seen_targets: + previous = seen_targets[target] + msg = f"Duplicate hyperparameter assignment for {qualified_key!r} from {previous!r} and {key!r}." + raise ValueError(msg) + seen_targets[target] = key + qualified[qualified_key] = value + + return qualified diff --git a/drevalpy/models/tuning/public_flat.py b/drevalpy/models/tuning/public_flat.py new file mode 100644 index 000000000..4c945cc8c --- /dev/null +++ b/drevalpy/models/tuning/public_flat.py @@ -0,0 +1,76 @@ +"""Translate between public hyperparameter mappings and resolved configs.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.models.config import ModelConfig +from drevalpy.models.config.resolved import ResolvedModelConfig +from drevalpy.models.tuning.hyperparameter_export import ( + export_public_mapping, + export_public_mapping_from_resolved, +) +from drevalpy.models.tuning.hyperparameter_keys import ( + build_ownership_index, + resolve_to_qualified_mapping, +) + +from .search_space import resolve_model_config + + +def apply_public_hyperparameters_to_config( + config: ModelConfig, + mapping: dict[str, Any], +) -> ResolvedModelConfig: + """Apply a collision-aware public hyperparameter mapping onto a template. + + :param config: Immutable ``ModelConfig`` template. + :param mapping: Public flat hyperparameter mapping. + :returns: Resolved instance configuration. + """ + if not mapping: + return resolve_model_config(config) + + normalized = dict(mapping) + if "methylation_n_components" not in normalized and "methylation_pca_components" in normalized: + normalized["methylation_n_components"] = normalized.pop("methylation_pca_components") + + index = build_ownership_index(config) + qualified = resolve_to_qualified_mapping( + config, + normalized, + index, + reserved_keys=frozenset(), + ) + return resolve_model_config(config, qualified) + + +def public_hyperparameters_from_config( + config: ModelConfig | ResolvedModelConfig, +) -> dict[str, Any]: + """Export a model config into a collision-aware public hyperparameter mapping. + + :param config: Template or resolved configuration. + :returns: Result. + """ + if isinstance(config, ResolvedModelConfig): + return export_public_mapping_from_resolved(config) + return export_public_mapping(config) + + +def config_from_public_hyperparameters( + model_class: type[Any], + hyperparameters: dict[str, Any] | None, +) -> ResolvedModelConfig | None: + """Convert a public hyperparameter mapping into a resolved config. + + :param model_class: model class. + :param hyperparameters: hyperparameters. + :returns: Result. + """ + config = model_class._resolve_base_config() + if config is None: + return None + if not hyperparameters: + return resolve_model_config(config) + return apply_public_hyperparameters_to_config(config, hyperparameters) diff --git a/drevalpy/models/tuning/search_space.py b/drevalpy/models/tuning/search_space.py new file mode 100644 index 000000000..814910746 --- /dev/null +++ b/drevalpy/models/tuning/search_space.py @@ -0,0 +1,256 @@ +"""Hyperparameter search space utilities for internal modular composition.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from drevalpy.components.contracts.hyperparameter_space import validate_hyperparameter_space +from drevalpy.components.featurizers._featurizer_label import qualified_featurizer_selector +from drevalpy.components.featurizers._featurizer_tree import iter_featurizer_leaves +from drevalpy.models._hp_key_grammar import ( + CELL_LINE_SLOT, + DRUG_SLOT, + PREDICTOR_SLOT, + featurizer_prefix, + is_featurizer_slot_key, + predictor_prefix, +) +from drevalpy.models.config import FeaturizerConfig, ModelConfig, PredictorConfig +from drevalpy.models.config._hp_key_validation import validate_merged_mapping +from drevalpy.models.config.resolved import ResolvedModelConfig +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from drevalpy.registry.predictor import get as get_predictor + + +def _effective_space(config_space: Mapping[str, Any] | None, cls: type[Any]) -> dict[str, Any]: + if config_space is not None: + return {key: dict(value) if isinstance(value, Mapping) else value for key, value in config_space.items()} + return dict(cls.get_hyperparameter_space()) + + +def _leaf_selector(featurizer: FeaturizerConfig) -> str: + return qualified_featurizer_selector(featurizer.name, featurizer.view) + + +def _accepted_featurizer_selectors(featurizer: FeaturizerConfig, registry: str) -> list[str]: + return [_leaf_selector(leaf) for leaf in iter_featurizer_leaves(featurizer, registry)] + + +def _merge_concat_child_spaces( + featurizer: FeaturizerConfig, + registry: str, +) -> dict[str, Any]: + merged: dict[str, Any] = {} + for child_cfg in featurizer.featurizers or (): + child_space = _featurizer_spaces(child_cfg) + for key, spec in child_space.items(): + if is_featurizer_slot_key(key): + merged[key] = spec + else: + selector = _leaf_selector(child_cfg) + merged[featurizer_prefix(registry, selector, key)] = spec + return merged + + +def _leaf_featurizer_spaces(featurizer: FeaturizerConfig, registry: str) -> dict[str, Any]: + cls = ( + get_cell_line_featurizer(featurizer.name) + if registry == "cell_line" + else get_drug_featurizer( + featurizer.name, + ) + ) + space = _effective_space( + dict(featurizer.hyperparameter_space) if featurizer.hyperparameter_space is not None else None, + cls, + ) + selector = _leaf_selector(featurizer) + return {featurizer_prefix(registry, selector, key): value for key, value in space.items()} + + +def _featurizer_spaces(featurizer: FeaturizerConfig) -> dict[str, Any]: + registry = str(featurizer.registry) + if featurizer.name == "concatFeaturizers": + return _merge_concat_child_spaces(featurizer, registry) + return _leaf_featurizer_spaces(featurizer, registry) + + +def _predictor_spaces(predictor: PredictorConfig) -> dict[str, Any]: + cls = get_predictor(predictor.name) + space = _effective_space( + dict(predictor.hyperparameter_space) if predictor.hyperparameter_space is not None else None, + cls, + ) + return {predictor_prefix(predictor.name, key): value for key, value in space.items()} + + +def merge_model_config_spaces(config: ModelConfig) -> dict[str, Any]: + """Merge all component spaces for a declarative model config. + + :param config: config. + :returns: Result. + """ + merged: dict[str, Any] = {} + if config.cell_line_featurizer is not None: + merged.update(_featurizer_spaces(config.cell_line_featurizer)) + if config.drug_featurizer is not None: + merged.update(_featurizer_spaces(config.drug_featurizer)) + merged.update(_predictor_spaces(config.predictor)) + return merged + + +def merge_search_spaces( + cell_line_featurizer_space: dict[str, Any] | None = None, + drug_featurizer_space: dict[str, Any] | None = None, + predictor_space: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Merge component spaces into a single dict with dot-notation prefixed keys. + + :param cell_line_featurizer_space: cell line featurizer space. + :param drug_featurizer_space: drug featurizer space. + :param predictor_space: predictor space. + :returns: Result. + """ + merged: dict[str, Any] = {} + if cell_line_featurizer_space: + for key, value in cell_line_featurizer_space.items(): + merged[f"{CELL_LINE_SLOT}.{key}"] = value + if drug_featurizer_space: + for key, value in drug_featurizer_space.items(): + merged[f"{DRUG_SLOT}.{key}"] = value + if predictor_space: + for key, value in predictor_space.items(): + merged[f"{PREDICTOR_SLOT}.{key}"] = value + return merged + + +def split_hyperparameters( + merged_config: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + """Invert merged search spaces into per-role hyperparameter dicts. + + :param merged_config: merged config. + :returns: Result. + """ + cell_line_hp: dict[str, Any] = {} + drug_hp: dict[str, Any] = {} + predictor_hp: dict[str, Any] = {} + for key, value in merged_config.items(): + if key.startswith(f"{CELL_LINE_SLOT}."): + cell_line_hp[key.removeprefix(f"{CELL_LINE_SLOT}.")] = value + elif key.startswith(f"{DRUG_SLOT}."): + drug_hp[key.removeprefix(f"{DRUG_SLOT}.")] = value + elif key.startswith(f"{PREDICTOR_SLOT}."): + predictor_hp[key.removeprefix(f"{PREDICTOR_SLOT}.")] = value + else: + predictor_hp[key] = value + return cell_line_hp, drug_hp, predictor_hp + + +def resolve_model_config( + template: ModelConfig, + overrides: dict[str, Any] | None = None, + *, + include_defaults: bool = True, +) -> ResolvedModelConfig: + """Build a resolved instance config from a template and qualified overrides. + + :param template: Immutable class-level ``ModelConfig``. + :param overrides: Qualified concrete values to apply on top of defaults. + :param include_defaults: When ``True``, fill omitted keys from effective spaces. + :returns: Validated ``ResolvedModelConfig``. + """ + qualified = dict(overrides or {}) + if include_defaults: + defaults = defaults_from_merged_space(merge_model_config_spaces(template)) + values = {**defaults, **qualified} + else: + values = qualified + validate_merged_mapping(template, values) + return ResolvedModelConfig(template=template, values=values) + + +def apply_merged_to_model_config(config: ModelConfig, merged: dict[str, Any]) -> ResolvedModelConfig: + """Apply merged prefixed hyperparameters onto a model template. + + Historically returned a ``ModelConfig`` with concrete values written into + component ``hyperparameters``. It now returns a ``ResolvedModelConfig`` + and leaves the template unchanged. + + :param config: Immutable model template. + :param merged: Qualified concrete hyperparameter mapping. + :returns: Resolved instance configuration. + """ + return resolve_model_config(config, merged, include_defaults=True) + + +def extract_defaults( + cell_line_featurizer_space: dict[str, Any] | None = None, + drug_featurizer_space: dict[str, Any] | None = None, + predictor_space: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Pull ``default`` values from spec dicts, returning a merged flat dict. + + :param cell_line_featurizer_space: cell line featurizer space. + :param drug_featurizer_space: drug featurizer space. + :param predictor_space: predictor space. + :returns: Result. + """ + defaults: dict[str, Any] = {} + + def _pull(space: dict[str, Any], prefix: str) -> None: + validate_hyperparameter_space(space, context=f"hyperparameter space under {prefix!r}") + for name, spec in space.items(): + defaults[f"{prefix}.{name}"] = spec["default"] + + if cell_line_featurizer_space: + _pull(cell_line_featurizer_space, CELL_LINE_SLOT) + if drug_featurizer_space: + _pull(drug_featurizer_space, DRUG_SLOT) + if predictor_space: + _pull(predictor_space, PREDICTOR_SLOT) + return defaults + + +def defaults_from_merged_space(space: dict[str, Any]) -> dict[str, Any]: + """Extract default values from a merged structured search space. + + :param space: space. + :returns: Result. + """ + validate_hyperparameter_space(space, context="merged hyperparameter space") + return {key: spec["default"] for key, spec in space.items()} + + +def sample_from_optuna_trial(trial: Any, space_dict: dict[str, Any]) -> dict[str, Any]: + """Sample hyperparameters from an Optuna trial using the structured search space. + + :param trial: An ``optuna.Trial`` instance. + :param space_dict: Structured hyperparameter space with entries like + ``{"alpha": {"type": "float", "low": 0.001, "high": 10.0, "log": True}}``. + :returns: Flat dict of sampled concrete hyperparameter values. + :raises ValueError: If a parameter spec has an unknown type. + """ + result: dict[str, Any] = {} + for name, spec in space_dict.items(): + if not isinstance(spec, Mapping): + result[name] = spec + continue + kind = spec.get("type", "categorical") + if kind == "int": + result[name] = trial.suggest_int(name, int(spec["low"]), int(spec["high"]), log=spec.get("log", False)) + elif kind == "float": + result[name] = trial.suggest_float( + name, float(spec["low"]), float(spec["high"]), log=spec.get("log", False) + ) + elif kind == "categorical": + result[name] = trial.suggest_categorical(name, list(spec.get("choices", []))) + elif kind == "pow2": + exp = trial.suggest_int(name, int(spec["low"]), int(spec["high"])) + result[name] = 2**exp + else: + msg = f"Unknown hyperparameter type {kind!r} for parameter {name!r}" + raise ValueError(msg) + return result diff --git a/drevalpy/models/utils.py b/drevalpy/models/utils.py deleted file mode 100644 index e3e835be0..000000000 --- a/drevalpy/models/utils.py +++ /dev/null @@ -1,650 +0,0 @@ -"""Utility functions for loading and processing data.""" - -import os.path - -import numpy as np -import pandas as pd -from sklearn.base import BaseEstimator, TransformerMixin -from sklearn.decomposition import PCA - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER, TISSUE_IDENTIFIER - - -def load_generic_csv(path: str, dataset_name: str, feature_name: str, index_col=CELL_LINE_IDENTIFIER) -> FeatureDataset: - """ - Loads a generic CSV file with cell line IDs as index and features as columns. - - :param path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :param feature_name: name of the feature, e.g., gene_expression - :param index_col: name of the index column, e.g., cell_line_id - :returns: FeatureDataset with the features - """ - feature_csv = pd.read_csv(f"{path}/{dataset_name}/{feature_name}.csv", index_col=index_col) - feature_csv.index = feature_csv.index.astype(str) - if "cellosaurus_id" in feature_csv.columns: - feature_csv = feature_csv.drop(columns=["cellosaurus_id"]) - return FeatureDataset(features=iterate_features(df=feature_csv, feature_type=feature_name)) - - -def iterate_features(df: pd.DataFrame, feature_type: str) -> dict[str, dict[str, np.ndarray]]: - """ - Iterate over features. - - :param df: DataFrame with the features - :param feature_type: type of feature, e.g., gene_expression, methylation, etc. - :returns: dictionary with the features - """ - features: dict[str, dict[str, np.ndarray]] = {} - for cl in df.index: - if cl in features.keys(): - continue - rows = df.loc[cl] - rows = rows.astype(float).to_numpy() - if (len(rows.shape) > 1) and (rows.shape[0] > 1): # multiple rows returned - # take mean - rows = np.mean(rows, axis=0) - features[cl] = {feature_type: rows} - return features - - -def load_cl_ids_from_csv(path: str, dataset_name: str) -> FeatureDataset: - """ - Load cell line ids from csv file. - - :param path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :returns: FeatureDataset with the cell line ids - """ - cl_names = pd.read_csv(f"{path}/{dataset_name}/cell_line_names.csv", index_col=CELL_LINE_IDENTIFIER) - cl_names.index = cl_names.index.astype(str) - return FeatureDataset(features={cl: {CELL_LINE_IDENTIFIER: np.array([cl])} for cl in cl_names.index}) - - -def load_tissues_from_csv(path: str, dataset_name: str) -> FeatureDataset: - """ - Load tissues from csv file. - - :param path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :returns: FeatureDataset with the tissues - """ - tissues = pd.read_csv( - f"{path}/{dataset_name}/cell_line_names.csv", index_col=CELL_LINE_IDENTIFIER - ).drop_duplicates() - return FeatureDataset( - features={cl: {TISSUE_IDENTIFIER: np.array([tissues.loc[cl, TISSUE_IDENTIFIER]])} for cl in tissues.index} - ) - - -def load_cl_ids_and_tissues_from_csv(path: str, dataset_name: str) -> FeatureDataset: - """ - Load cell line ids and optional tissue annotations from csv file. - - :param path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :returns: FeatureDataset with cell line ids and tissue annotations, if available - """ - cl_ids = load_cl_ids_from_csv(path, dataset_name) - try: - cl_ids.add_features(load_tissues_from_csv(path, dataset_name)) - except KeyError: - pass - return cl_ids - - -def load_and_select_gene_features( - feature_type: str, - gene_list: str | None, - data_path: str, - dataset_name: str, -) -> FeatureDataset: - """ - Load and reduce features of a single feature type, ensuring selection and ordering based on the gene list. - - Attention: if gene_list is None, all features are loaded, which can be problematic for cross study prediction. - - :param feature_type: type of feature, e.g., gene_expression, methylation, etc. - :param gene_list: list of genes to include, e.g., landmark_genes - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :returns: FeatureDataset with the reduced features - :raises ValueError: if genes from gene_list are missing in the dataset - """ - ge = pd.read_csv(f"{data_path}/{dataset_name}/{feature_type}.csv", index_col=CELL_LINE_IDENTIFIER) - ge.index = ge.index.astype(str) - if "cellosaurus_id" in ge.columns: - ge = ge.drop(columns=["cellosaurus_id"]) - - cl_features = FeatureDataset( - features=iterate_features(df=ge, feature_type=feature_type), - meta_info={feature_type: ge.columns.values}, - ) - if gene_list is None: - return cl_features - - gene_info = pd.read_csv( - f"{data_path}/meta/gene_lists/{gene_list}.csv", - sep=",", - ) - ordered_genes = gene_info["Symbol"].tolist() - - genes_in_features = set(cl_features.meta_info[feature_type]) - missing_genes = [gene for gene in ordered_genes if gene not in genes_in_features] - - if missing_genes: - missing_str = ( - f"{', '.join(missing_genes[:10])}, ... ({len(missing_genes)} genes in total)" - if len(missing_genes) > 10 - else ", ".join(missing_genes) - ) - raise ValueError( - f"The following genes are missing from the dataset {dataset_name} for {feature_type}: {missing_str}" - ) - - indices_to_keep = [i for i, gene in enumerate(cl_features.meta_info[feature_type]) if gene in ordered_genes] - - cl_features.meta_info[feature_type] = np.array(ordered_genes) - - for cell_line in cl_features.features.keys(): - cl_features.features[cell_line][feature_type] = cl_features.features[cell_line][feature_type][indices_to_keep] - - return cl_features - - -def load_drug_ids_from_csv(data_path: str, dataset_name: str) -> FeatureDataset: - """ - Load drug ids from csv file. - - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :returns: FeatureDataset with the drug ids - """ - drug_names = pd.read_csv( - f"{data_path}/{dataset_name}/drug_names.csv", - index_col=DRUG_IDENTIFIER, - dtype={"pubchem_id": str}, - low_memory=False, - ) - drug_names.index = drug_names.index.astype(str) - return FeatureDataset(features={drug: {DRUG_IDENTIFIER: np.array([drug])} for drug in drug_names.index}) - - -def load_drug_fingerprint_features(data_path: str, dataset_name: str, fill_na=True, n_bits=128) -> FeatureDataset: - """ - Load drug features from fingerprints. - - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :param fill_na: whether to use default pubchemid-hashed fingerprints if fingerprint is not available - :param n_bits: number of bits in the fingerprint - :returns: FeatureDataset with the drug fingerprints - """ - fingerprints = pd.read_csv( - os.path.join(data_path, dataset_name, "drug_fingerprints", f"pubchem_id_to_demorgan_{n_bits}_map.csv"), - index_col=None, - ).T - if fill_na: - for drug in fingerprints.index: - if ( - not fingerprints.loc[drug].isna().all() - ): # if all values are NaN, replace with random fingerprint for the drug - continue - # Create random fingerprint for the drug, which is based on a hash of the pubchemid - rng = np.random.default_rng(hash(drug) % (2**32)) - fingerprints.loc[drug] = rng.integers(0, 2, size=fingerprints.loc[drug].shape) - - return FeatureDataset( - features={drug: {"fingerprints": fingerprints.loc[drug].values} for drug in fingerprints.index} - ) - - -def get_multiomics_feature_dataset( - data_path: str, - dataset_name: str, - gene_lists: dict | None = None, - omics: list[str] | None = None, -) -> FeatureDataset: - """ - Get multiomics feature dataset for the given list of OMICs. - - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC2 - :param gene_lists: dictionary of names of lists of genes to include, for each omics type, - e.g., {"gene_expression": "landmark_genes_reduced"}, if None, all features are not reduced - :param omics: list of omics to include, e.g., ["gene_expression", "methylation"] - :returns: FeatureDataset with the multiomics features - :raises ValueError: if no omics features are found - """ - if omics is None: - omics = ["gene_expression", "methylation", "mutations", "copy_number_variation_gistic", "proteomics"] - - if gene_lists is None: - gene_lists = {o: None for o in omics} - - if not np.all([k in omics for k in gene_lists.keys()]): - raise ValueError("Gene lists must be provided for all omics types.") - - feature_dataset = None - for omic in omics: - if feature_dataset is None: - feature_dataset = load_and_select_gene_features( - feature_type=omic, - gene_list=gene_lists[omic], - data_path=data_path, - dataset_name=dataset_name, - ) - else: - feature_dataset.add_features( - load_and_select_gene_features( - feature_type=omic, - gene_list=gene_lists[omic], - data_path=data_path, - dataset_name=dataset_name, - ) - ) - if feature_dataset is None: - raise ValueError("No omics features found.") - return feature_dataset - - -def unique(array): - """ - Get unique values ordered by first occurrence. - - :param array: array of values - :returns: unique values ordered by first occurrence - """ - uniq, index = np.unique(array, return_index=True) - return uniq[index.argsort()] - - -def prepare_expression_and_methylation( - cell_line_input: FeatureDataset, - cell_line_ids: np.ndarray, - training: bool, - gene_expression_scaler: TransformerMixin | None = None, - methylation_scaler: TransformerMixin | None = None, - methylation_pca: PCA | None = None, -) -> FeatureDataset: - """ - Applies preprocessing to gene expression and optionally methylation views. - - - Applies arcsinh + scaling to gene expression if a scaler is provided. - - Applies scaling + PCA to methylation if both a scaler and PCA are provided. - - Applies to all cell lines in `cell_line_input`, using fitting only on the given IDs if training=True. - - :param cell_line_input: FeatureDataset with the cell line features - :param cell_line_ids: IDs of the cell lines used for training or transformation - :param training: Whether to fit the scalers/PCA (True) or just apply transformation (False) - :param gene_expression_scaler: Optional fitted or to-be-fitted scaler for gene expression - :param methylation_scaler: Optional fitted or to-be-fitted scaler for methylation - :param methylation_pca: Optional PCA transformer for methylation - :returns: FeatureDataset with the transformed features - """ - cell_line_input = cell_line_input.copy() - first_feature = next(iter(cell_line_input.features.values())) - if ("gene_expression" in first_feature.keys()) and (gene_expression_scaler is not None): - cell_line_input.apply(function=np.arcsinh, view="gene_expression") - if training: - cell_line_input.fit_transform_features( - train_ids=cell_line_ids, - transformer=gene_expression_scaler, - view="gene_expression", - ) - else: - cell_line_input.transform_features( - ids=cell_line_ids, - transformer=gene_expression_scaler, - view="gene_expression", - ) - - if ("methylation" in first_feature.keys()) and (methylation_scaler is not None) and (methylation_pca is not None): - if training: - cell_line_input.fit_transform_features( - train_ids=cell_line_ids, - transformer=methylation_scaler, - view="methylation", - ) - # Ensure the number of PCA components does not exceed the number of unique cell lines. - methylation_pca.n_components = min(methylation_pca.n_components, len(np.unique(cell_line_ids))) - cell_line_input.fit_transform_features( - train_ids=cell_line_ids, - transformer=methylation_pca, - view="methylation", - ) - else: - cell_line_input.transform_features( - ids=cell_line_ids, - transformer=methylation_scaler, - view="methylation", - ) - cell_line_input.transform_features( - ids=cell_line_ids, - transformer=methylation_pca, - view="methylation", - ) - return cell_line_input - - -def scale_gene_expression( - cell_line_input: FeatureDataset, - cell_line_ids: np.ndarray, - training: bool, - gene_expression_scaler: TransformerMixin, -) -> FeatureDataset: - """ - Scales gene expression inplace using arcsinh transformation and a provided scaler. - - :param cell_line_input: FeatureDataset with the cell line features - :param cell_line_ids: IDs of cell lines to use for fitting or transformation - :param training: whether to fit or transform - :param gene_expression_scaler: sklearn transformer for gene expression - :returns: FeatureDataset with the transformed features - """ - cell_line_input = prepare_expression_and_methylation( - cell_line_input=cell_line_input, - cell_line_ids=cell_line_ids, - training=training, - gene_expression_scaler=gene_expression_scaler, - ) - return cell_line_input - - -class VarianceFeatureSelector: - """ - Selects the top-k features with highest variance for a specific omics view. - - Stores a boolean mask after fitting on training data and applies it - consistently to other datasets. - """ - - def __init__(self, view: str, k: int = 1000): - """ - Initialize the selector. - - :param view: omics view to select from, e.g., "gene_expression" - :param k: number of top-variance features to retain - """ - self.view = view - self.k = k - self.mask: np.ndarray = np.array([]) - self.selected_meta_info: list[str] = [] - - def fit(self, cell_line_input: FeatureDataset, output: DrugResponseDataset) -> None: - """ - Fit the selector to the training data by computing a variance-based mask. - - :param cell_line_input: FeatureDataset containing omics features - :param output: DrugResponseDataset with the training cell line IDs - """ - train_features = np.vstack( - [cell_line_input.features[identifier][self.view] for identifier in np.unique(output.cell_line_ids)] - ) - variances = np.var(train_features, axis=0) - self.mask = np.zeros(len(variances), dtype=bool) - self.mask[np.argsort(variances)[::-1][: self.k]] = True - self.selected_meta_info = list(np.array(cell_line_input.meta_info[self.view])[self.mask]) - - def transform(self, cell_line_input: FeatureDataset) -> FeatureDataset: - """ - Apply the feature mask to reduce the dataset to selected features. - - :param cell_line_input: FeatureDataset to transform - :returns: reduced FeatureDataset - :raises RuntimeError: if selector was not fitted - """ - if self.mask.size == 0: - raise RuntimeError("VarianceFeatureSelector must be fitted before transform()") - - for identifier in cell_line_input.features: - cell_line_input.features[identifier][self.view] = cell_line_input.features[identifier][self.view][self.mask] - cell_line_input.meta_info[self.view] = self.selected_meta_info - return cell_line_input - - -def log10_and_set_na(x): - """ - Log10 transform and set NaN for infinite values. - - :param x: input array - :returns: log10 transformed array with NaN for infinite values - """ - x = np.log10(x) - x[np.isinf(x)] = np.nan - return x - - -class ProteomicsMedianCenterAndImputeTransformer(BaseEstimator, TransformerMixin): - """Performs median centering and imputation of proteomics data.""" - - def __init__( - self, - feature_threshold=0.7, - n_features=1000, - normalization_downshift=1.8, - normalization_width=0.3, - imputation_seed=100, - ): - """ - Hyperparameters for the normalization. - - :param feature_threshold: Require that, e.g., 70% of the proteins are measured without NAs - over all cell lines -> n_complete_features = number of proteins with at least 70% of the cell lines - :param n_features: fallback for feature selection. Take top n complete features. - Select max(n_complete_features, n_features) features. - :param normalization_downshift: downshift factor for the mean - :param normalization_width: width factor for the standard deviation - :param imputation_seed: seed for the per-call RNG used to impute missing values; kept - here (rather than mutating np.random globally) so the transformer stays reproducible - without touching the global RNG state. - """ - self.feature_threshold = feature_threshold - self.n_features = n_features - self.normalization_downshift = normalization_downshift - self.normalization_width = normalization_width - self.imputation_seed = imputation_seed - self.protein_indices = np.array([]) - self.mean_median = 0 - - def fit(self, X, y=None): - """ - Learns the top n_feature complete proteins and calculates the mean median of the train cell lines. - - :param X: input proteomics data - :param y: not used - :returns: self - """ - required_proteins = int(X.shape[0] * self.feature_threshold) - # identify the complete columns - completeness = np.sum(~np.isnan(X), axis=0) - n_complete_features = np.count_nonzero(completeness >= required_proteins) - if n_complete_features < self.n_features: - # select top 1000 complete features - # sort by completeness - sorted_indices = np.argsort(completeness)[::-1] - self.protein_indices = sorted_indices[: self.n_features] - else: - # select the features meeting the required threshold - self.protein_indices = np.where(completeness >= required_proteins)[0] - X = X[:, self.protein_indices] - # calculate mean of sample medians - medians = np.nanmedian(X, axis=1) - self.mean_median = np.nanmean(medians) - return self - - def transform(self, X): - """ - Median center the data and impute missing values with downshifted normal distribution. - - :param X: input proteomics data - :returns: transformed proteomics data - """ - X = X[0] - - X = X[self.protein_indices] - - correction_factor = self.mean_median / np.nanmedian(X) - X = X * correction_factor - cell_line_mean = np.nanmean(X) - cell_line_sd = np.nanstd(X) - downshifted_mean = cell_line_mean - (self.normalization_downshift * cell_line_sd) - shrinked_sd = self.normalization_width * cell_line_sd - n_missing = np.count_nonzero(np.isnan(X)) - # local RNG keeps imputation deterministic without poisoning the global np.random state - rng = np.random.default_rng(self.imputation_seed) - X[np.isnan(X)] = rng.normal(loc=downshifted_mean, scale=shrinked_sd, size=n_missing) - return [X] - - -def prepare_proteomics( - cell_line_input: FeatureDataset, - cell_line_ids: np.ndarray, - training: bool, - transformer: ProteomicsMedianCenterAndImputeTransformer, -) -> FeatureDataset: - """ - Applies log10 transform and proteomics normalization (centering + imputation) to proteomics view. - - :param cell_line_input: FeatureDataset with proteomics features - :param cell_line_ids: cell line IDs for training or transformation - :param training: whether to fit or only transform - :param transformer: Proteomics transformer - :returns: transformed FeatureDataset - """ - cell_line_input = cell_line_input.copy() - cell_line_input.apply(log10_and_set_na, view="proteomics") - if training: - cell_line_input.fit_transform_features( - train_ids=cell_line_ids, - transformer=transformer, - view="proteomics", - ) - else: - cell_line_input.transform_features( - ids=cell_line_ids, - transformer=transformer, - view="proteomics", - ) - return cell_line_input - - -def _get_view_as_list(value): - return [value] if isinstance(value, str) else value - - -def load_single_cell_line_view( - cell_line_views: list[str], - data_path: str, - dataset_name: str, - model_name: str, -) -> FeatureDataset: - """ - Load cell line features for a single-view model. - - If the view is "gene_expression", the landmark_genes_reduced list is used for subsetting. - Otherwise, the whole CSV is loaded. - - :param cell_line_views: list of cell line views (must have exactly one element) - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC1 - :param model_name: name of the model, used for error messages - :returns: FeatureDataset containing the cell line features - :raises ValueError: if cell_line_views is empty or has more than one element - """ - if len(cell_line_views) == 0: - raise ValueError( - "cell_line_views is empty. Call build_model() before load_cell_line_features() " - "so the model knows which omics to load." - ) - if len(cell_line_views) > 1: - raise ValueError(f"Only one cell line view is supported for {model_name}.") - print(f"Loading a {model_name} with the following cell line views: {cell_line_views}") - - if "gene_expression" in cell_line_views: - return load_and_select_gene_features( - feature_type="gene_expression", - gene_list="landmark_genes_reduced", - data_path=data_path, - dataset_name=dataset_name, - ) - else: - return load_generic_csv( - path=data_path, - dataset_name=dataset_name, - feature_name=cell_line_views[0], - index_col=CELL_LINE_IDENTIFIER, - ) - - -def load_multi_cell_line_view( - cell_line_views: list[str], - data_path: str, - dataset_name: str, - model_name: str, -) -> FeatureDataset: - """ - Load cell line features for a multi-view model. - - Known omics types use specific gene lists for subsetting. Unknown types are loaded in full. - - :param cell_line_views: list of cell line views - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC1 - :param model_name: name of the model, used for error messages - :returns: FeatureDataset containing the cell line features - :raises ValueError: if cell_line_views is empty - """ - if len(cell_line_views) == 0: - raise ValueError( - "cell_line_views is empty. Call build_model() before load_cell_line_features() " - "so the model knows which omics to load." - ) - print(f"Loading a {model_name} with the following cell line views: {cell_line_views}") - - gene_list_defaults = { - "gene_expression": "drug_target_genes_all_drugs", - "methylation": "methylation_intersection", - "mutations": "drug_target_genes_all_drugs", - "copy_number_variation_gistic": "drug_target_genes_all_drugs", - "proteomics": "drug_target_genes_all_drugs_proteomics", - } - gene_lists = {feature_name: gene_list_defaults.get(feature_name, None) for feature_name in cell_line_views} - - return get_multiomics_feature_dataset( - data_path=data_path, gene_lists=gene_lists, dataset_name=dataset_name, omics=cell_line_views - ) - - -def load_single_drug_view( - drug_views: list[str], - data_path: str, - dataset_name: str, - model_name: str, -) -> FeatureDataset | None: - """ - Load drug features for a single-view model. - - If drug_views is empty, drug IDs are loaded. If "fingerprints", fingerprints are loaded. - Otherwise, the CSV is loaded generically. - - :param drug_views: list of drug views (at most one element) - :param data_path: path to the data, e.g., data/ - :param dataset_name: name of the dataset, e.g., GDSC1 - :param model_name: name of the model, used for error messages - :returns: FeatureDataset containing the drug features - :raises ValueError: if more than one drug view is specified - """ - if len(drug_views) > 1: - raise ValueError(f"Only one drug view is supported for {model_name}.") - print(f"Loading a {model_name} with the following drug views: {drug_views}") - - if len(drug_views) == 0: - return load_drug_ids_from_csv(data_path, dataset_name) - elif drug_views[0] == "fingerprints": - return load_drug_fingerprint_features(data_path, dataset_name, fill_na=True) - else: - return load_generic_csv( - path=data_path, dataset_name=dataset_name, feature_name=drug_views[0], index_col=DRUG_IDENTIFIER - ) diff --git a/drevalpy/models/zoo/AdaBoostDecisionTree.yaml b/drevalpy/models/zoo/AdaBoostDecisionTree.yaml new file mode 100644 index 000000000..cab9cea58 --- /dev/null +++ b/drevalpy/models/zoo/AdaBoostDecisionTree.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: scaledGeneExpression +drug_featurizer: fingerprints +predictor: adaboost diff --git a/drevalpy/models/zoo/DIPK.yaml b/drevalpy/models/zoo/DIPK.yaml new file mode 100644 index 000000000..9347e7eb0 --- /dev/null +++ b/drevalpy/models/zoo/DIPK.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: dipkGeneExpression+bionic +drug_featurizer: molgnet +predictor: dipk diff --git a/drevalpy/models/zoo/DrugGNN.yaml b/drevalpy/models/zoo/DrugGNN.yaml new file mode 100644 index 000000000..24fa9f605 --- /dev/null +++ b/drevalpy/models/zoo/DrugGNN.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: landmarkGenesReduced +drug_featurizer: drugGraph +predictor: drugGNN diff --git a/drevalpy/models/zoo/ElasticNet.yaml b/drevalpy/models/zoo/ElasticNet.yaml new file mode 100644 index 000000000..fcadb942e --- /dev/null +++ b/drevalpy/models/zoo/ElasticNet.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: scaledGeneExpression +drug_featurizer: fingerprints +predictor: elasticNet diff --git a/drevalpy/models/zoo/GradientBoosting.yaml b/drevalpy/models/zoo/GradientBoosting.yaml new file mode 100644 index 000000000..fa388b702 --- /dev/null +++ b/drevalpy/models/zoo/GradientBoosting.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: scaledGeneExpression +drug_featurizer: fingerprints +predictor: gradientBoosting diff --git a/drevalpy/models/zoo/KNNRegressor.yaml b/drevalpy/models/zoo/KNNRegressor.yaml new file mode 100644 index 000000000..c8d2e0e9b --- /dev/null +++ b/drevalpy/models/zoo/KNNRegressor.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: scaledGeneExpression +drug_featurizer: fingerprints +predictor: knn diff --git a/drevalpy/models/zoo/Lasso.yaml b/drevalpy/models/zoo/Lasso.yaml new file mode 100644 index 000000000..f1db16564 --- /dev/null +++ b/drevalpy/models/zoo/Lasso.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: scaledGeneExpression +drug_featurizer: fingerprints +predictor: lasso diff --git a/drevalpy/models/zoo/MOLIR.yaml b/drevalpy/models/zoo/MOLIR.yaml new file mode 100644 index 000000000..3f1b0b418 --- /dev/null +++ b/drevalpy/models/zoo/MOLIR.yaml @@ -0,0 +1,2 @@ +cell_line_featurizer: molirOmics +predictor: molir diff --git a/drevalpy/models/zoo/MultiViewLightGBM.yaml b/drevalpy/models/zoo/MultiViewLightGBM.yaml new file mode 100644 index 000000000..de5123b2d --- /dev/null +++ b/drevalpy/models/zoo/MultiViewLightGBM.yaml @@ -0,0 +1,8 @@ +cell_line_featurizer: + - scaledGeneExpression + - pca[methylation]: + n_components: 100 + - raw[mutations] + - raw[copy_number_variation_gistic] +drug_featurizer: fingerprints +predictor: lightgbm diff --git a/drevalpy/models/zoo/MultiViewNeuralNetwork.yaml b/drevalpy/models/zoo/MultiViewNeuralNetwork.yaml new file mode 100644 index 000000000..19429d6e7 --- /dev/null +++ b/drevalpy/models/zoo/MultiViewNeuralNetwork.yaml @@ -0,0 +1,8 @@ +cell_line_featurizer: + - scaledGeneExpression + - pca[methylation]: + n_components: 100 + - raw[mutations] + - raw[copy_number_variation_gistic] +drug_featurizer: fingerprints +predictor: neuralNetwork diff --git a/drevalpy/models/zoo/MultiViewRandomForest.yaml b/drevalpy/models/zoo/MultiViewRandomForest.yaml new file mode 100644 index 000000000..26682f354 --- /dev/null +++ b/drevalpy/models/zoo/MultiViewRandomForest.yaml @@ -0,0 +1,8 @@ +cell_line_featurizer: + - scaledGeneExpression + - pca[methylation]: + n_components: 100 + - raw[mutations] + - raw[copy_number_variation_gistic] +drug_featurizer: fingerprints +predictor: randomForest diff --git a/drevalpy/models/zoo/MultiViewXGBoost.yaml b/drevalpy/models/zoo/MultiViewXGBoost.yaml new file mode 100644 index 000000000..2294f140c --- /dev/null +++ b/drevalpy/models/zoo/MultiViewXGBoost.yaml @@ -0,0 +1,8 @@ +cell_line_featurizer: + - scaledGeneExpression + - pca[methylation]: + n_components: 100 + - raw[mutations] + - raw[copy_number_variation_gistic] +drug_featurizer: fingerprints +predictor: xgboost diff --git a/drevalpy/models/zoo/NaiveCellLineMeanPredictor.yaml b/drevalpy/models/zoo/NaiveCellLineMeanPredictor.yaml new file mode 100644 index 000000000..c48b2725b --- /dev/null +++ b/drevalpy/models/zoo/NaiveCellLineMeanPredictor.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: identity +drug_featurizer: constant +predictor: naiveCellLineMean diff --git a/drevalpy/models/zoo/NaiveDrugMeanPredictor.yaml b/drevalpy/models/zoo/NaiveDrugMeanPredictor.yaml new file mode 100644 index 000000000..8525a1cb5 --- /dev/null +++ b/drevalpy/models/zoo/NaiveDrugMeanPredictor.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: constant +drug_featurizer: identity +predictor: naiveDrugMean diff --git a/drevalpy/models/zoo/NaiveMeanEffectsPredictor.yaml b/drevalpy/models/zoo/NaiveMeanEffectsPredictor.yaml new file mode 100644 index 000000000..9b2ab7387 --- /dev/null +++ b/drevalpy/models/zoo/NaiveMeanEffectsPredictor.yaml @@ -0,0 +1,6 @@ +cell_line_featurizer: + - identity + - tissue: + allow_missing: true +drug_featurizer: identity +predictor: naiveMeanEffects diff --git a/drevalpy/models/zoo/NaivePredictor.yaml b/drevalpy/models/zoo/NaivePredictor.yaml new file mode 100644 index 000000000..b1762db76 --- /dev/null +++ b/drevalpy/models/zoo/NaivePredictor.yaml @@ -0,0 +1 @@ +predictor: naiveMean diff --git a/drevalpy/models/zoo/NaiveTissueDrugMeanPredictor.yaml b/drevalpy/models/zoo/NaiveTissueDrugMeanPredictor.yaml new file mode 100644 index 000000000..2ed945127 --- /dev/null +++ b/drevalpy/models/zoo/NaiveTissueDrugMeanPredictor.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: tissue +drug_featurizer: identity +predictor: naiveTissueDrugMean diff --git a/drevalpy/models/zoo/NaiveTissueMeanPredictor.yaml b/drevalpy/models/zoo/NaiveTissueMeanPredictor.yaml new file mode 100644 index 000000000..e39bb5535 --- /dev/null +++ b/drevalpy/models/zoo/NaiveTissueMeanPredictor.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: tissue +drug_featurizer: constant +predictor: naiveTissueMean diff --git a/drevalpy/models/zoo/PharmaFormer.yaml b/drevalpy/models/zoo/PharmaFormer.yaml new file mode 100644 index 000000000..95d34a988 --- /dev/null +++ b/drevalpy/models/zoo/PharmaFormer.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: pharmaFormerGeneExpression +drug_featurizer: bpePharmaformer +predictor: pharmaFormer diff --git a/drevalpy/models/zoo/Precily.yaml b/drevalpy/models/zoo/Precily.yaml new file mode 100644 index 000000000..d92c3d845 --- /dev/null +++ b/drevalpy/models/zoo/Precily.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: pathways +drug_featurizer: smilesvec +predictor: precily diff --git a/drevalpy/models/zoo/RandomForest.yaml b/drevalpy/models/zoo/RandomForest.yaml new file mode 100644 index 000000000..da85bd661 --- /dev/null +++ b/drevalpy/models/zoo/RandomForest.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: scaledGeneExpression +drug_featurizer: fingerprints +predictor: randomForest diff --git a/drevalpy/models/zoo/Ridge.yaml b/drevalpy/models/zoo/Ridge.yaml new file mode 100644 index 000000000..6f8ad7bba --- /dev/null +++ b/drevalpy/models/zoo/Ridge.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: scaledGeneExpression +drug_featurizer: fingerprints +predictor: ridge diff --git a/drevalpy/models/zoo/SRMF.yaml b/drevalpy/models/zoo/SRMF.yaml new file mode 100644 index 000000000..ddbbc821a --- /dev/null +++ b/drevalpy/models/zoo/SRMF.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: raw[gene_expression] +drug_featurizer: fingerprints +predictor: srmf diff --git a/drevalpy/models/zoo/SVR.yaml b/drevalpy/models/zoo/SVR.yaml new file mode 100644 index 000000000..92504ba21 --- /dev/null +++ b/drevalpy/models/zoo/SVR.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: scaledGeneExpression +drug_featurizer: fingerprints +predictor: svr diff --git a/drevalpy/models/zoo/SimpleNeuralNetwork.yaml b/drevalpy/models/zoo/SimpleNeuralNetwork.yaml new file mode 100644 index 000000000..cb111706d --- /dev/null +++ b/drevalpy/models/zoo/SimpleNeuralNetwork.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: scaledGeneExpression +drug_featurizer: fingerprints +predictor: neuralNetwork diff --git a/drevalpy/models/zoo/SingleDrugElasticNet.yaml b/drevalpy/models/zoo/SingleDrugElasticNet.yaml new file mode 100644 index 000000000..4fc7d0c02 --- /dev/null +++ b/drevalpy/models/zoo/SingleDrugElasticNet.yaml @@ -0,0 +1,2 @@ +cell_line_featurizer: scaledGeneExpression +predictor: singleDrugElasticNet diff --git a/drevalpy/models/zoo/SingleDrugRandomForest.yaml b/drevalpy/models/zoo/SingleDrugRandomForest.yaml new file mode 100644 index 000000000..b67e37623 --- /dev/null +++ b/drevalpy/models/zoo/SingleDrugRandomForest.yaml @@ -0,0 +1,2 @@ +cell_line_featurizer: scaledGeneExpression +predictor: singleDrugRandomForest diff --git a/drevalpy/models/zoo/SparseGO.yaml b/drevalpy/models/zoo/SparseGO.yaml new file mode 100644 index 000000000..7833c924a --- /dev/null +++ b/drevalpy/models/zoo/SparseGO.yaml @@ -0,0 +1,3 @@ +cell_line_featurizer: sparsegoOntology +drug_featurizer: fingerprints +predictor: sparsego diff --git a/drevalpy/models/zoo/SuperFELTR.yaml b/drevalpy/models/zoo/SuperFELTR.yaml new file mode 100644 index 000000000..0c1f47cf6 --- /dev/null +++ b/drevalpy/models/zoo/SuperFELTR.yaml @@ -0,0 +1,2 @@ +cell_line_featurizer: superfeltrOmics +predictor: superfeltr diff --git a/drevalpy/models/zoo/__init__.py b/drevalpy/models/zoo/__init__.py new file mode 100644 index 000000000..277d1c8cc --- /dev/null +++ b/drevalpy/models/zoo/__init__.py @@ -0,0 +1,159 @@ +"""Built-in zoo entries for passing official drevalpy models.""" + +from __future__ import annotations + +from typing import Any + +from upath import UPath as Path + +from drevalpy.models.config import ModelConfig, ResolvedModelConfig +from drevalpy.models.config.io import from_yaml +from drevalpy.models.tuning.public_flat import apply_public_hyperparameters_to_config +from drevalpy.types.enums.model_scope import ModelScope +from drevalpy.types.enums.prediction_mode import PredictionMode + +from ._external_load import ( + _collect_zoo_entries_from_yaml, + _load_zoo_yaml_mapping, +) + +_BUILTIN_ZOO_DIR = Path(__file__).resolve().parent +_EXTERNAL_ZOO: dict[str, ModelConfig] = {} + + +def _load_builtin_entries() -> dict[str, ModelConfig]: + entries: dict[str, ModelConfig] = {} + for path in sorted(_BUILTIN_ZOO_DIR.glob("*.yaml")): + try: + config = from_yaml(path) + except ValueError: + continue + entries[path.stem] = config + return entries + + +_BUILTIN_ZOO: dict[str, ModelConfig] | None = None +_BUILTIN_ZOO_NAMES: frozenset[str] = frozenset() + + +def _get_builtin_zoo() -> dict[str, ModelConfig]: + """Return built-in zoo entries, loading lazily on first access.""" + global _BUILTIN_ZOO, _BUILTIN_ZOO_NAMES # noqa: PLW0603 + if _BUILTIN_ZOO is None: + _BUILTIN_ZOO = _load_builtin_entries() + _BUILTIN_ZOO_NAMES = frozenset(_BUILTIN_ZOO) + return _BUILTIN_ZOO + + +def _coerce_scope(scope: ModelScope | str | None) -> ModelScope | None: + if scope is None: + return None + if isinstance(scope, ModelScope): + return scope + return ModelScope(scope) + + +def list_zoo_names( + *, + include_external: bool = True, + scope: ModelScope | str | None = None, +) -> list[str]: + """Return sorted built-in (and optional external) zoo entry names. + + :param include_external: Include externally registered zoo entries. + :param scope: Optional ``ModelScope`` (or its string value) filter for multi-drug vs single-drug presets. + :returns: Sorted list of zoo preset names matching the filters. + """ + names = set(_get_builtin_zoo()) + if include_external: + names.update(_EXTERNAL_ZOO) + resolved_scope = _coerce_scope(scope) + if resolved_scope is None: + return sorted(names) + filtered = [name for name in names if get_zoo_config(name).scope == resolved_scope] + return sorted(filtered) + + +def get_zoo_config(name: str, *, prediction_mode: PredictionMode | None = None) -> ModelConfig: + """Return a copy of a zoo entry by name. + + :param name: Built-in or externally registered zoo preset name. + :param prediction_mode: Optional prediction mode overriding the preset's own value. + :returns: Deep copy of the zoo ``ModelConfig``. + :raises KeyError: If ``name`` is not a known zoo entry. + """ + if name in _EXTERNAL_ZOO: + return _clone_model_config(_EXTERNAL_ZOO[name], prediction_mode=prediction_mode) + if name not in _get_builtin_zoo(): + msg = f"Unknown zoo entry: {name}" + raise KeyError(msg) + return _clone_model_config(_get_builtin_zoo()[name], prediction_mode=prediction_mode) + + +def register_external_zoo_entry(name: str, config: ModelConfig, *, replace: bool = True) -> None: + """Register an external zoo entry. + + External entries are resolved through ``ModelConfig`` / ``construct_model`` + rather than dynamically extending an already-built ``MODEL_FACTORY``. + + :param name: Unique preset name; must not collide with built-in names unless ``replace`` allows replacement. + :param config: Validated model configuration for the preset. + :param replace: Allow replacing an existing external entry with the same name. + :raises ValueError: If ``name`` collides with a built-in preset. + """ + if name in frozenset(_get_builtin_zoo()): + msg = f"External zoo entry {name!r} collides with a built-in preset" + raise ValueError(msg) + if name in _EXTERNAL_ZOO and not replace: + msg = f"External zoo entry {name!r} is already registered" + raise ValueError(msg) + _EXTERNAL_ZOO[name] = _clone_model_config(config) + + +def clear_external_zoo() -> None: + """Remove all externally registered zoo entries (primarily for tests).""" + _EXTERNAL_ZOO.clear() + + +def load_external_zoo_file(path: Path | str) -> list[str]: + """Load one or more zoo entries from a YAML file. + + Validates the complete file before mutating global external zoo state. + + :param path: YAML file mapping preset names to model configs. + :returns: Names of entries loaded from the file. + """ + yaml_path = Path(path) + data = _load_zoo_yaml_mapping(yaml_path) + parsed = _collect_zoo_entries_from_yaml(data, source=yaml_path, builtin_names=frozenset(_get_builtin_zoo())) + for entry_name, config in parsed: + _EXTERNAL_ZOO[entry_name] = _clone_model_config(config) + return [entry_name for entry_name, _ in parsed] + + +def zoo_model_config( + name: str, + hyperparameters: dict[str, Any] | None = None, + *, + prediction_mode: PredictionMode | None = None, +) -> ModelConfig | ResolvedModelConfig: + """Return a zoo config with optional public flat hyperparameter overrides. + + When *hyperparameters* are provided, returns a ``ResolvedModelConfig``. + + :param name: Built-in or external zoo preset name. + :param hyperparameters: Optional flat public overrides applied to the preset. + :param prediction_mode: Optional prediction mode overriding the preset's own value. + :returns: ``ModelConfig`` copy, or resolved config when overrides are provided. + """ + config = get_zoo_config(name, prediction_mode=prediction_mode) + if not hyperparameters: + return config + return apply_public_hyperparameters_to_config(config, hyperparameters) + + +def _clone_model_config(config: ModelConfig, *, prediction_mode: PredictionMode | None = None) -> ModelConfig: + payload = config.model_dump(mode="python") + if prediction_mode is not None: + payload["prediction_mode"] = prediction_mode + return ModelConfig.model_validate(payload) diff --git a/drevalpy/models/zoo/_external_load.py b/drevalpy/models/zoo/_external_load.py new file mode 100644 index 000000000..5640e48f3 --- /dev/null +++ b/drevalpy/models/zoo/_external_load.py @@ -0,0 +1,73 @@ +"""Load external zoo YAML entries.""" + +from __future__ import annotations + +from typing import Any + +import yaml +from upath import UPath as Path + +from drevalpy.models.config.io import from_dict + + +def _load_zoo_yaml_mapping(path: Path) -> dict[str, Any]: + if not path.is_file(): + msg = f"External zoo YAML not found: {path}" + raise FileNotFoundError(msg) + with path.open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + if not isinstance(data, dict): + msg = f"External zoo YAML must contain a mapping: {path}" + raise ValueError(msg) + return data + + +def _assert_not_builtin_zoo_name(entry_name: str, builtin_names: frozenset[str]) -> None: + if entry_name in builtin_names: + msg = f"External zoo entry {entry_name!r} collides with a built-in preset" + raise ValueError(msg) + + +def _parse_zoo_entry( + entry_name: str, + payload: dict[str, Any], + *, + source: Path, + builtin_names: frozenset[str], +) -> tuple[str, Any]: + from drevalpy.models.config import ModelConfig + + _assert_not_builtin_zoo_name(entry_name, builtin_names) + try: + config = from_dict(payload, source=source) + except ValueError as exc: + msg = f"Invalid zoo entry {entry_name!r} in {source}: {exc}" + raise ValueError(msg) from exc + if not isinstance(config, ModelConfig): + msg = f"Invalid zoo entry {entry_name!r} in {source}: expected ModelConfig" + raise ValueError(msg) + return entry_name, config + + +def _collect_zoo_entries_from_yaml( + data: dict[str, Any], + *, + source: Path, + builtin_names: frozenset[str], +) -> list[tuple[str, Any]]: + parsed: list[tuple[str, Any]] = [] + if "predictor" in data: + payload = dict(data) + entry_name = str(payload.pop("name", source.stem)) + parsed.append(_parse_zoo_entry(entry_name, payload, source=source, builtin_names=builtin_names)) + return parsed + + for entry_name, entry_data in data.items(): + if not isinstance(entry_data, dict): + msg = f"Zoo entry '{entry_name}' must be a mapping in {source}" + raise ValueError(msg) + payload = dict(entry_data) + payload.pop("name", None) + name = str(entry_name) + parsed.append(_parse_zoo_entry(name, payload, source=source, builtin_names=builtin_names)) + return parsed diff --git a/drevalpy/pipeline_function.py b/drevalpy/pipeline_function.py deleted file mode 100644 index 531ae4e68..000000000 --- a/drevalpy/pipeline_function.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Decorator to mark a function as a pipeline function.""" - - -def pipeline_function(func): - """ - Decorator to mark a function as a pipeline function. - - :param func: function to decorate - :return: function with custom attribute - """ - func.is_pipeline_function = True # Adds a custom attribute to the function - return func diff --git a/drevalpy/plugin/__init__.py b/drevalpy/plugin/__init__.py new file mode 100644 index 000000000..ce3d17d34 --- /dev/null +++ b/drevalpy/plugin/__init__.py @@ -0,0 +1,135 @@ +"""The supported import surface for third-party drevalpy plugins. + +Everything a plugin needs to declare a featurizer, predictor, splitter or +visualization is re-exported here, so a plugin imports from exactly one module:: + + from drevalpy.plugin import ( + CellLineFeaturizer, + FeatureFormat, + register_cell_line_featurizer, + ) + +Nothing is defined in this module. Every name is an alias for a symbol that +lives in the package's internal layout, which means that layout stays free to +move: only the aliases below are a compatibility promise. Importing the deep +paths still works, but they are private in the sense that matters - a refactor +may rename them without a deprecation cycle. + +The five per-registry ``register_*`` aliases point at the ``register`` +decorators, which are all spelled ``register`` in their own modules. Naming them +apart here is what makes several registrations in one module readable, and +removes the ``from ... import register as register_x`` boilerplate every plugin +would otherwise repeat. ``register_for_sides`` is not one of them: it is a +featurizer-only decorator that registers one side-agnostic implementation in +*both* featurizer registries at once. + +Two of the aliases below - ``DenseViewFeaturizer`` and ``register_for_sides`` - +live in underscore-private modules (``featurizers/_dense_view.py``, +``featurizers/_side_binding.py``). The leading underscore keeps those modules out +of the registry's component directory scan; it does not make the aliases here any +less of a promise. +""" + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.components.contracts.training_context import TrainingContext +from drevalpy.components.featurizers._dense_view import DenseViewFeaturizer +from drevalpy.components.featurizers._side_binding import register_for_sides +from drevalpy.components.featurizers.base import Featurizer, HPOStrategy +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer, DenseViewCellLineFeaturizer +from drevalpy.components.featurizers.drug.base import DenseViewDrugFeaturizer, DrugFeaturizer +from drevalpy.components.featurizers.storage import FeaturizerStorageMixin +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.data.quality import curve_quality_mask +from drevalpy.registry.cell_line_featurizer import register as register_cell_line_featurizer +from drevalpy.registry.drug_featurizer import register as register_drug_featurizer +from drevalpy.registry.predictor import register as register_predictor +from drevalpy.registry.splitter import ( + Splitter, + SplitValidationError, + Validation, +) +from drevalpy.registry.splitter import register as register_splitter +from drevalpy.registry.visualization import register as register_visualization +from drevalpy.types.data.batch.feature_block import ( + BlockSpec, + FeatureBlock, + graph_feature_block, + merge_feature_blocks, + metadata_feature_block, + numeric_feature_block, + ragged_feature_block, +) +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.batch.response_batch import ResponseBatch +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import ( + CellLineFeatureSource, + DrugFeatureSource, + FeatureSource, +) +from drevalpy.types.data.mudatalike import MuDataLike +from drevalpy.types.data.split_mask import SplitMask +from drevalpy.types.data.split_masks import SplitMasks +from drevalpy.types.enums.literature_reference import LiteratureReference +from drevalpy.types.enums.model_scope import ModelScope +from drevalpy.types.enums.prediction_mode import PredictionMode +from drevalpy.types.results import ExperimentResult, ModelResult, RunResult +from drevalpy.visualization.base import ImageVisualization, Section, Visualization +from drevalpy.visualization.requirements import PlotRequirement + +__all__ = [ + "BlockPredictor", + "BlockSpec", + "CellLineFeatureSource", + "CellLineFeaturizer", + "Dataset", + "DenseViewCellLineFeaturizer", + "DenseViewDrugFeaturizer", + "DenseViewFeaturizer", + "DrugFeatureSource", + "DrugFeaturizer", + "ExperimentResult", + "FeatureBlock", + "FeatureContract", + "FeatureFormat", + "FeatureFreePredictor", + "FeatureSource", + "Featurizer", + "FeaturizerStorageMixin", + "HPOStrategy", + "ImageVisualization", + "LiteratureReference", + "MatrixPredictor", + "ModelInputBatch", + "ModelResult", + "ModelScope", + "MuDataLike", + "PlotRequirement", + "PredictionMode", + "Predictor", + "ResponseBatch", + "RunResult", + "Section", + "SplitMask", + "SplitMasks", + "SplitValidationError", + "Splitter", + "TrainingContext", + "Validation", + "Visualization", + "curve_quality_mask", + "graph_feature_block", + "merge_feature_blocks", + "metadata_feature_block", + "numeric_feature_block", + "ragged_feature_block", + "register_cell_line_featurizer", + "register_drug_featurizer", + "register_for_sides", + "register_predictor", + "register_splitter", + "register_visualization", +] diff --git a/drevalpy/py.typed b/drevalpy/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/drevalpy/registry/__init__.py b/drevalpy/registry/__init__.py new file mode 100644 index 000000000..4cb2db347 --- /dev/null +++ b/drevalpy/registry/__init__.py @@ -0,0 +1,29 @@ +"""Unified registry module -- all registries, extension loading, plugin discovery.""" + +from . import cell_line_featurizer as cell_line_featurizer +from . import dataset as dataset +from . import drug_featurizer as drug_featurizer +from . import predictor as predictor +from . import splitter as splitter +from . import visualization as visualization +from ._builtins import get_skipped_builtin_modules as get_skipped_builtin_modules +from ._builtins import register_builtin_components +from ._extensions import ( + load_extension_dir as load_extension_dir, +) +from ._extensions import ( + load_extension_file as load_extension_file, +) +from ._extensions import ( + load_extension_module as load_extension_module, +) +from ._extensions import ( + load_extensions as load_extensions, +) +from ._plugins import discover_plugins +from ._plugins import get_failed_plugins as get_failed_plugins +from ._plugins import get_loaded_plugins as get_loaded_plugins + +# Auto-initialize: register builtins, then discover installed plugins +register_builtin_components() +discover_plugins() diff --git a/drevalpy/registry/_base.py b/drevalpy/registry/_base.py new file mode 100644 index 000000000..eb22cced8 --- /dev/null +++ b/drevalpy/registry/_base.py @@ -0,0 +1,121 @@ +"""Shared registry base -- thread-safe name-to-class store.""" + +from __future__ import annotations + +import threading +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +from drevalpy.log import get_logger + +if TYPE_CHECKING: + import pandas as pd + +logger = get_logger(__name__) + + +class Registry(ABC): + """Thread-safe name-to-class registry with shared store and metadata listing.""" + + def __init__(self, registry_id: str, label: str, display_name: str) -> None: + """Initialize an empty registry store. + + :param registry_id: Stable identifier used in validation messages. + :param label: Human-readable label for unknown/duplicate errors. + :param display_name: Catalog registry name written into component metadata. + """ + self._registry_id = registry_id + self._label = label + self._display_name = display_name + self._store: dict[str, type[Any]] = {} + self._lock = threading.Lock() + + def get(self, name: str) -> type[Any]: + """Return the class registered under *name*. + + :param name: Registry name of the component. + :returns: Registered component class. + :raises ValueError: If *name* is not registered. + """ + with self._lock: + if name not in self._store: + available = list(self._store.keys()) + raise ValueError(f"Unknown {self._label}: {name!r}. Available: {available}") + return self._store[name] + + def list_names(self) -> list[str]: + """Return all registered names. + + :returns: Sorted list of registry names currently stored. + """ + with self._lock: + return list(self._store.keys()) + + def get_metadata(self, name: str) -> dict[str, Any]: + """Return the metadata record for the component registered under *name*. + + :param name: Registry name of the component. + :returns: Metadata dict for catalog listings. + """ + cls = self.get(name) + return self._component_metadata(name, cls) + + def list_metadata(self, *, tag: str | None = None) -> list[dict[str, Any]]: + """Return metadata for all components, optionally filtered by discovery tag. + + :param tag: When set, keep only components whose ``tags`` contain *tag*. + :returns: List of metadata dicts. + """ + rows = [self.get_metadata(name) for name in self.list_names()] + if tag is None: + return rows + needle = tag.strip() + return [row for row in rows if needle in row.get("tags", frozenset())] + + def clear(self) -> None: + """Remove all entries (primarily for testing).""" + with self._lock: + self._store.clear() + + def retain_only(self, names: frozenset[str]) -> None: + """Drop entries whose names are not in *names*. + + :param names: Registry names to keep after rollback or partial unload. + """ + with self._lock: + for registered_name in list(self._store): + if registered_name not in names: + del self._store[registered_name] + + @abstractmethod + def _component_metadata(self, name: str, cls: type[Any]) -> dict[str, Any]: + """Return component metadata for a registered class. + + :param name: Registry name of the component. + :param cls: Registered component class. + :returns: Metadata dict. + """ + + def to_dataframe(self) -> pd.DataFrame: + """Return registry contents as a pandas DataFrame.""" + import pandas as pd + + rows = [] + for name in self.list_names(): + meta = self.get_metadata(name) + rows.append( + { + "Name": name, + "Description": meta.get("description", ""), + "Tags": ", ".join(sorted(meta.get("tags", frozenset()))), + } + ) + return pd.DataFrame(rows) + + def __repr__(self) -> str: + """Return a tabular string representation.""" + return self.to_dataframe().to_string(index=False) + + def _repr_html_(self) -> str: + """HTML table for Jupyter notebooks.""" + return self.to_dataframe().to_html(index=False) diff --git a/drevalpy/registry/_builtins.py b/drevalpy/registry/_builtins.py new file mode 100644 index 000000000..eba104a7a --- /dev/null +++ b/drevalpy/registry/_builtins.py @@ -0,0 +1,366 @@ +"""Register built-in components by scanning package directories. + +No explicit name-to-module mapping required -- modules are discovered by +convention (any .py file without a leading underscore in the component dirs). +""" + +from __future__ import annotations + +import importlib +import traceback +from typing import Any + +from drevalpy.log import get_logger + +logger = get_logger(__name__) + +_SKIPPED_MODULES: dict[str, str] = {} + + +def get_skipped_builtin_modules() -> dict[str, str]: + """Return modules that could not be imported during built-in registration. + + :returns: Mapping of dotted module name to the formatted traceback of the + import failure. Empty when every built-in module imported cleanly. + """ + return dict(_SKIPPED_MODULES) + + +def _discover_modules(package_path: str, package_name: str) -> list[str]: + """Return dotted module names for all public .py files in a package directory. + + :param package_path: Filesystem path of the package directory. + :param package_name: Dotted package name prefix. + :returns: List of importable module names. + """ + from upath import UPath + + pkg_dir = UPath(package_path) + modules = [] + for py_file in sorted(pkg_dir.glob("*.py")): + name = py_file.stem + if name.startswith("_") or name in ("base", "__init__"): + continue + modules.append(f"{package_name}.{name}") + return modules + + +def _try_import(module_name: str) -> Exception | None: + """Make *module_name*'s components available, reporting failure rather than raising. + + An already-imported module is rescanned instead of re-imported, which is what + re-populates the registries after a ``clear()``. + + :param module_name: Dotted name of a component module. + :returns: The import error, or ``None`` once the module's components are registered. + """ + import sys + + if module_name in sys.modules: + _reregister_from_module(sys.modules[module_name]) + return None + try: + importlib.import_module(module_name) + except (ImportError, AttributeError) as exc: + return exc + _SKIPPED_MODULES.pop(module_name, None) + return None + + +def _import_modules(module_names: list[str]) -> None: + """Import each module, logging and skipping failures. + + If a module is already imported, scan it for registered classes and + re-register them (handles the case where clear() was called). + Modules that fail on first attempt are retried at the end; modules that + still fail are recorded in :func:`get_skipped_builtin_modules` and reported + at WARNING level, because a silently skipped module means a component + silently disappears from the registries. + """ + deferred: list[str] = [] + for module_name in module_names: + if _try_import(module_name) is not None: + deferred.append(module_name) + logger.debug("Deferring %s (import failed on first pass)", module_name) + + for module_name in deferred: + exc = _try_import(module_name) + if exc is None: + continue + _SKIPPED_MODULES[module_name] = "".join(traceback.format_exception(exc)) + logger.warning( + "Skipping built-in component module %s: its components will be unavailable (%s: %s)", + module_name, + type(exc).__name__, + exc, + ) + + +def _registry_for(cls) -> Any: + """Return the registry a previously registered class belongs to. + + Which registry a class came from is recoverable from the attributes + registration stamped on it: ``side`` for a featurizer, and the contract + attributes for everything else. + + :param cls: Class carrying a ``registry_name``. + :returns: The matching registry, or ``None`` if the class is not a component. + """ + from drevalpy.registry.cell_line_featurizer._registry import cell_line_featurizer_registry + from drevalpy.registry.drug_featurizer._registry import drug_featurizer_registry + from drevalpy.registry.predictor._registry import predictor_registry + + side = getattr(cls, "side", None) + if side == "cell_line": + return cell_line_featurizer_registry + if side == "drug": + return drug_featurizer_registry + if hasattr(cls, "cell_line_contract"): + return predictor_registry + if hasattr(cls, "contract"): + return cell_line_featurizer_registry + return None + + +def _reregister_from_module(module) -> None: + """Re-register classes from an already-imported module.""" + import inspect + + for value in vars(module).values(): + if not inspect.isclass(value): + continue + registry_name = getattr(value, "registry_name", None) + if not registry_name: + continue + registry = _registry_for(value) + if registry is not None: + registry.register_existing(registry_name, value) + + +def _discover_literature_predictor_modules() -> list[str]: + """Find all literature//predictor.py modules.""" + from upath import UPath + + import drevalpy.components.predictors as pkg + + lit_dir = UPath(pkg.__path__[0]) / "literature" + modules = [] + if lit_dir.is_dir(): + for sub_dir in sorted(lit_dir.iterdir()): + if sub_dir.is_dir() and not sub_dir.name.startswith("_"): + modules.append(f"{pkg.__name__}.literature.{sub_dir.name}.predictor") + # Also the neural_network predictor + modules.append(f"{pkg.__name__}.neural_network.predictor") + return modules + + +def _shared_featurizer_modules() -> list[str]: + """Discover featurizers whose one implementation is bound to both sides. + + Each module registers itself on every side via ``register_for_sides``, so the + directory is scanned once rather than once per side. + + :returns: Importable module names under ``featurizers/shared``. + """ + import drevalpy.components.featurizers.shared as pkg + + return _discover_modules(pkg.__path__[0], pkg.__name__) + + +def _cell_line_featurizer_modules() -> list[str]: + import drevalpy.components.featurizers.cell_line as pkg + + return _discover_modules(pkg.__path__[0], pkg.__name__) + + +def _drug_featurizer_modules() -> list[str]: + import drevalpy.components.featurizers.drug as pkg + + return _discover_modules(pkg.__path__[0], pkg.__name__) + + +def _native_predictor_modules() -> list[str]: + """Discover non-literature predictor modules.""" + import drevalpy.components.predictors as pkg + import drevalpy.components.predictors.naive as naive_pkg + + top_level = _discover_modules(pkg.__path__[0], pkg.__name__) + naive = _discover_modules(naive_pkg.__path__[0], naive_pkg.__name__) + return top_level + naive + + +def register_native_components() -> None: + """Register dependency-light native components (featurizers + non-literature predictors).""" + _import_modules(_shared_featurizer_modules()) + _import_modules(_cell_line_featurizer_modules()) + _import_modules(_drug_featurizer_modules()) + _import_modules(_native_predictor_modules()) + + +def register_literature_components() -> None: + """Register literature predictors and their neural dependencies.""" + _import_modules(_discover_literature_predictor_modules()) + + +def _register_builtin_splitters() -> None: + """Import splitter modules to trigger their @register decorators.""" + import sys + + from drevalpy.registry.splitter._registry import splitter_registry + + if splitter_registry.modes: + return + + splitter_modules = [ + "drevalpy.data.splitters.lco", + "drevalpy.data.splitters.ldo", + "drevalpy.data.splitters.lpo", + "drevalpy.data.splitters.lto", + ] + # Evict every module before importing any of them: importing the first one runs + # the package __init__, which imports all four. Interleaving eviction and import + # would re-execute the later modules and register their modes twice. + for mod_name in splitter_modules: + sys.modules.pop(mod_name, None) + for mod_name in splitter_modules: + importlib.import_module(mod_name) + + +def _register_builtin_visualizations() -> None: + """Import visualization plot modules to trigger their @register decorators.""" + import sys + + from drevalpy.registry.visualization._registry import visualization_registry + + if visualization_registry.names: + return + + viz_pkg = "drevalpy.visualization.plots" + modules_to_reload = [key for key in sys.modules if key.startswith(viz_pkg)] + for mod_name in modules_to_reload: + del sys.modules[mod_name] + importlib.import_module(viz_pkg) + + +def register_builtin_components() -> None: + """Register every built-in component by scanning package directories. + + Safe to call multiple times. Detects cleared registries and re-populates. + """ + from drevalpy.registry.cell_line_featurizer._registry import cell_line_featurizer_registry + from drevalpy.registry.drug_featurizer._registry import drug_featurizer_registry + from drevalpy.registry.predictor._registry import predictor_registry + + all_populated = ( + predictor_registry.list_names() + and cell_line_featurizer_registry.list_names() + and drug_featurizer_registry.list_names() + ) + if all_populated: + return + + logger.debug("Registering built-in components...") + register_native_components() + register_literature_components() + _register_builtin_splitters() + _register_builtin_visualizations() + logger.debug("Built-in registration complete.") + + +def reregister_builtin_components() -> None: + """Force re-registration of all builtins (used after `clear()` in tests).""" + register_builtin_components() + + +# --------------------------------------------------------------------------- +# Compatibility helpers +# --------------------------------------------------------------------------- + + +def ensure_predictor_registered(name: str) -> None: + """No-op with eager loading -- all predictors are registered at startup.""" + + +def ensure_cell_line_featurizer_registered(name: str) -> None: + """No-op with eager loading -- all featurizers are registered at startup.""" + + +def ensure_drug_featurizer_registered(name: str) -> None: + """No-op with eager loading -- all featurizers are registered at startup.""" + + +def is_known_builtin_predictor(name: str) -> bool: + """Return whether *name* is a registered predictor.""" + from drevalpy.registry.predictor._registry import predictor_registry + + return name in predictor_registry.list_names() + + +def is_known_builtin_cell_line_featurizer(name: str) -> bool: + """Return whether *name* is a registered cell-line featurizer.""" + from drevalpy.registry.cell_line_featurizer._registry import cell_line_featurizer_registry + + return name in cell_line_featurizer_registry.list_names() + + +def is_known_builtin_drug_featurizer(name: str) -> bool: + """Return whether *name* is a registered drug featurizer.""" + from drevalpy.registry.drug_featurizer._registry import drug_featurizer_registry + + return name in drug_featurizer_registry.list_names() + + +# --------------------------------------------------------------------------- +# Lazy built-in name sets (computed on first access) +# --------------------------------------------------------------------------- + + +def _get_builtin_cell_line_featurizer_names() -> frozenset[str]: + from drevalpy.registry.cell_line_featurizer._registry import cell_line_featurizer_registry + + return frozenset(cell_line_featurizer_registry.list_names()) + + +def _get_builtin_drug_featurizer_names() -> frozenset[str]: + from drevalpy.registry.drug_featurizer._registry import drug_featurizer_registry + + return frozenset(drug_featurizer_registry.list_names()) + + +def _get_builtin_predictor_names() -> frozenset[str]: + from drevalpy.registry.predictor._registry import predictor_registry + + return frozenset(predictor_registry.list_names()) + + +class _LazyFrozenset: + """Module-level lazy frozenset that computes on first access.""" + + def __init__(self, getter): + self._getter = getter + self._value = None + + def _resolve(self): + if self._value is None: + self._value = self._getter() + return self._value + + def __iter__(self): + return iter(self._resolve()) + + def __contains__(self, item): + return item in self._resolve() + + def __len__(self): + return len(self._resolve()) + + def __eq__(self, other): + return self._resolve() == other + + def __repr__(self): + return repr(self._resolve()) + + +BUILTIN_CELL_LINE_FEATURIZER_NAMES = _LazyFrozenset(_get_builtin_cell_line_featurizer_names) +BUILTIN_DRUG_FEATURIZER_NAMES = _LazyFrozenset(_get_builtin_drug_featurizer_names) +BUILTIN_PREDICTOR_NAMES = _LazyFrozenset(_get_builtin_predictor_names) diff --git a/drevalpy/registry/_extensions.py b/drevalpy/registry/_extensions.py new file mode 100644 index 000000000..9755e0ca8 --- /dev/null +++ b/drevalpy/registry/_extensions.py @@ -0,0 +1,231 @@ +"""Load external featurizers, predictors, splitters, visualizations, and zoo entries. + +Example:: + + from drevalpy.registry._extensions import load_extensions + + load_extensions( + directories=["./my_components"], + zoo_files=["./my_zoo.yaml"], + ) +""" + +from __future__ import annotations + +import hashlib +import importlib +import importlib.util +import sys +from typing import Any + +from upath import UPath as Path + + +def _extension_module_name(file_path: Path) -> str: + digest = hashlib.sha256(str(file_path).encode()).hexdigest()[:16] + return f"drevalpy_user_extension_{file_path.stem}_{digest}" + + +def _get_registries() -> tuple[Any, Any, Any, Any, Any, Any]: + """Lazily import all registry singletons to avoid circular imports.""" + from drevalpy.registry.cell_line_featurizer._registry import cell_line_featurizer_registry + from drevalpy.registry.dataset._registry import dataset_registry + from drevalpy.registry.drug_featurizer._registry import drug_featurizer_registry + from drevalpy.registry.predictor._registry import predictor_registry + from drevalpy.registry.splitter._registry import splitter_registry + from drevalpy.registry.visualization._registry import visualization_registry + + return ( + predictor_registry, + cell_line_featurizer_registry, + drug_featurizer_registry, + splitter_registry, + visualization_registry, + dataset_registry, + ) + + +def _snapshot_all_registries() -> dict[str, frozenset[str]]: + """Capture current state of all in-memory registries for rollback.""" + ( + predictor_registry, + cell_line_featurizer_registry, + drug_featurizer_registry, + splitter_registry, + visualization_registry, + _dataset_registry, + ) = _get_registries() + + return { + "predictor": frozenset(predictor_registry.list_names()), + "cell_line_featurizer": frozenset(cell_line_featurizer_registry.list_names()), + "drug_featurizer": frozenset(drug_featurizer_registry.list_names()), + "splitter": frozenset(splitter_registry.modes), + "visualization": frozenset(visualization_registry.names), + } + + +def _restore_all_registries(snapshot: dict[str, frozenset[str]]) -> None: + """Roll back all in-memory registries to a prior snapshot.""" + ( + predictor_registry, + cell_line_featurizer_registry, + drug_featurizer_registry, + splitter_registry, + visualization_registry, + _dataset_registry, + ) = _get_registries() + + predictor_registry.retain_only(snapshot["predictor"]) + cell_line_featurizer_registry.retain_only(snapshot["cell_line_featurizer"]) + drug_featurizer_registry.retain_only(snapshot["drug_featurizer"]) + splitter_registry.retain_only(snapshot["splitter"]) + visualization_registry.retain_only(snapshot["visualization"]) + + +def load_extension_module(module_name: str) -> None: + """Import a Python module so its registration decorators run. + + :param module_name: Dotted import path of an installed or ``PYTHONPATH`` module. + :raises ValueError: If *module_name* is empty. + :raises ImportError: If the module cannot be imported or registration fails mid-load. + """ + if not module_name: + msg = "module_name must be a non-empty string" + raise ValueError(msg) + snapshot = _snapshot_all_registries() + try: + importlib.import_module(module_name) + except ImportError: + _restore_all_registries(snapshot) + raise + except Exception as exc: + _restore_all_registries(snapshot) + msg = f"Failed to import extension module '{module_name}'" + raise ImportError(msg) from exc + + +def load_extension_file(path: Path | str) -> None: + """Import one Python file so its registration decorators run. + + :param path: Path to a ``.py`` file containing ``@register_*`` decorators. + :raises FileNotFoundError: If *path* does not exist. + :raises ImportError: If the file cannot be executed or registration fails mid-load. + """ + file_path = Path(path).resolve() + if not file_path.is_file(): + msg = f"Extension file not found: {file_path}" + raise FileNotFoundError(msg) + module_name = _extension_module_name(file_path) + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + msg = f"Could not load extension file: {file_path}" + raise ImportError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + snapshot = _snapshot_all_registries() + try: + spec.loader.exec_module(module) + except ImportError: + sys.modules.pop(module_name, None) + _restore_all_registries(snapshot) + raise + except Exception as exc: + sys.modules.pop(module_name, None) + _restore_all_registries(snapshot) + msg = f"Failed to import extension file '{file_path}'" + raise ImportError(msg) from exc + + +def _load_yaml_extension(path: Path) -> None: + """Inspect YAML top-level keys: dataset config vs zoo entry.""" + import yaml + + data = yaml.safe_load(path.read_text()) + if not isinstance(data, dict): + return + + if "sources" in data or "datasets" in data: + _register_datasets_from_yaml(data) + else: + from drevalpy.models.zoo import load_external_zoo_file + + load_external_zoo_file(path) + + +def _register_datasets_from_yaml(data: dict) -> None: + """Register sources and datasets from a parsed YAML dict.""" + from drevalpy.registry.dataset._registry import dataset_registry + + for name, source_info in (data.get("sources") or {}).items(): + dataset_registry.register_source( + name, + base_url=source_info["url"], + storage_options=source_info.get("storage_options"), + ) + for name, ds_info in (data.get("datasets") or {}).items(): + dataset_registry.register_dataset( + name, + source=ds_info["source"], + file=ds_info["file"], + ) + + +def load_extension_dir(path: Path | str) -> None: + """Import all ``*.py`` files and load all ``*.yaml`` files from a directory. + + Python files are imported in sorted order (skipping ``__init__.py``). + YAML files are inspected and dispatched to either the dataset registry + or the zoo loader. + + :param path: Directory containing extension modules (non-recursive). + :raises FileNotFoundError: If *path* is not a directory. + """ + dir_path = Path(path).resolve() + if not dir_path.is_dir(): + msg = f"Extension directory not found: {dir_path}" + raise FileNotFoundError(msg) + + snapshot = _snapshot_all_registries() + try: + py_files = sorted( + file_path + for file_path in dir_path.glob("*.py") + if file_path.name != "__init__.py" and "__pycache__" not in file_path.parts + ) + for file_path in py_files: + load_extension_file(file_path) + + yaml_files = sorted(dir_path.glob("*.yaml")) + for yaml_file in yaml_files: + _load_yaml_extension(yaml_file) + except Exception: + _restore_all_registries(snapshot) + raise + + +def load_extensions( + *, + modules: list[str] | None = None, + files: list[Path | str] | None = None, + directories: list[Path | str] | None = None, + zoo_files: list[Path | str] | None = None, +) -> None: + """Load extension modules/files/directories and optional external zoo YAML. + + :param modules: Installed module names to import. + :param files: Individual ``.py`` extension files. + :param directories: Directories scanned for ``*.py`` and ``*.yaml`` extension files. + :param zoo_files: External zoo YAML files resolved via ``ModelConfig`` / ``construct_model``. + """ + for module_name in modules or []: + load_extension_module(module_name) + for file_path in files or []: + load_extension_file(file_path) + for directory in directories or []: + load_extension_dir(directory) + if zoo_files: + from drevalpy.models.zoo import load_external_zoo_file + + for zoo_path in zoo_files: + load_external_zoo_file(zoo_path) diff --git a/drevalpy/registry/_plugins.py b/drevalpy/registry/_plugins.py new file mode 100644 index 000000000..3fe4ad2c7 --- /dev/null +++ b/drevalpy/registry/_plugins.py @@ -0,0 +1,111 @@ +"""Plugin discovery via importlib.metadata entry points. + +Load failures are recorded rather than swallowed: a plugin that fails to import +silently removes every component it would have registered, which surfaces much +later as an "unknown predictor" error far from the cause. Failures are therefore +kept in :func:`get_failed_plugins` and reported at WARNING level, mirroring +:func:`drevalpy.registry._builtins.get_skipped_builtin_modules`. + +Set ``DREVALPY_STRICT_PLUGINS=1`` to re-raise instead of warn. Plugin CI wants +this; the default stays non-fatal so one broken third-party package cannot brick +the CLI for everyone else. +""" + +from __future__ import annotations + +import importlib.metadata +import os +import traceback + +from drevalpy.log import get_logger + +logger = get_logger(__name__) + +ENTRY_POINT_GROUP = "drevalpy.plugins" +STRICT_ENV_VAR = "DREVALPY_STRICT_PLUGINS" + +_TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"}) + +_discovered = False +_LOADED_PLUGINS: dict[str, str] = {} +_FAILED_PLUGINS: dict[str, str] = {} + + +def strict_plugins_enabled() -> bool: + """Return whether plugin load failures should propagate. + + :returns: ``True`` when ``DREVALPY_STRICT_PLUGINS`` is set to a truthy value + (``1``, ``true``, ``yes`` or ``on``, case-insensitive). + """ + return os.environ.get(STRICT_ENV_VAR, "").strip().lower() in _TRUTHY_VALUES + + +def get_loaded_plugins() -> dict[str, str]: + """Return plugins that were imported successfully. + + :returns: Mapping of entry-point name to the entry-point value (the dotted + object reference the plugin declared). Empty before discovery runs. + """ + return dict(_LOADED_PLUGINS) + + +def get_failed_plugins() -> dict[str, str]: + """Return plugins that raised while being imported. + + :returns: Mapping of entry-point name to the formatted traceback of the + failure. Empty when every declared plugin loaded cleanly. + """ + return dict(_FAILED_PLUGINS) + + +def _load_entry_point(ep: importlib.metadata.EntryPoint) -> None: + """Import one entry point, recording success or failure. + + :param ep: Entry point declared under the ``drevalpy.plugins`` group. + :raises Exception: Any error raised by the plugin, when strict mode is on. + """ + logger.debug("Loading plugin %r", ep.name) + try: + ep.load() + except Exception: + _LOADED_PLUGINS.pop(ep.name, None) + _FAILED_PLUGINS[ep.name] = traceback.format_exc() + if strict_plugins_enabled(): + logger.error("Failed to load drevalpy plugin %r (%s is set)", ep.name, STRICT_ENV_VAR) + raise + logger.warning( + "Failed to load drevalpy plugin %r: its components will be unavailable. " + "See drevalpy.registry.get_failed_plugins() for the traceback, " + "or set %s=1 to make this fatal.", + ep.name, + STRICT_ENV_VAR, + exc_info=True, + ) + else: + _FAILED_PLUGINS.pop(ep.name, None) + _LOADED_PLUGINS[ep.name] = getattr(ep, "value", "") + + +def discover_plugins() -> None: + """Import installed packages declaring the ``drevalpy.plugins`` entry point. + + Uses the ``importlib.metadata.entry_points`` API to find third-party packages + that register themselves under the ``drevalpy.plugins`` group. Each entry point + is loaded (imported), which triggers any registration decorators the plugin + defines. + + An idempotency guard ensures this function only performs discovery once per + process, regardless of how many times it is called. The guard is set before + loading anything, so a plugin that imports drevalpy does not recurse -- and so + a strict-mode failure does not re-run discovery on the next call. + + :raises Exception: The first plugin failure, when ``DREVALPY_STRICT_PLUGINS`` + is set. Otherwise failures are recorded in :func:`get_failed_plugins`. + """ + global _discovered # noqa: PLW0603 + if _discovered: + return + _discovered = True + logger.debug("Discovering plugins") + for ep in importlib.metadata.entry_points(group=ENTRY_POINT_GROUP): + _load_entry_point(ep) diff --git a/drevalpy/registry/cell_line_featurizer/__init__.py b/drevalpy/registry/cell_line_featurizer/__init__.py new file mode 100644 index 000000000..f618179d5 --- /dev/null +++ b/drevalpy/registry/cell_line_featurizer/__init__.py @@ -0,0 +1,14 @@ +"""Cell-line featurizer registry: register, discover, and retrieve cell-line featurizer classes.""" + +from ._registration import get, list, metadata, register, table +from ._registry import CellLineFeaturizerRegistry, cell_line_featurizer_registry + +__all__ = [ + "CellLineFeaturizerRegistry", + "cell_line_featurizer_registry", + "get", + "list", + "metadata", + "register", + "table", +] diff --git a/drevalpy/registry/cell_line_featurizer/_registration.py b/drevalpy/registry/cell_line_featurizer/_registration.py new file mode 100644 index 000000000..83e9a8719 --- /dev/null +++ b/drevalpy/registry/cell_line_featurizer/_registration.py @@ -0,0 +1,70 @@ +"""Public register / get / list / table / metadata helpers for the cell-line featurizer registry.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING, Any + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.types.enums.literature_reference import LiteratureReference + +from ._registry import cell_line_featurizer_registry + +if TYPE_CHECKING: + import pandas as pd + + +def register( + name: str, + *, + description: str, + contract: FeatureContract | FeatureFormat | None = None, + tags: Iterable[str] | None = None, + reference: LiteratureReference | None = None, +) -> Callable[[type[Any]], type[Any]]: + """Decorator: register a cell-line featurizer. + + :param name: Registry name used in ``ModelConfig`` and recipes. + :param description: Short human-readable summary for catalog listings. + :param contract: Feature format contract for predictor compatibility checks. + Falls back to the ``contract`` declared on the class body when omitted. + :param tags: Optional discovery tags (for example ``"omics"``). + :param reference: Optional literature citation metadata. + :returns: Class decorator that registers the decorated featurizer under *name*. + """ + return cell_line_featurizer_registry.register( + name, + description=description, + contract=contract, + tags=tags, + reference=reference, + ) + + +def get(name: str) -> type[Any]: + """Return the cell-line featurizer class registered under *name*. + + :param name: Registry name of the featurizer. + :returns: Featurizer class registered under *name*. + :raises ValueError: If *name* is not registered. + """ + return cell_line_featurizer_registry.get(name) + + +def list() -> list[str]: # noqa: A001 + """Return sorted list of registered cell-line featurizer names.""" + return cell_line_featurizer_registry.list_names() + + +def table() -> pd.DataFrame: + """Return registry contents as a DataFrame.""" + return cell_line_featurizer_registry.to_dataframe() + + +def metadata(name: str) -> dict[str, Any]: + """Return metadata for a registered cell-line featurizer. + + :param name: Registry name of the featurizer. + :returns: Metadata dict including output format and tags. + """ + return cell_line_featurizer_registry.get_metadata(name) diff --git a/drevalpy/registry/cell_line_featurizer/_registry.py b/drevalpy/registry/cell_line_featurizer/_registry.py new file mode 100644 index 000000000..ff12bdb82 --- /dev/null +++ b/drevalpy/registry/cell_line_featurizer/_registry.py @@ -0,0 +1,16 @@ +"""Cell-line featurizer registry singleton and class.""" + +from __future__ import annotations + +from drevalpy.registry.featurizer._base import FeaturizerRegistry + + +class CellLineFeaturizerRegistry(FeaturizerRegistry): + """Registry for cell-line featurizers.""" + + def __init__(self) -> None: + """Initialize with fixed cell-line identity.""" + super().__init__("cell_line_featurizer", "Cell line featurizer", "cell_line_featurizers", side="cell_line") + + +cell_line_featurizer_registry = CellLineFeaturizerRegistry() diff --git a/drevalpy/registry/cell_line_featurizer/_validate.py b/drevalpy/registry/cell_line_featurizer/_validate.py new file mode 100644 index 000000000..c653e46d0 --- /dev/null +++ b/drevalpy/registry/cell_line_featurizer/_validate.py @@ -0,0 +1,7 @@ +"""Cell-line featurizer validation (re-exports shared featurizer validation).""" + +from __future__ import annotations + +from drevalpy.registry.featurizer import validate_featurizer_input_views + +__all__ = ["validate_featurizer_input_views"] diff --git a/drevalpy/registry/components/__init__.py b/drevalpy/registry/components/__init__.py new file mode 100644 index 000000000..00e2b43c7 --- /dev/null +++ b/drevalpy/registry/components/__init__.py @@ -0,0 +1,17 @@ +"""Shared metadata infrastructure for component registries (predictor, featurizer).""" + +from ._base import ComponentRegistry +from ._metadata import base_component_metadata, featurizer_component_metadata, predictor_component_metadata +from ._metadata_validate import validate_literature_reference, validate_registered_class +from ._registration_metadata import apply_registration_metadata, normalize_registration_metadata + +__all__ = [ + "ComponentRegistry", + "apply_registration_metadata", + "base_component_metadata", + "featurizer_component_metadata", + "normalize_registration_metadata", + "predictor_component_metadata", + "validate_literature_reference", + "validate_registered_class", +] diff --git a/drevalpy/registry/components/_abstract.py b/drevalpy/registry/components/_abstract.py new file mode 100644 index 000000000..34a3785b6 --- /dev/null +++ b/drevalpy/registry/components/_abstract.py @@ -0,0 +1,40 @@ +"""Registration-time rejection of classes with unimplemented abstract methods. + +A component that forgets ``_fit`` registers happily and only fails when the +experiment instantiates it, far from the cause. The registries therefore reject +such a class up front, naming the members it still has to implement. +""" + +from __future__ import annotations + +from typing import Any + + +def abstract_members(cls: type[Any]) -> tuple[str, ...]: + """Return the abstract members *cls* has not implemented. + + :param cls: Component class being registered. + :returns: Sorted member names still declared abstract, empty when the class + is concrete (or is not an ABC at all). + """ + return tuple(sorted(getattr(cls, "__abstractmethods__", frozenset()))) + + +def validate_no_abstract_methods(registry_id: str, name: str, cls: type[Any]) -> None: + """Raise ``ValueError`` when *cls* still has unimplemented abstract members. + + :param registry_id: Registry identifier used in the error message. + :param name: Registry name under which *cls* is being registered. + :param cls: Component class being registered. + :raises ValueError: If the class cannot be instantiated because abstract + members remain unimplemented. + """ + missing = abstract_members(cls) + if not missing: + return + msg = ( + f"{registry_id} '{name}' ({cls.__name__}) does not implement " + f"{', '.join(missing)}. Implement the missing member(s), or register a " + "concrete subclass instead of an abstract base." + ) + raise ValueError(msg) diff --git a/drevalpy/registry/components/_base.py b/drevalpy/registry/components/_base.py new file mode 100644 index 000000000..38bacb4d3 --- /dev/null +++ b/drevalpy/registry/components/_base.py @@ -0,0 +1,56 @@ +"""ComponentRegistry -- shared base for predictor and featurizer registries. + +Adds ``register_existing()`` and the registry-wide class invariants on top of the +abstract ``Registry`` base. The two concerns that used to sit here but read no +registry state - resolving a contract and stamping registration metadata onto a +class - live in ``_contract_assignment.py`` and ``_registration_metadata.py``, and +the concrete registries call those directly. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from drevalpy.log import get_logger +from drevalpy.registry._base import Registry +from drevalpy.registry.components._abstract import validate_no_abstract_methods +from drevalpy.registry.components._metadata_validate import validate_registered_class + +logger = get_logger(__name__) + + +class ComponentRegistry(Registry): + """Base for registries whose entries carry validated registration metadata.""" + + _required_fields: ClassVar[tuple[str, ...]] = ("description",) + + def register_existing(self, name: str, cls: type[Any]) -> None: + """Register a class that was previously decorated but removed via ``clear``. + + :param name: Registry name under which *cls* should be restored. + :param cls: Component class with registration metadata attributes. + """ + with self._lock: + if name in self._store: + return + validate_registered_class( + self._registry_id, + name, + cls, + required_fields=self._required_fields, + ) + self._validate_registration(name, cls) + self._store[name] = cls + cls.registry_name = name + logger.debug("Registered %s: %s", self._label, name) + + def _validate_registration(self, name: str, cls: type[Any]) -> None: + """Run registry-specific class invariants after metadata validation. + + Subclasses that override this must call ``super()`` so the shared + abstract-member check keeps running. + + :param name: Registry name under which *cls* is being registered. + :param cls: Component class being registered or restored. + """ + validate_no_abstract_methods(self._registry_id, name, cls) diff --git a/drevalpy/registry/components/_contract_assignment.py b/drevalpy/registry/components/_contract_assignment.py new file mode 100644 index 000000000..f8e59c3ee --- /dev/null +++ b/drevalpy/registry/components/_contract_assignment.py @@ -0,0 +1,64 @@ +"""Contract resolution for component registration. + +Deciding *which* contract a component ends up with - the one passed to the +registration decorator or the one declared in the class body - is a pure function +of the class and the decorator argument. It touches no registry state, which is +what made ``ComponentRegistry`` incohesive while it lived there, and it is shared +verbatim by the featurizer registry (one ``contract``) and the predictor registry +(a ``cell_line_contract`` and a ``drug_contract``). +""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.components.contracts.contracts import FeatureContract, normalize_feature_contract +from drevalpy.log import get_logger + +logger = get_logger(__name__) + + +def assign_contract(cls: type[Any], attr_name: str, contract: FeatureContract | None) -> None: + """Set *attr_name* on *cls* to the contract that wins. + + A class-body declaration is a valid way to state a contract: it is used + whenever the registration decorator was given none. When both are present the + decorator wins, since it is the more specific of the two. + + :param cls: Class being registered. + :param attr_name: Attribute name such as ``contract`` or ``cell_line_contract``. + :param contract: Already-normalized feature contract from the decorator, or + ``None`` to fall back to the class declaration. + :raises ValueError: If neither the decorator nor the class declares *attr_name*. + """ + if contract is None: + contract = declared_contract(cls, attr_name) + elif attr_name in cls.__dict__: + logger.debug( + "%s: decorator %s overrides the class-body declaration", + cls.__name__, + attr_name, + ) + setattr(cls, attr_name, contract) + + +def declared_contract(cls: type[Any], attr_name: str) -> FeatureContract: + """Return the contract *cls* declares under *attr_name*. + + :param cls: Class being registered. + :param attr_name: Attribute name such as ``contract`` or ``cell_line_contract``. + :returns: Normalized contract taken from the class declaration. + :raises ValueError: If the class declares no usable contract. + """ + declared = getattr(cls, attr_name, None) + if declared is None: + msg = ( + f"{cls.__name__}: no {attr_name} declared; pass {attr_name}= to the " + f"registration decorator or set it on the class body" + ) + raise ValueError(msg) + try: + return normalize_feature_contract(declared) + except TypeError as exc: + msg = f"{cls.__name__}: class-body {attr_name} is invalid: {exc}" + raise ValueError(msg) from exc diff --git a/drevalpy/registry/components/_metadata.py b/drevalpy/registry/components/_metadata.py new file mode 100644 index 000000000..0b9b040b8 --- /dev/null +++ b/drevalpy/registry/components/_metadata.py @@ -0,0 +1,76 @@ +"""Build discovery/catalog metadata dicts for registered components.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.components.contracts.contracts import featurizer_contract +from drevalpy.types.enums.literature_reference import LiteratureReference + + +def _reference_fields(cls: type[Any]) -> dict[str, str]: + reference = getattr(cls, "reference", None) + if not isinstance(reference, LiteratureReference): + return { + "repo_url": "", + "citation": "", + "citation_doi": "", + "citation_text": "", + "deviations": "", + } + cite = reference.citation_doi or reference.citation_text + if cite.startswith("10."): + cite = f"https://doi.org/{cite}" + return { + "repo_url": reference.repo_url, + "citation": cite, + "citation_doi": reference.citation_doi, + "citation_text": reference.citation_text, + "deviations": reference.deviations, + } + + +def base_component_metadata(registry_name: str, name: str, cls: type[Any]) -> dict[str, Any]: + """Return shared discovery fields for a registered component. + + :param registry_name: registry name. + :param name: name. + :param cls: Registered component class. + :returns: Catalog metadata dict. + """ + fields: dict[str, Any] = { + "registry": registry_name, + "name": name, + "class_name": cls.__name__, + "description": str(getattr(cls, "description", "") or ""), + "tags": getattr(cls, "tags", frozenset()), + } + fields.update(_reference_fields(cls)) + return fields + + +def featurizer_component_metadata(registry_name: str, name: str, cls: type[Any]) -> dict[str, Any]: + """Like `base_component_metadata` plus featurizer output format. + + :param registry_name: registry name. + :param name: name. + :param cls: Registered featurizer class. + :returns: Catalog metadata dict. + """ + meta = base_component_metadata(registry_name, name, cls) + meta["output_format"] = featurizer_contract(cls).format.value + meta["precompute"] = getattr(cls, "precompute", False) + return meta + + +def predictor_component_metadata(registry_name: str, name: str, cls: type[Any]) -> dict[str, Any]: + """Like `base_component_metadata` plus predictor input interface. + + :param registry_name: registry name. + :param name: name. + :param cls: Registered predictor class. + :returns: Catalog metadata dict. + """ + meta = base_component_metadata(registry_name, name, cls) + meta["input_interface"] = getattr(cls, "input_interface", "") + return meta diff --git a/drevalpy/registry/components/_metadata_validate.py b/drevalpy/registry/components/_metadata_validate.py new file mode 100644 index 000000000..79338af79 --- /dev/null +++ b/drevalpy/registry/components/_metadata_validate.py @@ -0,0 +1,145 @@ +"""Class-state validation for restored registered components.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.components.contracts.contracts import FeatureContract +from drevalpy.types.enums.literature_reference import LiteratureReference + +_CONTRACT_FIELDS = frozenset({"contract", "cell_line_contract", "drug_contract"}) + + +def _is_valid_url(url: str) -> bool: + return url.startswith(("http://", "https://")) + + +def validate_literature_reference(reference: LiteratureReference) -> list[str]: + """Return invalid-field names for a literature reference, or an empty list. + + :param reference: reference. + :returns: Result. + """ + invalid: list[str] = [] + if not reference.repo_url or not _is_valid_url(reference.repo_url): + invalid.append("repo_url") + if not (reference.citation_text or reference.citation_doi): + invalid.append("citation") + if not reference.deviations: + invalid.append("deviations") + return invalid + + +def _has_invalid_tags(tags: object) -> bool: + """Return ``True`` if ``tags`` fails the registration invariant. + + Valid tags are a ``frozenset`` of non-empty strings (use an empty + ``frozenset`` when there are no tags). Anything else is invalid. + + :param tags: Candidate tag collection from a registered class. + :returns: ``True`` when *tags* is not a valid ``frozenset`` of non-empty strings. + """ + if not isinstance(tags, frozenset): + return True + return any(not isinstance(tag, str) or not tag for tag in tags) + + +def _missing_fields(cls: type[Any], required_fields: tuple[str, ...]) -> list[str]: + """Return required fields missing from ``cls`` or present as blank strings. + + :param cls: Registered component class. + :param required_fields: Field names that must appear on the class body. + :returns: Names of required fields that are absent or empty. + """ + missing: list[str] = [] + for field in required_fields: + if field not in cls.__dict__: + missing.append(field) + continue + value = cls.__dict__[field] + if isinstance(value, str) and not value.strip(): + missing.append(field) + return missing + + +def _invalid_shared_fields(cls: type[Any]) -> list[str]: + """Return shared metadata fields with invalid values on ``cls``. + + :param cls: Registered component class. + :returns: Names of metadata fields with invalid values. + """ + invalid: list[str] = [] + if _has_invalid_tags(getattr(cls, "tags", frozenset())): + invalid.append("tags") + + reference = getattr(cls, "reference", None) + if reference is None: + return invalid + if not isinstance(reference, LiteratureReference): + invalid.append("reference") + return invalid + invalid.extend(validate_literature_reference(reference)) + return invalid + + +def _invalid_contract_fields(cls: type[Any], required_fields: tuple[str, ...]) -> list[str]: + """Return required contract fields that are not ``FeatureContract`` instances. + + :param cls: Registered component class. + :param required_fields: Field names required on the class body. + :returns: Contract field names with the wrong runtime type. + """ + invalid: list[str] = [] + for field in required_fields: + if field not in _CONTRACT_FIELDS or field not in cls.__dict__: + continue + if not isinstance(cls.__dict__[field], FeatureContract): + invalid.append(field) + return invalid + + +def _format_validation_error( + registry_id: str, + name: str, + *, + missing: list[str], + invalid: list[str], +) -> str: + """Format a registry metadata validation error. + + :param registry_id: Registry identifier. + :param name: Component registry name. + :param missing: Required metadata fields that are absent. + :param invalid: Metadata fields with invalid values. + :returns: Human-readable validation error message. + """ + parts: list[str] = [] + if missing: + parts.append(f"missing={missing}") + if invalid: + parts.append(f"invalid={invalid}") + return f"{registry_id} '{name}' metadata validation failed: " + ", ".join(parts) + + +def validate_registered_class( + registry_id: str, + name: str, + cls: type[Any], + *, + required_fields: tuple[str, ...], +) -> None: + """Raise ``ValueError`` if a restored class lacks valid registration state. + + Used by ``register_existing`` when decorator kwargs are unavailable. + + :param registry_id: registry id. + :param name: Component registry name. + :param cls: Previously registered component class. + :param required_fields: Field names required on the class body for this registry. + :raises ValueError: Raised on invalid input. + """ + missing = _missing_fields(cls, required_fields) + invalid = _invalid_shared_fields(cls) + invalid.extend(_invalid_contract_fields(cls, required_fields)) + if missing or invalid: + raise ValueError(_format_validation_error(registry_id, name, missing=missing, invalid=invalid)) diff --git a/drevalpy/registry/components/_registration_metadata.py b/drevalpy/registry/components/_registration_metadata.py new file mode 100644 index 000000000..c5fe0ffc2 --- /dev/null +++ b/drevalpy/registry/components/_registration_metadata.py @@ -0,0 +1,87 @@ +"""Normalized shared registration metadata for fresh decorator registration.""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any + +from drevalpy.registry.components._metadata_validate import validate_literature_reference +from drevalpy.types.enums.literature_reference import LiteratureReference + + +@dataclass(frozen=True) +class RegistrationMetadata: + """Validated description, tags, and optional literature reference.""" + + description: str + tags: frozenset[str] + reference: LiteratureReference | None + + +def _normalize_description(description: str) -> str: + stripped = str(description).strip() + if not stripped: + msg = "description must be a non-empty string" + raise ValueError(msg) + return stripped + + +def _normalize_tags(tags: Iterable[str] | None) -> frozenset[str]: + if tags is None: + return frozenset() + if isinstance(tags, str): + msg = "tags must be an iterable of strings, not a bare string" + raise TypeError(msg) + cleaned: list[str] = [] + for tag in tags: + if not isinstance(tag, str): + msg = f"tags must contain strings, got {type(tag).__name__}" + raise TypeError(msg) + stripped = tag.strip() + if stripped: + cleaned.append(stripped) + return frozenset(cleaned) + + +def _normalize_reference(reference: LiteratureReference | None) -> LiteratureReference | None: + if reference is None: + return None + if not isinstance(reference, LiteratureReference): + msg = f"reference must be LiteratureReference, got {type(reference).__name__}" + raise TypeError(msg) + invalid = validate_literature_reference(reference) + if invalid: + msg = f"reference has invalid fields: {invalid}" + raise ValueError(msg) + return reference + + +def normalize_registration_metadata( + description: str, + tags: Iterable[str] | None = None, + reference: LiteratureReference | None = None, +) -> RegistrationMetadata: + """Validate and normalize shared registration kwargs. + + :param description: Short human-readable summary. + :param tags: Optional discovery tags. + :param reference: Optional literature citation metadata. + :returns: Normalized registration metadata. + """ + return RegistrationMetadata( + description=_normalize_description(description), + tags=_normalize_tags(tags), + reference=_normalize_reference(reference), + ) + + +def apply_registration_metadata(cls: type[Any], metadata: RegistrationMetadata) -> None: + """Attach already-normalized registration metadata to *cls*. + + :param cls: Class receiving registration metadata. + :param metadata: Normalized description, tags, and optional reference. + """ + cls.description = metadata.description + cls.tags = metadata.tags + cls.reference = metadata.reference diff --git a/drevalpy/registry/dataset/__init__.py b/drevalpy/registry/dataset/__init__.py new file mode 100644 index 000000000..e6dadbc0c --- /dev/null +++ b/drevalpy/registry/dataset/__init__.py @@ -0,0 +1,41 @@ +"""Dataset registry: register, discover, and manage dataset sources and entries.""" + +from ._io import config_lock, get_config_path, load_config, save_config +from ._models import DatasetEntry, DrevalConfig, SourceEntry +from ._registry import DatasetRegistry, dataset_registry + +__all__ = [ + "DatasetEntry", + "DatasetRegistry", + "DrevalConfig", + "SourceEntry", + "config_lock", + "dataset_registry", + "get_config_path", + "list", + "load_config", + "register_dataset", + "register_source", + "save_config", + "table", +] + + +def register_source(name: str, base_url: str, storage_options: dict | None = None) -> None: + """Register a custom source (base URL + optional fsspec storage options).""" + dataset_registry.register_source(name, base_url, storage_options) + + +def register_dataset(name: str, source: str, file: str) -> None: + """Register a custom dataset under an existing source.""" + dataset_registry.register_dataset(name, source, file) + + +def list() -> list[str]: # noqa: A001 + """Return sorted list of all registered dataset names.""" + return dataset_registry.dataset_names + + +def table(): + """Return registry contents as a DataFrame.""" + return dataset_registry.to_dataframe() diff --git a/drevalpy/registry/dataset/_io.py b/drevalpy/registry/dataset/_io.py new file mode 100644 index 000000000..1c6153299 --- /dev/null +++ b/drevalpy/registry/dataset/_io.py @@ -0,0 +1,75 @@ +"""Config file I/O with file locking for drevalpy dataset registry.""" + +from __future__ import annotations + +import json +from collections.abc import Generator +from contextlib import contextmanager + +from filelock import FileLock +from upath import UPath as Path + +from ._models import DrevalConfig + +_LOCK_TIMEOUT = 10 + + +def get_config_path() -> Path: + """Return path to the dataset registry config file. + + :returns: Path to ``datasets.json`` in the config directory. + """ + from drevalpy.data._paths import get_config_dir + + return get_config_dir() / "datasets.json" + + +def _lock_path() -> Path: + """Return the lock file path (sibling of the config file).""" + return get_config_path().with_suffix(".lock") + + +@contextmanager +def config_lock() -> Generator[None, None, None]: + """Acquire an exclusive file lock for config read-modify-write operations. + + ``is_singleton=True`` makes the lock reentrant: nesting two ``config_lock()`` + blocks in the same process reuses one underlying lock and its recursion + counter, instead of deadlocking a second independent ``FileLock`` until the + timeout expires. + + :yields: Nothing; the lock is held for the duration of the context. + """ + lock = FileLock(_lock_path(), timeout=_LOCK_TIMEOUT, is_singleton=True) + with lock: + yield + + +def load_config() -> DrevalConfig: + """Read the user config file, returning defaults if it doesn't exist. + + Does NOT acquire the lock -- callers performing read-modify-write should + use ``config_lock()`` to wrap the entire operation. + + :returns: Parsed config. + """ + path = get_config_path() + if not path.is_file(): + return DrevalConfig() + with open(path, encoding="utf-8") as f: + raw = json.load(f) + return DrevalConfig.from_raw(raw) + + +def save_config(config: DrevalConfig) -> None: + """Write the config to disk, creating the directory if needed. + + Does NOT acquire the lock -- callers should wrap with ``config_lock()``. + + :param config: Config to persist. + """ + path = get_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(config.to_raw(), f, indent=2) + f.write("\n") diff --git a/drevalpy/registry/dataset/_models.py b/drevalpy/registry/dataset/_models.py new file mode 100644 index 000000000..15dffd218 --- /dev/null +++ b/drevalpy/registry/dataset/_models.py @@ -0,0 +1,60 @@ +"""Pydantic models for drevalpy user configuration.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class SourceEntry(BaseModel): + """A dataset source with a base URL and optional fsspec storage options.""" + + url: str + storage_options: dict[str, Any] = Field(default_factory=dict) + + @classmethod + def from_raw(cls, raw: str | dict[str, Any]) -> SourceEntry: + """Parse either a plain URL string or a {url, storage_options} dict.""" + if isinstance(raw, str): + return cls(url=raw) + return cls.model_validate(raw) + + def to_raw(self) -> str | dict[str, Any]: + """Serialize back: plain string if no storage_options, else dict.""" + if not self.storage_options: + return self.url + return {"url": self.url, "storage_options": self.storage_options} + + +class DatasetEntry(BaseModel): + """A registered dataset pointing to a source and filename.""" + + source: str + file: str + + +class DrevalConfig(BaseModel): + """Dataset registry config file schema. + + The file contains only sources and datasets at the root level. + """ + + model_config = {"extra": "forbid"} + + sources: dict[str, SourceEntry] = Field(default_factory=dict) + datasets: dict[str, DatasetEntry] = Field(default_factory=dict) + + @classmethod + def from_raw(cls, raw: dict[str, Any]) -> DrevalConfig: + """Parse from a raw JSON dict.""" + sources = {name: SourceEntry.from_raw(val) for name, val in raw.get("sources", {}).items()} + datasets = {name: DatasetEntry.model_validate(val) for name, val in raw.get("datasets", {}).items()} + return cls(sources=sources, datasets=datasets) + + def to_raw(self) -> dict[str, Any]: + """Serialize to JSON-compatible dict.""" + return { + "sources": {name: entry.to_raw() for name, entry in self.sources.items()}, + "datasets": {name: entry.model_dump() for name, entry in self.datasets.items()}, + } diff --git a/drevalpy/registry/dataset/_registry.py b/drevalpy/registry/dataset/_registry.py new file mode 100644 index 000000000..1f0ff259a --- /dev/null +++ b/drevalpy/registry/dataset/_registry.py @@ -0,0 +1,198 @@ +"""Dataset registry: built-in + user-registered sources and datasets.""" + +from __future__ import annotations + +import json +from importlib import resources +from typing import TYPE_CHECKING, Any + +from ._io import config_lock, load_config, save_config +from ._models import DatasetEntry, DrevalConfig, SourceEntry + +if TYPE_CHECKING: + import pandas as pd + +_REGISTRY_JSON = "available_datasets.json" + + +class DatasetRegistry: + """Dataset registry merging built-in and user-registered datasets. + + Built-in and custom entries are stored separately. The combined view + is a computed property so it always reflects the current state. + Mutation methods use file locking for atomic read-modify-write. + """ + + def __init__(self) -> None: + """Initialize with lazy loading of built-in and custom registries.""" + self._builtin: DrevalConfig | None = None + self._custom: DrevalConfig | None = None + + def _ensure_loaded(self) -> None: + """Load registries on first access.""" + if self._builtin is None: + registry_path = resources.files("drevalpy.data.datasets").joinpath(_REGISTRY_JSON) + with registry_path.open(encoding="utf-8") as handle: + raw = json.load(handle) + self._builtin = DrevalConfig.from_raw(raw) + if self._custom is None: + self._custom = load_config() + + @property + def sources(self) -> dict[str, SourceEntry]: + """All sources (custom overrides built-in).""" + self._ensure_loaded() + return {**self._builtin.sources, **self._custom.sources} # type: ignore[union-attr] + + @property + def datasets(self) -> dict[str, DatasetEntry]: + """All datasets (custom overrides built-in).""" + self._ensure_loaded() + return {**self._builtin.datasets, **self._custom.datasets} # type: ignore[union-attr] + + @property + def builtin_sources(self) -> dict[str, SourceEntry]: + """Only built-in sources (read-only).""" + self._ensure_loaded() + return self._builtin.sources # type: ignore[union-attr] + + @property + def builtin_datasets(self) -> dict[str, DatasetEntry]: + """Only built-in datasets (read-only).""" + self._ensure_loaded() + return self._builtin.datasets # type: ignore[union-attr] + + @property + def custom_sources(self) -> dict[str, SourceEntry]: + """Only user-registered sources.""" + self._ensure_loaded() + return self._custom.sources # type: ignore[union-attr] + + @property + def custom_datasets(self) -> dict[str, DatasetEntry]: + """Only user-registered datasets.""" + self._ensure_loaded() + return self._custom.datasets # type: ignore[union-attr] + + @property + def dataset_names(self) -> list[str]: + """Sorted list of all registered dataset names (built-in + custom).""" + return sorted(self.datasets) + + @property + def source_names(self) -> list[str]: + """Sorted list of all registered source names (built-in + custom).""" + return sorted(self.sources) + + def to_dataframe(self) -> pd.DataFrame: + """Return registry contents as a pandas DataFrame.""" + import pandas as pd + + rows = [] + for name in sorted(self.datasets): + entry = self.datasets[name] + origin = "custom" if name in self._custom.datasets else "built-in" + rows.append({"Name": name, "Source": entry.source, "File": entry.file, "Origin": origin}) + return pd.DataFrame(rows) + + def __repr__(self) -> str: + """Return a tabular string representation.""" + return self.to_dataframe().to_string(index=False) + + def _repr_html_(self) -> str: + """HTML table for Jupyter notebooks.""" + return self.to_dataframe().to_html(index=False) + + def is_registered(self, name: str) -> bool: + """Return whether ``name`` is a registered dataset. + + :param name: Dataset name to look up. + :returns: ``True`` when ``name`` is registered. + """ + return name in self.datasets + + def register_source(self, name: str, base_url: str, storage_options: dict[str, Any] | None = None) -> None: + """Register a custom source (base URL + optional fsspec storage options). + + Atomically reads the config, applies the change, and writes back. + + :param name: Source name (used to reference from dataset entries). + :param base_url: Base URL (any fsspec-compatible protocol: https, s3, gs, az, ...). + :param storage_options: Optional dict passed to fsspec for auth/config. + """ + entry = SourceEntry(url=base_url, storage_options=storage_options or {}) + with config_lock(): + config = load_config() + config.sources[name] = entry + save_config(config) + self._custom = config + + def register_dataset(self, name: str, source: str, file: str) -> None: + """Register a custom dataset under an existing source. + + Atomically reads the config, applies the change, and writes back. + + :param name: Dataset name (used with ``drevalpy.data.load``). + :param source: Source name (must be registered). + :param file: Filename of the .h5mu file at the source URL. + :raises KeyError: If the source is not registered. + """ + if source not in self.sources: + raise KeyError(f"Source '{source}' not registered. Register it first with register_source().") + + with config_lock(): + config = load_config() + config.datasets[name] = DatasetEntry(source=source, file=file) + save_config(config) + self._custom = config + + def unregister_dataset(self, name: str) -> None: + """Remove a custom dataset registration. + + :param name: Dataset name to remove. + :raises KeyError: If the dataset is not in the custom registry. + """ + self._ensure_loaded() + with config_lock(): + config = load_config() + if name not in config.datasets: + raise KeyError(f"Dataset '{name}' not in custom registry.") + if name in self._builtin.datasets: # type: ignore[union-attr] + raise KeyError(f"Dataset '{name}' is built-in and cannot be unregistered.") + del config.datasets[name] + save_config(config) + self._custom = config + + def unregister_source(self, name: str) -> None: + """Remove a custom source registration. + + :param name: Source name to remove. + :raises KeyError: If the source is not in the custom registry. + :raises ValueError: If datasets still reference this source. + """ + self._ensure_loaded() + with config_lock(): + config = load_config() + if name not in config.sources: + raise KeyError(f"Source '{name}' not in custom registry.") + if name in self._builtin.sources: # type: ignore[union-attr] + raise KeyError(f"Source '{name}' is built-in and cannot be unregistered.") + + referencing = [ds for ds, entry in config.datasets.items() if entry.source == name] + if referencing: + raise ValueError(f"Cannot remove source '{name}': still referenced by datasets {referencing}") + + del config.sources[name] + save_config(config) + self._custom = config + + def reload(self) -> None: + """Re-read the custom registry from disk. + + Useful after external modifications to the config file. + """ + self._ensure_loaded() + self._custom = load_config() + + +dataset_registry = DatasetRegistry() diff --git a/drevalpy/registry/drug_featurizer/__init__.py b/drevalpy/registry/drug_featurizer/__init__.py new file mode 100644 index 000000000..cee307643 --- /dev/null +++ b/drevalpy/registry/drug_featurizer/__init__.py @@ -0,0 +1,14 @@ +"""Drug featurizer registry: register, discover, and retrieve drug featurizer classes.""" + +from ._registration import get, list, metadata, register, table +from ._registry import DrugFeaturizerRegistry, drug_featurizer_registry + +__all__ = [ + "DrugFeaturizerRegistry", + "drug_featurizer_registry", + "get", + "list", + "metadata", + "register", + "table", +] diff --git a/drevalpy/registry/drug_featurizer/_registration.py b/drevalpy/registry/drug_featurizer/_registration.py new file mode 100644 index 000000000..7ec4e25a1 --- /dev/null +++ b/drevalpy/registry/drug_featurizer/_registration.py @@ -0,0 +1,70 @@ +"""Public register / get / list / table / metadata helpers for the drug featurizer registry.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING, Any + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.types.enums.literature_reference import LiteratureReference + +from ._registry import drug_featurizer_registry + +if TYPE_CHECKING: + import pandas as pd + + +def register( + name: str, + *, + description: str, + contract: FeatureContract | FeatureFormat | None = None, + tags: Iterable[str] | None = None, + reference: LiteratureReference | None = None, +) -> Callable[[type[Any]], type[Any]]: + """Decorator: register a drug featurizer. + + :param name: Registry name used in ``ModelConfig`` and recipes. + :param description: Short human-readable summary for catalog listings. + :param contract: Feature format contract for predictor compatibility checks. + Falls back to the ``contract`` declared on the class body when omitted. + :param tags: Optional discovery tags. + :param reference: Optional literature citation metadata. + :returns: Class decorator that registers the decorated featurizer under *name*. + """ + return drug_featurizer_registry.register( + name, + description=description, + contract=contract, + tags=tags, + reference=reference, + ) + + +def get(name: str) -> type[Any]: + """Return the drug featurizer class registered under *name*. + + :param name: Registry name of the featurizer. + :returns: Featurizer class registered under *name*. + :raises ValueError: If *name* is not registered. + """ + return drug_featurizer_registry.get(name) + + +def list() -> list[str]: # noqa: A001 + """Return sorted list of registered drug featurizer names.""" + return drug_featurizer_registry.list_names() + + +def table() -> pd.DataFrame: + """Return registry contents as a DataFrame.""" + return drug_featurizer_registry.to_dataframe() + + +def metadata(name: str) -> dict[str, Any]: + """Return metadata for a registered drug featurizer. + + :param name: Registry name of the featurizer. + :returns: Metadata dict including output format and tags. + """ + return drug_featurizer_registry.get_metadata(name) diff --git a/drevalpy/registry/drug_featurizer/_registry.py b/drevalpy/registry/drug_featurizer/_registry.py new file mode 100644 index 000000000..8852b9b05 --- /dev/null +++ b/drevalpy/registry/drug_featurizer/_registry.py @@ -0,0 +1,16 @@ +"""Drug featurizer registry singleton and class.""" + +from __future__ import annotations + +from drevalpy.registry.featurizer._base import FeaturizerRegistry + + +class DrugFeaturizerRegistry(FeaturizerRegistry): + """Registry for drug featurizers.""" + + def __init__(self) -> None: + """Initialize with fixed drug identity.""" + super().__init__("drug_featurizer", "Drug featurizer", "drug_featurizers", side="drug") + + +drug_featurizer_registry = DrugFeaturizerRegistry() diff --git a/drevalpy/registry/drug_featurizer/_validate.py b/drevalpy/registry/drug_featurizer/_validate.py new file mode 100644 index 000000000..152b28e41 --- /dev/null +++ b/drevalpy/registry/drug_featurizer/_validate.py @@ -0,0 +1,7 @@ +"""Drug featurizer validation (re-exports shared featurizer validation).""" + +from __future__ import annotations + +from drevalpy.registry.featurizer import validate_featurizer_input_views + +__all__ = ["validate_featurizer_input_views"] diff --git a/drevalpy/registry/featurizer/__init__.py b/drevalpy/registry/featurizer/__init__.py new file mode 100644 index 000000000..c627db41d --- /dev/null +++ b/drevalpy/registry/featurizer/__init__.py @@ -0,0 +1,6 @@ +"""Shared featurizer registry infrastructure used by cell_line and drug sub-packages.""" + +from ._base import FeaturizerRegistry +from ._validate import validate_featurizer_input_views + +__all__ = ["FeaturizerRegistry", "validate_featurizer_input_views"] diff --git a/drevalpy/registry/featurizer/_base.py b/drevalpy/registry/featurizer/_base.py new file mode 100644 index 000000000..2b93f30d9 --- /dev/null +++ b/drevalpy/registry/featurizer/_base.py @@ -0,0 +1,89 @@ +"""Shared FeaturizerRegistry class for cell-line and drug featurizer registries.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any, ClassVar + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat, normalize_feature_contract +from drevalpy.components.contracts.hyperparameter_space import validate_component_hyperparameter_space +from drevalpy.registry.components._base import ComponentRegistry +from drevalpy.registry.components._contract_assignment import assign_contract +from drevalpy.registry.components._metadata import featurizer_component_metadata +from drevalpy.registry.components._registration_metadata import ( + apply_registration_metadata, + normalize_registration_metadata, +) +from drevalpy.registry.featurizer._validate import validate_featurizer_input_views +from drevalpy.types.enums.literature_reference import LiteratureReference + + +class FeaturizerRegistry(ComponentRegistry): + """Registry for featurizers that emit one feature contract.""" + + _required_fields: ClassVar[tuple[str, ...]] = ("description", "contract") + _side: str = "" + + def __init__(self, registry_id: str, label: str, display_name: str, *, side: str = "") -> None: + """Initialize with an optional side designation. + + :param registry_id: Stable identifier. + :param label: Human-readable label. + :param display_name: Catalog name. + :param side: Entity side ("cell_line" or "drug"). + """ + super().__init__(registry_id, label, display_name) + self._side = side + + def register( + self, + name: str, + *, + description: str, + contract: FeatureContract | FeatureFormat | None = None, + tags: Iterable[str] | None = None, + reference: LiteratureReference | None = None, + ) -> Callable[[type[Any]], type[Any]]: + """Return a class decorator that registers a featurizer under *name*. + + :param name: Registry name used in model configs and discovery listings. + :param description: Short human-readable summary. + :param contract: Feature format contract for predictor matching. When + omitted, the ``contract`` declared on the class body is used. + :param tags: Optional discovery tags. + :param reference: Optional literature citation metadata. + :returns: Class decorator that registers and returns the decorated class. + """ + metadata = normalize_registration_metadata(description, tags, reference) + normalized_contract = None if contract is None else normalize_feature_contract(contract) + + def decorator(cls: type[Any]) -> type[Any]: + with self._lock: + if name in self._store: + msg = f"{self._label} {name!r} already registered" + raise ValueError(msg) + assign_contract(cls, "contract", normalized_contract) + apply_registration_metadata(cls, metadata) + self._validate_registration(name, cls) + self._store[name] = cls + cls.registry_name = name + if not getattr(cls, "storage_key", ""): + cls.storage_key = name + if self._side: + cls.side = self._side + return cls + + return decorator + + def _validate_registration(self, name: str, cls: type[Any]) -> None: + """Enforce featurizer class invariants at registration time. + + :param name: Registry name under which *cls* is being registered. + :param cls: Featurizer class with contract metadata already attached. + """ + super()._validate_registration(name, cls) + validate_component_hyperparameter_space(name, cls) + validate_featurizer_input_views(self._registry_id, name, cls) + + def _component_metadata(self, name: str, cls: type[Any]) -> dict[str, Any]: + return featurizer_component_metadata(self._display_name, name, cls) diff --git a/drevalpy/registry/featurizer/_validate.py b/drevalpy/registry/featurizer/_validate.py new file mode 100644 index 000000000..9fd90581b --- /dev/null +++ b/drevalpy/registry/featurizer/_validate.py @@ -0,0 +1,48 @@ +"""Registration-time invariants for featurizer input-view declarations.""" + +from __future__ import annotations + +from typing import Any + + +def _declares_input_views(cls: type[Any]) -> bool: + """Return whether *cls* states which raw views it reads. + + :param cls: Featurizer class being registered. + :returns: ``True`` when the class declares, parameterizes, or computes its input views. + """ + from drevalpy.components.featurizers.base import Featurizer + + if cls.input_views is not None: + return True + if cls.requires_view or cls.entity_id_only: + return True + return getattr(cls.resolve_input_views, "__func__", None) is not getattr( + Featurizer.resolve_input_views, "__func__", None + ) + + +def validate_featurizer_input_views(registry_id: str, name: str, cls: type[Any]) -> None: + """Raise ``ValueError`` when a featurizer does not declare its input views. + + Every featurizer must state which raw feature views it consumes so that the + data-loading layer never needs a name-to-view lookup table. Classes that do not + derive from ``Featurizer`` are skipped; they do not participate in view resolution. + + :param registry_id: Registry identifier used in the error message. + :param name: Registry name under which *cls* is being registered. + :param cls: Featurizer class being registered. + :raises ValueError: If the class declares no input views. + """ + from drevalpy.components.featurizers.base import Featurizer + + if not (isinstance(cls, type) and issubclass(cls, Featurizer)): + return + if _declares_input_views(cls): + return + msg = ( + f"{registry_id} '{name}' ({cls.__name__}) does not declare its input views. " + "Set input_views on the class body (use () when only entity ids are needed), " + "set requires_view/entity_id_only, or override resolve_input_views." + ) + raise ValueError(msg) diff --git a/drevalpy/registry/predictor/__init__.py b/drevalpy/registry/predictor/__init__.py new file mode 100644 index 000000000..cb03e5dad --- /dev/null +++ b/drevalpy/registry/predictor/__init__.py @@ -0,0 +1,14 @@ +"""Predictor registry: register, discover, and retrieve predictor classes.""" + +from drevalpy.registry.predictor._registration import get, list, metadata, register, table +from drevalpy.registry.predictor._registry import PredictorRegistry, predictor_registry + +__all__ = [ + "PredictorRegistry", + "get", + "list", + "metadata", + "predictor_registry", + "register", + "table", +] diff --git a/drevalpy/registry/predictor/_metadata.py b/drevalpy/registry/predictor/_metadata.py new file mode 100644 index 000000000..05859e90b --- /dev/null +++ b/drevalpy/registry/predictor/_metadata.py @@ -0,0 +1,20 @@ +"""Build discovery/catalog metadata dicts for registered predictors.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.registry.components._metadata import base_component_metadata + + +def predictor_component_metadata(registry_name: str, name: str, cls: type[Any]) -> dict[str, Any]: + """Like `base_component_metadata` plus predictor input interface. + + :param registry_name: registry name. + :param name: name. + :param cls: Registered predictor class. + :returns: Catalog metadata dict. + """ + meta = base_component_metadata(registry_name, name, cls) + meta["input_interface"] = getattr(cls, "input_interface", "") + return meta diff --git a/drevalpy/registry/predictor/_registration.py b/drevalpy/registry/predictor/_registration.py new file mode 100644 index 000000000..7092cb5ce --- /dev/null +++ b/drevalpy/registry/predictor/_registration.py @@ -0,0 +1,80 @@ +"""Public register / get / list / table / metadata helpers for the predictor registry.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING, Any + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.registry.predictor._registry import predictor_registry +from drevalpy.types.enums.literature_reference import LiteratureReference + +if TYPE_CHECKING: + import pandas as pd + + +def register( + name: str, + *, + description: str, + cell_line_contract: FeatureContract | FeatureFormat | None = None, + drug_contract: FeatureContract | FeatureFormat | None = None, + tags: Iterable[str] | None = None, + reference: LiteratureReference | None = None, +) -> Callable[[type[Any]], type[Any]]: + """Decorator: register a predictor. + + :param name: Registry name used in ``ModelConfig`` and recipes. + :param description: Short human-readable summary for catalog listings. + :param cell_line_contract: Expected cell-line feature format. Falls back to the + ``cell_line_contract`` declared on the class body when omitted. + :param drug_contract: Expected drug feature format. Falls back to the + ``drug_contract`` declared on the class body when omitted. + :param tags: Optional discovery tags. + :param reference: Optional literature citation metadata. + + :returns: Class decorator that registers the decorated predictor under *name*. + """ + return predictor_registry.register( + name, + description=description, + tags=tags, + reference=reference, + cell_line_contract=cell_line_contract, + drug_contract=drug_contract, + ) + + +def get(name: str) -> type[Any]: + """Return the predictor class registered under *name*. + + :param name: Registry name of the predictor. + :returns: Predictor class registered under *name*. + :raises ValueError: If *name* is not registered. + """ + return predictor_registry.get(name) + + +def list() -> list[str]: # noqa: A001 + """Return sorted list of registered predictor names. + + :returns: All currently registered predictor names. + """ + return predictor_registry.list_names() + + +def table() -> pd.DataFrame: + """Return registry contents as a DataFrame. + + :returns: DataFrame with Name, Description, and Tags columns. + """ + return predictor_registry.to_dataframe() + + +def metadata(name: str) -> dict[str, Any]: + """Return metadata for a registered predictor. + + :param name: Registry name of the predictor. + :returns: Metadata dict including input interface, tags, and literature fields. + """ + return predictor_registry.get_metadata(name) diff --git a/drevalpy/registry/predictor/_registry.py b/drevalpy/registry/predictor/_registry.py new file mode 100644 index 000000000..a8e43249a --- /dev/null +++ b/drevalpy/registry/predictor/_registry.py @@ -0,0 +1,89 @@ +"""Predictor registry class and module singleton.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any, ClassVar + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat, normalize_feature_contract +from drevalpy.registry.components._base import ComponentRegistry +from drevalpy.registry.components._contract_assignment import assign_contract +from drevalpy.registry.components._registration_metadata import ( + apply_registration_metadata, + normalize_registration_metadata, +) +from drevalpy.registry.predictor._metadata import predictor_component_metadata +from drevalpy.registry.predictor._validate import validate_predictor_registration +from drevalpy.types.enums.literature_reference import LiteratureReference + + +class PredictorRegistry(ComponentRegistry): + """Registry for predictors that declare cell-line and drug input contracts.""" + + _required_fields: ClassVar[tuple[str, ...]] = ( + "description", + "cell_line_contract", + "drug_contract", + ) + + def __init__(self) -> None: + """Initialize the predictor registry with its fixed identity.""" + super().__init__("predictor", "Predictor", "predictors") + + def register( + self, + name: str, + *, + description: str, + cell_line_contract: FeatureContract | FeatureFormat | None = None, + drug_contract: FeatureContract | FeatureFormat | None = None, + tags: Iterable[str] | None = None, + reference: LiteratureReference | None = None, + ) -> Callable[[type[Any]], type[Any]]: + """Return a class decorator that registers a predictor under *name*. + + :param name: Registry name used in model configs and discovery listings. + :param description: Short human-readable summary. + :param cell_line_contract: Expected cell-line feature format. When omitted, + the ``cell_line_contract`` declared on the class body is used. + :param drug_contract: Expected drug feature format. When omitted, the + ``drug_contract`` declared on the class body is used. + :param tags: Optional discovery tags. + :param reference: Optional literature citation metadata. + :returns: Class decorator that registers and returns the decorated class. + """ + metadata = normalize_registration_metadata(description, tags, reference) + normalized_cell_line_contract = ( + None if cell_line_contract is None else normalize_feature_contract(cell_line_contract) + ) + normalized_drug_contract = None if drug_contract is None else normalize_feature_contract(drug_contract) + + def decorator(cls: type[Any]) -> type[Any]: + with self._lock: + if name in self._store: + msg = f"{self._label} {name!r} already registered" + raise ValueError(msg) + assign_contract(cls, "cell_line_contract", normalized_cell_line_contract) + assign_contract(cls, "drug_contract", normalized_drug_contract) + apply_registration_metadata(cls, metadata) + self._validate_registration(name, cls) + self._store[name] = cls + cls.registry_name = name + return cls + + return decorator + + def _validate_registration(self, name: str, cls: type[Any]) -> None: + """Enforce predictor leaf-interface and capability invariants. + + :param name: Registry name under which *cls* is being registered. + :param cls: Predictor class with contracts already attached. + """ + super()._validate_registration(name, cls) + validate_predictor_registration(name, cls) + + def _component_metadata(self, name: str, cls: type[Any]) -> dict[str, Any]: + return predictor_component_metadata(self._display_name, name, cls) + + +predictor_registry = PredictorRegistry() diff --git a/drevalpy/registry/predictor/_validate.py b/drevalpy/registry/predictor/_validate.py new file mode 100644 index 000000000..da4e6d248 --- /dev/null +++ b/drevalpy/registry/predictor/_validate.py @@ -0,0 +1,47 @@ +"""Class-level invariants for registered predictors.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.contracts.hyperparameter_space import validate_component_hyperparameter_space +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor + + +def _leaf_interface_match(name: str, cls: type[Any], leaf_bases: tuple[type[Any], ...]) -> type[Any]: + matches = [base for base in leaf_bases if issubclass(cls, base)] + if len(matches) != 1: + msg = ( + f"Predictor {name!r} must inherit exactly one of " + f"FeatureFreePredictor, MatrixPredictor, BlockPredictor; " + f"matched={[base.__name__ for base in matches]}" + ) + raise ValueError(msg) + return matches[0] + + +def _validate_matrix_contracts(name: str, cls: type[Any]) -> None: + cell_line = getattr(cls, "cell_line_contract", None) + drug = getattr(cls, "drug_contract", None) + for side, contract in (("cell_line", cell_line), ("drug", drug)): + if contract is None or contract.format != FeatureFormat.NUMERIC_MATRIX: + actual = getattr(contract, "format", None) + actual_value = actual.value if actual is not None else "" + msg = f"Matrix predictor {name!r} requires numeric_matrix {side} contract, got {actual_value!r}" + raise ValueError(msg) + + +def validate_predictor_registration(name: str, cls: type[Any]) -> None: + """Raise ``ValueError`` if a predictor class violates registration invariants. + + :param name: Registry name under which *cls* is being registered. + :param cls: Predictor class with contracts already attached by the decorator. + """ + leaf_bases = (FeatureFreePredictor, MatrixPredictor, BlockPredictor) + leaf_base = _leaf_interface_match(name, cls, leaf_bases) + if leaf_base is MatrixPredictor: + _validate_matrix_contracts(name, cls) + validate_component_hyperparameter_space(name, cls) diff --git a/drevalpy/registry/splitter/__init__.py b/drevalpy/registry/splitter/__init__.py new file mode 100644 index 000000000..e699ebc40 --- /dev/null +++ b/drevalpy/registry/splitter/__init__.py @@ -0,0 +1,63 @@ +"""Splitter registry: register, discover, and retrieve splitter functions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ._registry import Splitter, SplitterRegistry, splitter_registry +from ._validation import SplitValidationError, Validation + +if TYPE_CHECKING: + import pandas as pd + +__all__ = [ + "SplitValidationError", + "Splitter", + "SplitterRegistry", + "Validation", + "get", + "list", + "metadata", + "register", + "splitter_registry", + "table", +] + + +def register(mode: str, description: str, validation: Validation, *, override: bool = False): + """Decorator to register a splitter function under a mode name. + + :param mode: Mode name (e.g. ``"LPO"``, or a custom name). + :param description: Human-readable description of the splitting approach. + :param validation: Which leakage constraint to enforce. + :param override: Replace an already-registered mode instead of raising. + :returns: Decorator that registers and returns the wrapped function. + """ + return splitter_registry.register(mode, description, validation, override=override) + + +def get(mode: str) -> Splitter: + """Return the validated splitter for the given mode.""" + return splitter_registry.get(mode) + + +def list() -> list[str]: # noqa: A001 + """Return sorted list of registered mode names.""" + return splitter_registry.modes + + +def table() -> pd.DataFrame: + """Return registry contents as a DataFrame. + + :returns: DataFrame with Mode, Description and Validation columns. + """ + return splitter_registry.to_dataframe() + + +def metadata(mode: str) -> dict[str, Any]: + """Return metadata for a registered splitter mode. + + :param mode: Registry name of the splitter mode. + :returns: Metadata dict including description and leakage constraint. + """ + return splitter_registry.get_metadata(mode) diff --git a/drevalpy/registry/splitter/_registry.py b/drevalpy/registry/splitter/_registry.py new file mode 100644 index 000000000..a11fdc905 --- /dev/null +++ b/drevalpy/registry/splitter/_registry.py @@ -0,0 +1,209 @@ +"""Splitter registry: maps mode names to validated splitter functions. + +A splitter is any callable with the signature:: + + (mudataset: MuDataLike, n_splits: int, validation_ratio: float, random_state: int) -> list[SplitMasks] + +Register custom splitters with the ``@splitter_registry.register`` decorator. +Validation runs automatically after each split -- no way to bypass it. +""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import wraps +from typing import TYPE_CHECKING, Any, Protocol + +from drevalpy.types import MuDataLike, SplitMasks + +from ._validation import Validation, validate_folds + +if TYPE_CHECKING: + import pandas as pd + + +class Splitter(Protocol): + """Protocol defining the splitter callable signature.""" + + def __call__( + self, + mudataset: MuDataLike, + n_splits: int = 5, + validation_ratio: float = 0.1, + random_state: int = 42, + ) -> list[SplitMasks]: + """Execute the splitter.""" + ... + + +def _wrap_with_validation(fn: Splitter, mode: str, validation: Validation) -> Splitter: + """Wrap a splitter so validation runs and default metadata is injected.""" + + @wraps(fn) + def wrapper( + mudataset: MuDataLike, + n_splits: int = 5, + validation_ratio: float = 0.1, + random_state: int = 42, + ) -> list[SplitMasks]: + folds = fn(mudataset, n_splits, validation_ratio, random_state) + validate_folds(folds, validation, mudataset) + for i, fold in enumerate(folds): + fold.metadata.setdefault("mode", mode) + fold.metadata.setdefault("fold_index", i) + fold.metadata.setdefault("n_splits", n_splits) + fold.metadata.setdefault("validation_ratio", validation_ratio) + fold.metadata.setdefault("random_state", random_state) + return folds + + return wrapper # type: ignore[return-value] + + +class SplitterRegistry: + """Registry mapping mode names to validated splitter callables.""" + + def __init__(self) -> None: + """Initialize with an empty registry.""" + self._splitters: dict[str, Splitter] = {} + self._descriptions: dict[str, str] = {} + self._validations: dict[str, Validation] = {} + + @property + def modes(self) -> list[str]: + """Sorted list of registered mode names.""" + return sorted(self._splitters) + + def register( + self, + mode: str, + description: str, + validation: Validation, + *, + override: bool = False, + ) -> Callable[[Splitter], Splitter]: + """Decorator to register a splitter function under a mode name. + + The first three parameters are required. The splitter is automatically + wrapped so that validation runs after every call. + + Registering a mode name that is already taken raises, matching the + visualization registry: a silent overwrite means one package quietly + changes another package's split semantics. Pass ``override=True`` when + replacing a mode is the intent. + + :param mode: Mode name (e.g. "LPO", "LCO", or a custom name). + :param description: Human-readable description of the splitting approach. + :param validation: Which leakage constraint to enforce ("LCO", "LDO", "LPO", "LTO"). + :param override: Replace an already-registered mode instead of raising. + :returns: Decorator that registers and returns the wrapped function. + :raises ValueError: If *mode* is already registered and *override* is false. + + Example:: + + @splitter_registry.register("MY_LCO", "Custom LCO with fraction", validation="LCO") + def my_lco(mudataset, n_splits=5, validation_ratio=0.1, random_state=42): ... + """ + + def decorator(fn: Splitter) -> Splitter: + if mode in self._splitters and not override: + msg = f"Splitter mode {mode!r} already registered" + raise ValueError(msg) + wrapped = _wrap_with_validation(fn, mode, validation) + self._splitters[mode] = wrapped + self._descriptions[mode] = description + self._validations[mode] = validation + return wrapped + + return decorator + + def get(self, mode: str) -> Splitter: + """Return the validated splitter for the given mode. + + :param mode: Registered mode name. + :returns: Splitter callable (with validation baked in). + :raises ValueError: If mode is not registered. + """ + splitter = self._splitters.get(mode) + if splitter is None: + raise ValueError(f"Unknown split mode {mode!r}. Registered: {self.modes}") + return splitter + + def resolve(self, splitter: str | Splitter) -> Splitter: + """Resolve a splitter from a mode string or pass through a callable. + + :param splitter: Either a mode name (str) or a Splitter callable. + :returns: Splitter callable. + """ + if isinstance(splitter, str): + return self.get(splitter) + return splitter + + def describe(self, mode: str) -> str: + """Return the description for a registered mode. + + :param mode: Registered mode name. + :returns: Description string. + """ + return self._descriptions.get(mode, "") + + def get_metadata(self, mode: str) -> dict[str, Any]: + """Return the metadata record for the splitter registered under *mode*. + + Mirrors ``get_metadata`` on the component registries so every registry can + be introspected the same way. + + :param mode: Registered mode name. + :returns: Metadata dict for catalog listings. + :raises ValueError: If *mode* is not registered. + """ + self.get(mode) + return { + "registry": "splitters", + "name": mode, + "description": self._descriptions.get(mode, ""), + "validation": self._validations.get(mode, ""), + } + + def list_metadata(self) -> list[dict[str, Any]]: + """Return metadata for every registered mode. + + :returns: List of metadata dicts, ordered by mode name. + """ + return [self.get_metadata(mode) for mode in self.modes] + + def to_dataframe(self) -> pd.DataFrame: + """Return registry contents as a pandas DataFrame.""" + import pandas as pd + + rows = [] + for mode in self.modes: + rows.append( + { + "Mode": mode, + "Description": self._descriptions.get(mode, ""), + "Validation": self._validations.get(mode, ""), + } + ) + return pd.DataFrame(rows) + + def retain_only(self, modes: frozenset[str]) -> None: + """Remove all entries not in the given set (for rollback support). + + :param modes: Set of mode names to keep. + """ + for mode in list(self._splitters): + if mode not in modes: + del self._splitters[mode] + self._descriptions.pop(mode, None) + self._validations.pop(mode, None) + + def __repr__(self) -> str: + """Return a tabular string representation.""" + return self.to_dataframe().to_string(index=False) + + def _repr_html_(self) -> str: + """HTML table for Jupyter notebooks.""" + return self.to_dataframe().to_html(index=False) + + +splitter_registry = SplitterRegistry() diff --git a/drevalpy/registry/splitter/_validation.py b/drevalpy/registry/splitter/_validation.py new file mode 100644 index 000000000..509bbbe03 --- /dev/null +++ b/drevalpy/registry/splitter/_validation.py @@ -0,0 +1,91 @@ +"""Split validation: ensures folds satisfy their declared leakage constraints.""" + +from __future__ import annotations + +from typing import Literal + +import numpy as np + +from drevalpy.types import MuDataLike, SplitMasks + +Validation = Literal["LCO", "LDO", "LPO", "LTO"] + + +class SplitValidationError(ValueError): + """Raised when a split violates its declared validation constraints.""" + + +def validate_folds( + folds: list[SplitMasks], + validation: Validation, + mudataset: MuDataLike, +) -> None: + """Validate all folds against the declared validation constraints. + + :param folds: List of SplitMasks produced by a splitter. + :param validation: Which leakage constraint to check. + :param mudataset: The dataset used for splitting (needed for tissue resolution). + :raises SplitValidationError: If any fold violates the constraint. + """ + validator = _VALIDATORS[validation] + for i, fold in enumerate(folds): + validator(fold, mudataset, fold_index=i) + + +def _validate_lco(fold: SplitMasks, mudataset: MuDataLike, *, fold_index: int) -> None: + """LCO: no cell line row has True in both train and test.""" + train_rows = np.where(fold.train.mask.any(axis=1))[0] + test_rows = np.where(fold.test.mask.any(axis=1))[0] + overlap = np.intersect1d(train_rows, test_rows) + if len(overlap) > 0: + raise SplitValidationError( + f"LCO validation failed (fold {fold_index}): " + f"{len(overlap)} cell line indices appear in both train and test." + ) + + +def _validate_ldo(fold: SplitMasks, mudataset: MuDataLike, *, fold_index: int) -> None: + """LDO: no drug column has True in both train and test.""" + train_cols = np.where(fold.train.mask.any(axis=0))[0] + test_cols = np.where(fold.test.mask.any(axis=0))[0] + overlap = np.intersect1d(train_cols, test_cols) + if len(overlap) > 0: + raise SplitValidationError( + f"LDO validation failed (fold {fold_index}): {len(overlap)} drug indices appear in both train and test." + ) + + +def _validate_lto(fold: SplitMasks, mudataset: MuDataLike, *, fold_index: int) -> None: + """LTO: no tissue appears in both train and test cell lines.""" + cl_ids = mudataset.cell_line_ids + tissues = mudataset.get_tissue(cl_ids) + + train_rows = np.where(fold.train.mask.any(axis=1))[0] + test_rows = np.where(fold.test.mask.any(axis=1))[0] + + train_tissues = set(tissues[train_rows].tolist()) + test_tissues = set(tissues[test_rows].tolist()) + overlap = train_tissues & test_tissues + if overlap: + raise SplitValidationError( + f"LTO validation failed (fold {fold_index}): " + f"{len(overlap)} tissues appear in both train and test: {sorted(overlap)[:5]}" + ) + + +def _validate_lpo(fold: SplitMasks, mudataset: MuDataLike, *, fold_index: int) -> None: + """LPO: no position is True in both train and test.""" + overlap_count = len(fold.train & fold.test) + if overlap_count > 0: + raise SplitValidationError( + f"LPO validation failed (fold {fold_index}): " + f"{overlap_count} (cell_line, drug) pairs appear in both train and test." + ) + + +_VALIDATORS = { + "LCO": _validate_lco, + "LDO": _validate_ldo, + "LTO": _validate_lto, + "LPO": _validate_lpo, +} diff --git a/drevalpy/registry/visualization/__init__.py b/drevalpy/registry/visualization/__init__.py new file mode 100644 index 000000000..fd5adc52f --- /dev/null +++ b/drevalpy/registry/visualization/__init__.py @@ -0,0 +1,82 @@ +"""Visualization registry: register, discover, and retrieve visualization classes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ._registry import VisualizationRegistry, visualization_registry + +if TYPE_CHECKING: + import pandas as pd + + from drevalpy.types.results import ExperimentResult + from drevalpy.visualization.base import Visualization + +__all__ = [ + "VisualizationRegistry", + "applicable", + "get", + "list", + "metadata", + "register", + "table", + "visualization_registry", +] + + +def register( + name: str, + description: str = "", + *, + result_type: str = "ExperimentResult", + requirements=frozenset(), + override: bool = False, +): + """Decorator to register a visualization class. + + :param name: Unique name for this visualization. + :param description: Human-readable description. + :param result_type: ``"ExperimentResult"`` or ``"ModelResult"``. + :param requirements: frozenset of ``PlotRequirement`` values. + :param override: Replace an already-registered name instead of raising. + :returns: Class decorator. + """ + return visualization_registry.register( + name, + description, + result_type=result_type, + requirements=requirements, + override=override, + ) + + +def get(name: str) -> type[Visualization]: + """Return the visualization class registered under name.""" + return visualization_registry.get(name) + + +def list() -> list[str]: # noqa: A001 + """Return sorted list of registered visualization names.""" + return visualization_registry.names + + +def table() -> pd.DataFrame: + """Return registry contents as a DataFrame. + + :returns: DataFrame with Name, Description, Result type and Requirements columns. + """ + return visualization_registry.to_dataframe() + + +def metadata(name: str) -> dict[str, Any]: + """Return metadata for a registered visualization. + + :param name: Registry name of the visualization. + :returns: Metadata dict including result type and plot requirements. + """ + return visualization_registry.get_metadata(name) + + +def applicable(experiment: ExperimentResult) -> list[type[Visualization]]: + """Return all visualization classes whose requirements are satisfied.""" + return visualization_registry.applicable(experiment) diff --git a/drevalpy/registry/visualization/_registry.py b/drevalpy/registry/visualization/_registry.py new file mode 100644 index 000000000..93867f13b --- /dev/null +++ b/drevalpy/registry/visualization/_registry.py @@ -0,0 +1,163 @@ +"""Visualization registry: maps names to visualization classes. + +Register custom visualizations with the ``@visualization_registry.register`` decorator. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import pandas as pd + + from drevalpy.types.results import ExperimentResult + + +class VisualizationRegistry: + """Registry mapping names to Visualization classes with requirement metadata.""" + + def __init__(self) -> None: + """Initialize with an empty registry.""" + self._store: dict[str, type[Any]] = {} + self._descriptions: dict[str, str] = {} + self._requirements: dict[str, frozenset[Any]] = {} + self._result_types: dict[str, str] = {} + + @property + def names(self) -> list[str]: + """Sorted list of registered visualization names.""" + return sorted(self._store) + + def list_names(self) -> list[str]: + """Alias for names property, for API consistency with other registries.""" + return self.names + + def register( + self, + name: str, + description: str = "", + *, + result_type: str = "ExperimentResult", + requirements: frozenset[Any] = frozenset(), + override: bool = False, + ) -> Callable[[type[Any]], type[Any]]: + """Decorator to register a visualization class. + + :param name: Unique name for this visualization. + :param description: Human-readable description. + :param result_type: "ExperimentResult" or "ModelResult". + :param requirements: frozenset of PlotRequirement values. + :param override: Replace an already-registered name instead of raising. + :returns: Class decorator. + :raises ValueError: If *name* is already registered and *override* is false. + """ + + def decorator(cls: type[Any]) -> type[Any]: + if name in self._store and not override: + raise ValueError(f"Visualization {name!r} already registered") + cls.registry_name = name + self._store[name] = cls + self._descriptions[name] = description + self._requirements[name] = requirements + self._result_types[name] = result_type + return cls + + return decorator + + def get(self, name: str) -> type[Any]: + """Return the visualization class registered under name. + + :raises ValueError: If name is not registered. + """ + if name not in self._store: + raise ValueError(f"Unknown visualization {name!r}. Registered: {self.names}") + return self._store[name] + + def applicable(self, experiment: ExperimentResult) -> list[type[Any]]: + """Return all visualization classes whose requirements are satisfied. + + :param experiment: The experiment result to check against. + :returns: List of applicable visualization classes. + """ + result = [] + for name, cls in self._store.items(): + reqs = self._requirements[name] + if experiment.satisfies(reqs): + result.append(cls) + return result + + def describe(self, name: str) -> str: + """Return the description for a registered visualization.""" + return self._descriptions.get(name, "") + + def get_metadata(self, name: str) -> dict[str, Any]: + """Return the metadata record for the visualization registered under *name*. + + Mirrors ``get_metadata`` on the component registries so every registry can + be introspected the same way. + + :param name: Registered visualization name. + :returns: Metadata dict for catalog listings. + :raises ValueError: If *name* is not registered. + """ + cls = self.get(name) + return { + "registry": "visualizations", + "name": name, + "class_name": cls.__name__, + "description": self._descriptions.get(name, ""), + "result_type": self._result_types.get(name, ""), + "requirements": frozenset(self._requirements.get(name, frozenset())), + } + + def list_metadata(self) -> list[dict[str, Any]]: + """Return metadata for every registered visualization. + + :returns: List of metadata dicts, ordered by visualization name. + """ + return [self.get_metadata(name) for name in self.names] + + def to_dataframe(self) -> pd.DataFrame: + """Return registry contents as a pandas DataFrame. + + :returns: One row per visualization, with Name, Description, Result type + and Requirements columns. + """ + import pandas as pd + + rows = [] + for name in self.names: + meta = self.get_metadata(name) + rows.append( + { + "Name": name, + "Description": meta["description"], + "Result type": meta["result_type"], + "Requirements": ", ".join(sorted(str(req) for req in meta["requirements"])), + } + ) + return pd.DataFrame(rows) + + def retain_only(self, names: frozenset[str]) -> None: + """Remove all entries not in the given set (for rollback support). + + :param names: Set of visualization names to keep. + """ + for name in list(self._store): + if name not in names: + del self._store[name] + self._descriptions.pop(name, None) + self._requirements.pop(name, None) + self._result_types.pop(name, None) + + def __repr__(self) -> str: + """Return a tabular string representation.""" + return self.to_dataframe().to_string(index=False) + + def _repr_html_(self) -> str: + """HTML table for Jupyter notebooks.""" + return self.to_dataframe().to_html(index=False) + + +visualization_registry = VisualizationRegistry() diff --git a/drevalpy/testing/__init__.py b/drevalpy/testing/__init__.py new file mode 100644 index 000000000..89acd54eb --- /dev/null +++ b/drevalpy/testing/__init__.py @@ -0,0 +1,52 @@ +"""Test utilities for drevalpy plugins and components. + +Everything a plugin's test suite needs to exercise its own components without a +downloaded dataset: + +* :func:`build_synthetic_dataset` - an in-memory :class:`~drevalpy.plugin.Dataset`. +* :func:`build_synthetic_batch` - a featurized batch a predictor can train on. +* :func:`check_plugin` - assert the plugin's entry point loaded and its + components resolve through the registries. +* The ``check_*`` functions in :mod:`drevalpy.testing.conformance` - assert a + featurizer or predictor instantiates, fits, transforms and round-trips through + ``get_state``/``set_state``. + +This is shipped in the wheel rather than kept in drevalpy's own ``tests/`` tree +precisely so third-party plugins can import it. +""" + +from .batch import build_synthetic_batch, observed_pairs +from .conformance import ( + FEATURIZER_CHECKS, + PREDICTOR_CHECKS, + ConformanceError, + check_featurizer_fit_transform, + check_featurizer_instantiates, + check_featurizer_state_round_trip, + check_predictor_fit_predict, + check_predictor_instantiates, + check_predictor_state_round_trip, + feature_source_for, +) +from .plugins import ENTRY_POINT_GROUP, PluginCheckError, PluginReport, check_plugin +from .synthetic import build_synthetic_dataset + +__all__ = [ + "ConformanceError", + "ENTRY_POINT_GROUP", + "FEATURIZER_CHECKS", + "PREDICTOR_CHECKS", + "PluginCheckError", + "PluginReport", + "build_synthetic_batch", + "build_synthetic_dataset", + "check_featurizer_fit_transform", + "check_featurizer_instantiates", + "check_featurizer_state_round_trip", + "check_plugin", + "check_predictor_fit_predict", + "check_predictor_instantiates", + "check_predictor_state_round_trip", + "feature_source_for", + "observed_pairs", +] diff --git a/drevalpy/testing/batch.py b/drevalpy/testing/batch.py new file mode 100644 index 000000000..b3a7f49d5 --- /dev/null +++ b/drevalpy/testing/batch.py @@ -0,0 +1,124 @@ +"""Synthetic :class:`ModelInputBatch` construction for predictor tests. + +Predictors consume a fully featurized batch, so exercising one normally means +composing a whole model. This module short-circuits that: it draws dense feature +matrices directly and pairs them with the measured entries of a dataset's +response matrix, so a predictor can be trained without any featurizer at all. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np + +from drevalpy.types.data.batch.feature_block import numeric_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.batch.response_batch import ResponseBatch +from drevalpy.types.data.dataset import Dataset + +N_CELL_LINE_FEATURES = 8 +N_DRUG_FEATURES = 4 +SEED = 20260813 + + +def observed_pairs(dataset: Dataset) -> ResponseBatch: + """Return every measured cell-line/drug pair in *dataset*. + + Args: + dataset: Dataset whose response matrix is read. + + Returns: + Response triples for the non-NaN entries, in row-major order. + """ + matrix = dataset.response_matrix + rows, columns = np.nonzero(~np.isnan(matrix)) + return ResponseBatch( + response=matrix[rows, columns].astype(np.float64), + cell_line_ids=np.asarray(dataset.cell_line_ids)[rows], + drug_ids=np.asarray(dataset.drug_ids)[columns], + ) + + +def build_synthetic_batch( + dataset: Dataset, + *, + cell_line_block_names: Sequence[str] = (), + drug_block_names: Sequence[str] = (), + n_cell_line_features: int = N_CELL_LINE_FEATURES, + n_drug_features: int | None = N_DRUG_FEATURES, + seed: int = SEED, +) -> ModelInputBatch: + """Build a featurized batch over every measured pair in *dataset*. + + Features are drawn rather than computed, so the batch says nothing about any + featurizer - which is the point when the predictor is what is under test. The + response is a noisy linear function of the drawn features, so a predictor + that trains at all can beat the mean. + + Args: + dataset: Dataset supplying the entity ids and the measured pairs. + cell_line_block_names: Names to expose the cell-line matrix under, for + predictors that read named blocks rather than a flat matrix. + drug_block_names: Names to expose the drug matrix under. + n_cell_line_features: Width of the drawn cell-line feature matrix. + n_drug_features: Width of the drawn drug feature matrix, or ``None`` for + a cell-line-only batch. + seed: Seed for the drawn features and noise. + + Returns: + A batch ready for :meth:`~drevalpy.plugin.Predictor.fit`. + """ + rng = np.random.default_rng(seed) + response = observed_pairs(dataset) + cell_line_entity_ids = np.asarray(dataset.cell_line_ids) + drug_entity_ids = np.asarray(dataset.drug_ids) + + cell_line_features = rng.normal(size=(len(cell_line_entity_ids), n_cell_line_features)).astype(np.float32) + cell_line_pair_idx = _row_index(cell_line_entity_ids, response.cell_line_ids) + + drug_features = None + drug_pair_idx = None + if n_drug_features is not None: + drug_features = rng.normal(size=(len(drug_entity_ids), n_drug_features)).astype(np.float32) + drug_pair_idx = _row_index(drug_entity_ids, response.drug_ids) + + return ModelInputBatch.from_response( + _learnable_response(response, cell_line_features, cell_line_pair_idx, rng), + cell_line_entity_ids=cell_line_entity_ids, + drug_entity_ids=None if drug_features is None else drug_entity_ids, + cell_line_features=cell_line_features, + drug_features=drug_features, + cell_line_pair_idx=cell_line_pair_idx, + drug_pair_idx=drug_pair_idx, + cell_line_blocks={name: numeric_feature_block(cell_line_features) for name in cell_line_block_names}, + drug_blocks=( + {} if drug_features is None else {name: numeric_feature_block(drug_features) for name in drug_block_names} + ), + ) + + +def _row_index(entity_ids: np.ndarray, pair_ids: np.ndarray) -> np.ndarray: + """Map each pair identifier onto its row in the feature matrix.""" + rows = {str(entity_id): row for row, entity_id in enumerate(entity_ids)} + return np.asarray([rows[str(pair_id)] for pair_id in pair_ids], dtype=np.int64) + + +def _learnable_response( + response: ResponseBatch, + cell_line_features: np.ndarray, + cell_line_pair_idx: np.ndarray, + rng: np.random.Generator, +) -> ResponseBatch: + """Replace the response with a noisy linear function of the drawn features. + + Without this the drawn features carry no signal, and a check asserting that + a predictor learned something could only ever assert that it ran. + """ + weights = rng.normal(size=cell_line_features.shape[1]) + signal = cell_line_features[cell_line_pair_idx] @ weights + return ResponseBatch( + response=(signal + rng.normal(scale=0.1, size=len(response))).astype(np.float64), + cell_line_ids=response.cell_line_ids, + drug_ids=response.drug_ids, + ) diff --git a/drevalpy/testing/conformance.py b/drevalpy/testing/conformance.py new file mode 100644 index 000000000..81e99eaec --- /dev/null +++ b/drevalpy/testing/conformance.py @@ -0,0 +1,320 @@ +"""Reusable conformance checks for third-party featurizers and predictors. + +Registration validates a component's declarations, not its behaviour: a class +missing ``_fit`` registers happily and only fails when instantiated, and a +``get_state``/``set_state`` pair that drops a fitted attribute is invisible until +a checkpoint is reloaded. The checks below close that gap by actually running the +component, and are what a plugin's test suite parametrizes over. + +Each ``check_*`` function raises :class:`ConformanceError` with a message naming +the component and the broken expectation, and otherwise returns ``None``. + +Every check in a family takes the same arguments - ``(cls, fixture, **kwargs)``, +where the fixture is a dataset for featurizers and a batch for predictors and may +be omitted - so a suite can parametrize over :data:`FEATURIZER_CHECKS` or +:data:`PREDICTOR_CHECKS` directly:: + + @pytest.mark.parametrize("check", FEATURIZER_CHECKS) + def test_my_featurizer_conforms(check): + check(MyFeaturizer) +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import numpy as np + +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import ( + CellLineFeatureSource, + DrugFeatureSource, + FeatureSource, +) + +from .batch import build_synthetic_batch +from .synthetic import build_synthetic_dataset + + +class ConformanceError(AssertionError): + """Raised when a component does not honour the featurizer/predictor contract. + + Subclasses ``AssertionError`` so a failure reads as a test failure when + raised from inside a test. + """ + + +def _require(condition: object, message: str) -> None: + """Raise :class:`ConformanceError` with *message* unless *condition* holds.""" + if not condition: + raise ConformanceError(message) + + +def feature_source_for(cls: type[Featurizer], dataset: Dataset) -> FeatureSource: + """Return the feature source matching the featurizer's registered side. + + Args: + cls: Featurizer class. Its ``side`` is set by the registry to + ``"cell_line"`` or ``"drug"``. + dataset: Dataset to wrap. + + Returns: + A source for the featurizer's own entity side. + """ + if getattr(cls, "side", "") == "drug": + return DrugFeatureSource(dataset, np.asarray(dataset.drug_ids)) + return CellLineFeatureSource(dataset, np.asarray(dataset.cell_line_ids)) + + +def _entity_ids(cls: type[Featurizer], dataset: Dataset) -> np.ndarray: + return np.asarray(dataset.drug_ids if getattr(cls, "side", "") == "drug" else dataset.cell_line_ids) + + +def check_featurizer_instantiates( + cls: type[Featurizer], + dataset: Dataset | None = None, + **kwargs: Any, +) -> Featurizer: + """Check that *cls* can be constructed with its declared defaults. + + This is the check registration cannot make: ``ABCMeta`` only enforces + abstract methods at instantiation, so a featurizer missing ``_fit`` or + ``_transform_blocks`` registers cleanly and fails here. + + Args: + cls: Featurizer class to instantiate. + dataset: Unused; accepted so every entry of :data:`FEATURIZER_CHECKS` + takes the same arguments and a suite can parametrize over the tuple. + kwargs: Constructor keyword arguments. Defaults to none, i.e. the + featurizer must be constructible with no arguments. + + Returns: + The constructed instance. + + Raises: + ConformanceError: If construction fails, or the result is not a + :class:`~drevalpy.plugin.Featurizer`. + """ + _ = dataset + try: + instance = cls(**kwargs) + except TypeError as exc: + msg = f"{cls.__name__} could not be constructed with {kwargs!r}: {exc}" + raise ConformanceError(msg) from exc + _require(isinstance(instance, Featurizer), f"{cls.__name__} is not a Featurizer subclass instance") + return instance + + +def check_featurizer_fit_transform( + cls: type[Featurizer], + dataset: Dataset | None = None, + **kwargs: Any, +) -> None: + """Check that *cls* fits, transforms and reports a matching ``output_dim``. + + Args: + cls: Featurizer class to exercise. + dataset: Dataset to featurize. Defaults to + :func:`~drevalpy.testing.build_synthetic_dataset`, which suffices for + an ``entity_id_only`` featurizer; pass a dataset carrying the + required views otherwise. + kwargs: Constructor keyword arguments. + + Raises: + ConformanceError: If fitting or transforming fails, the blocks are not + aligned with the entity ids, or ``output_dim`` disagrees with the + produced width. + """ + dataset = dataset if dataset is not None else build_synthetic_dataset() + instance = check_featurizer_instantiates(cls, **kwargs) + source = feature_source_for(cls, dataset) + entity_ids = _entity_ids(cls, dataset) + + instance.fit(source, entity_ids=entity_ids) + blocks = instance.transform_blocks(source, entity_ids) + _require(blocks, f"{cls.__name__}.transform_blocks returned no blocks") + for name, block in blocks.items(): + if not block.entity_aligned: + continue + _require( + len(block.values) == len(entity_ids), + f"{cls.__name__} block {name!r} has {len(block.values)} rows for {len(entity_ids)} entities", + ) + + matrix = instance.transform(source, entity_ids) + _require( + matrix.shape[0] == len(entity_ids), + f"{cls.__name__}.transform returned {matrix.shape[0]} rows for {len(entity_ids)} entities", + ) + _require( + instance.output_dim == matrix.shape[1], + f"{cls.__name__}.output_dim is {instance.output_dim} but transform produced {matrix.shape[1]} columns", + ) + + +def check_featurizer_state_round_trip( + cls: type[Featurizer], + dataset: Dataset | None = None, + **kwargs: Any, +) -> None: + """Check that a fitted featurizer survives ``get_state``/``set_state``. + + A checkpoint stores the state mapping, not the object, so a fitted attribute + left out of ``get_state`` makes a reloaded model silently produce different + features. This transforms through a fresh instance restored from the state + and requires identical output. + + Args: + cls: Featurizer class to exercise. + dataset: Dataset to featurize; defaults to the synthetic one. + kwargs: Constructor keyword arguments. + + Raises: + ConformanceError: If the restored featurizer produces different features. + """ + dataset = dataset if dataset is not None else build_synthetic_dataset() + source = feature_source_for(cls, dataset) + entity_ids = _entity_ids(cls, dataset) + + fitted = check_featurizer_instantiates(cls, **kwargs) + fitted.fit(source, entity_ids=entity_ids) + expected = fitted.transform(source, entity_ids) + + restored = check_featurizer_instantiates(cls, **kwargs) + restored.set_state(fitted.get_state()) + actual = restored.transform(source, entity_ids) + + _require( + actual.shape == expected.shape, + f"{cls.__name__} restored from get_state produced shape {actual.shape}, expected {expected.shape}", + ) + _require( + np.allclose(actual, expected, equal_nan=True), + f"{cls.__name__} restored from get_state produced different features; " + "check that get_state covers every attribute _transform_blocks reads", + ) + + +def check_predictor_instantiates( + cls: type[Predictor], + batch: ModelInputBatch | None = None, + **kwargs: Any, +) -> Predictor: + """Check that *cls* can be constructed with its declared defaults. + + Args: + cls: Predictor class to instantiate. + batch: Unused; accepted so every entry of :data:`PREDICTOR_CHECKS` takes + the same arguments and a suite can parametrize over the tuple. + kwargs: Constructor keyword arguments, typically ``hyperparameters=``. + + Returns: + The constructed instance. + + Raises: + ConformanceError: If construction fails, or the result is not a + :class:`~drevalpy.plugin.Predictor`. + """ + _ = batch + try: + instance = cls(**kwargs) + except TypeError as exc: + msg = f"{cls.__name__} could not be constructed with {kwargs!r}: {exc}" + raise ConformanceError(msg) from exc + _require(isinstance(instance, Predictor), f"{cls.__name__} is not a Predictor subclass instance") + return instance + + +def check_predictor_fit_predict( + cls: type[Predictor], + batch: ModelInputBatch | None = None, + **kwargs: Any, +) -> None: + """Check that *cls* trains and returns one finite prediction per pair. + + An unfitted :class:`~drevalpy.plugin.MatrixPredictor` returns all-NaN, so the + finiteness requirement is what distinguishes a predictor that trained from + one that merely ran. + + Args: + cls: Predictor class to exercise. + batch: Featurized batch. Defaults to + :func:`~drevalpy.testing.build_synthetic_batch` over the synthetic + dataset. + kwargs: Constructor keyword arguments. + + Raises: + ConformanceError: If fitting or predicting fails, or the predictions are + the wrong length or not finite. + """ + batch = batch if batch is not None else build_synthetic_batch(build_synthetic_dataset()) + instance = check_predictor_instantiates(cls, **kwargs) + + instance.fit(batch) + predictions = np.asarray(instance.predict(batch)) + + _require( + predictions.shape == (batch.n_pairs,), + f"{cls.__name__}.predict returned shape {predictions.shape}, expected ({batch.n_pairs},)", + ) + _require( + np.isfinite(predictions).all(), + f"{cls.__name__}.predict returned non-finite values; an unfitted predictor returns all-NaN, " + "so check that _fit stores the trained model", + ) + + +def check_predictor_state_round_trip( + cls: type[Predictor], + batch: ModelInputBatch | None = None, + **kwargs: Any, +) -> None: + """Check that a trained predictor survives ``get_state``/``set_state``. + + Args: + cls: Predictor class to exercise. + batch: Featurized batch; defaults to the synthetic one. + kwargs: Constructor keyword arguments. + + Raises: + ConformanceError: If the restored predictor predicts differently. + """ + batch = batch if batch is not None else build_synthetic_batch(build_synthetic_dataset()) + + trained = check_predictor_instantiates(cls, **kwargs) + trained.fit(batch) + expected = np.asarray(trained.predict(batch)) + _require( + np.isfinite(expected).all(), + f"{cls.__name__} produced non-finite predictions before the round trip, so it cannot be compared", + ) + + restored = check_predictor_instantiates(cls, **kwargs) + restored.set_state(trained.get_state()) + actual = np.asarray(restored.predict(batch)) + + _require( + np.allclose(actual, expected, equal_nan=False), + f"{cls.__name__} restored from get_state predicted differently; " + "check that get_state covers every attribute _predict reads", + ) + + +#: Every featurizer check, so a plugin's suite can parametrize over the set +#: rather than list them and drift when a check is added. +FEATURIZER_CHECKS: tuple[Callable[..., Any], ...] = ( + check_featurizer_instantiates, + check_featurizer_fit_transform, + check_featurizer_state_round_trip, +) + +#: Every predictor check; see :data:`FEATURIZER_CHECKS`. +PREDICTOR_CHECKS: tuple[Callable[..., Any], ...] = ( + check_predictor_instantiates, + check_predictor_fit_predict, + check_predictor_state_round_trip, +) diff --git a/drevalpy/testing/plugins.py b/drevalpy/testing/plugins.py new file mode 100644 index 000000000..855e8a1d6 --- /dev/null +++ b/drevalpy/testing/plugins.py @@ -0,0 +1,169 @@ +"""Conformance check that an installed plugin actually reached the registries. + +A plugin is wired up through three separate mechanisms - a ``drevalpy.plugins`` +entry point, a module import, and one registration decorator per component - and +a mistake in any of them makes the components silently absent. :func:`check_plugin` +walks all three in order and reports the first that breaks, so a plugin's CI can +assert "my components are installed and reachable" in one call. +""" + +from __future__ import annotations + +import importlib.metadata +from collections.abc import Mapping +from dataclasses import dataclass +from types import ModuleType + +from drevalpy.registry import ( + cell_line_featurizer, + drug_featurizer, + get_failed_plugins, + predictor, + splitter, + visualization, +) + +#: Entry-point group third-party plugins declare themselves under. +ENTRY_POINT_GROUP = "drevalpy.plugins" + +#: The registries a plugin can contribute to, keyed by the name used in reports. +_REGISTRIES: Mapping[str, ModuleType] = { + "cell_line_featurizer": cell_line_featurizer, + "drug_featurizer": drug_featurizer, + "predictor": predictor, + "splitter": splitter, + "visualization": visualization, +} + + +class PluginCheckError(AssertionError): + """Raised when an installed plugin is not reachable through the registries. + + Subclasses ``AssertionError`` so a failure reads as a test failure rather + than an error when raised from inside a test. + """ + + +@dataclass(frozen=True) +class PluginReport: + """What one plugin contributed, as observed through the public registries.""" + + name: str + value: str + module: str + components: Mapping[str, tuple[str, ...]] + + @property + def component_names(self) -> tuple[str, ...]: + """Every registered name the plugin contributed, across all registries.""" + return tuple(name for names in self.components.values() for name in names) + + +def _declared_entry_point(name: str) -> importlib.metadata.EntryPoint: + """Return the ``drevalpy.plugins`` entry point called *name*. + + Args: + name: Entry-point name, which is the distribution's own choice and is + usually its import package name. + + Returns: + The declared entry point. + + Raises: + PluginCheckError: If no installed distribution declares it. + """ + declared = {ep.name: ep for ep in importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)} + entry_point = declared.get(name) + if entry_point is None: + msg = ( + f"No {ENTRY_POINT_GROUP} entry point named {name!r}. " + f"Declared: {sorted(declared) or 'none'}. " + "Check the [project.entry-points] table and reinstall the plugin." + ) + raise PluginCheckError(msg) + return entry_point + + +def _recorded_failure(name: str) -> str | None: + """Return the traceback the plugin loader recorded for *name*, if any. + + Args: + name: Entry-point name. + + Returns: + The formatted traceback, or ``None`` when the plugin did not fail. + """ + return get_failed_plugins().get(name) + + +def _load(entry_point: importlib.metadata.EntryPoint) -> str: + """Import *entry_point* and return the module it resolves to. + + Args: + entry_point: Declared plugin entry point. + + Returns: + Dotted module name the entry point targets. + + Raises: + PluginCheckError: If discovery recorded a failure, or the import raises. + """ + recorded = _recorded_failure(entry_point.name) + if recorded is not None: + msg = f"Plugin {entry_point.name!r} failed to load during discovery:\n{recorded}" + raise PluginCheckError(msg) + try: + entry_point.load() + except Exception as exc: + msg = f"Plugin {entry_point.name!r} declares {entry_point.value!r}, which failed to import: {exc!r}" + raise PluginCheckError(msg) from exc + return entry_point.module + + +def _contributed(root_package: str) -> dict[str, tuple[str, ...]]: + """Return every registered name whose implementation lives in *root_package*. + + Resolution goes through each registry's public ``get``, so a name that is + listed but cannot be retrieved surfaces here rather than at model build time. + + Args: + root_package: Top-level import package of the plugin. + + Returns: + Mapping of registry name to the sorted names it holds for the plugin. + """ + contributed: dict[str, tuple[str, ...]] = {} + for registry_name, module in _REGISTRIES.items(): + owned = [ + name for name in module.list() if getattr(module.get(name), "__module__", "").split(".")[0] == root_package + ] + if owned: + contributed[registry_name] = tuple(sorted(owned)) + return contributed + + +def check_plugin(name: str) -> PluginReport: + """Assert that the plugin called *name* is installed, loaded and reachable. + + Args: + name: Entry-point name under the ``drevalpy.plugins`` group. + + Returns: + A report naming every component the plugin contributed. + + Raises: + PluginCheckError: If the entry point is undeclared, failed to import, or + registered no component at all. + """ + entry_point = _declared_entry_point(name) + module = _load(entry_point) + root_package = module.split(".")[0] + components = _contributed(root_package) + if not components: + msg = ( + f"Plugin {name!r} imported cleanly but registered nothing under {root_package!r}. " + f"Registries checked: {sorted(_REGISTRIES)}. " + "Check that the entry-point module imports the modules holding the @register decorators." + ) + raise PluginCheckError(msg) + return PluginReport(name=name, value=entry_point.value, module=module, components=components) diff --git a/drevalpy/testing/synthetic.py b/drevalpy/testing/synthetic.py new file mode 100644 index 000000000..851f9bf80 --- /dev/null +++ b/drevalpy/testing/synthetic.py @@ -0,0 +1,280 @@ +"""In-memory synthetic dataset for plugin and component tests. + +Every dataset registered with drevalpy is fetched from a credentialed S3 bucket, +so an offline test run - and any third-party plugin's CI - cannot use one. This +builder produces a :class:`~drevalpy.types.data.dataset.Dataset` with the same +structural slots a published ``.h5mu`` has, entirely in memory. + +The default output carries only the ``response`` modality, which is all an +``entity_id_only`` featurizer needs. Pass *omics* to add cell-line feature +modalities for featurizers that read a view. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Final + +import anndata as ad +import mudata as md +import numpy as np +import pandas as pd + +from drevalpy.data.utils import CELL_LINE_IDENTIFIER, TISSUE_IDENTIFIER +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.modalities import resolve_omics_accessor + +#: Smallest shape that keeps the shipped splitters and predictors happy: ``LTO`` +#: runs ``KFold(n_splits=2)`` over the unique tissues and then carves a +#: validation tissue out of the training half, so three tissues is the floor. +N_CELL_LINES: Final = 24 +N_DRUGS: Final = 8 +N_TISSUES: Final = 6 +N_FEATURES: Final = 16 + +#: A curve-metric layer name the curation pipeline really emits. ``response.X`` +#: holds pEC50, which curation does not duplicate as a layer, so the fixture +#: derives this one from ``X`` the way a real fit would - see +#: :func:`drevalpy.curation.curate`. +MEASURE: Final = "LN_IC50" + +DATASET_NAME: Final = "SYNTHETIC" +SEED: Final = 20260813 + +_TISSUES: Final = ("Lung", "Blood", "Skin", "Colon", "Brain", "Breast") + + +def _punch_holes(matrix: np.ndarray, rng: np.random.Generator, fraction: float) -> None: + """Blank a fraction of *matrix* in place, leaving every row and column measured. + + Unmeasured pairs are what exercise a component's NaN-filtering path. The + row/column guarantee matters because a fully unmeasured cell line or drug + makes the leave-one-out splitters produce empty folds. + + Args: + matrix: Response matrix, modified in place. + rng: Source of randomness. + fraction: Share of entries to blank, clamped by the guarantee above. + """ + n_rows, n_columns = matrix.shape + if fraction <= 0 or n_rows < 2 or n_columns < 2: + return + observed_per_row = np.full(n_rows, n_columns) + observed_per_column = np.full(n_columns, n_rows) + candidates = rng.permutation(n_rows * n_columns) + remaining = int(round(fraction * n_rows * n_columns)) + for flat in candidates: + if remaining <= 0: + return + row, column = divmod(int(flat), n_columns) + if observed_per_row[row] <= 1 or observed_per_column[column] <= 1: + continue + matrix[row, column] = np.nan + observed_per_row[row] -= 1 + observed_per_column[column] -= 1 + remaining -= 1 + + +def _response_anndata( + rng: np.random.Generator, + *, + n_cell_lines: int, + n_drugs: int, + n_tissues: int, + missing_fraction: float, + low_quality_fraction: float, +) -> ad.AnnData: + """Build the ``response`` modality: the pair matrix plus entity metadata.""" + cell_line_ids = [f"CVCL_S{index:03d}" for index in range(n_cell_lines)] + drug_ids = [f"{100000 + index}" for index in range(n_drugs)] + tissues = [_TISSUES[index % min(n_tissues, len(_TISSUES))] for index in range(n_cell_lines)] + + matrix = rng.normal(2.0, 1.0, size=(n_cell_lines, n_drugs)).astype(np.float32) + _punch_holes(matrix, rng, missing_fraction) + + response = ad.AnnData( + X=matrix, + obs=pd.DataFrame( + { + CELL_LINE_IDENTIFIER: [f"SYNTH-{index:03d}" for index in range(n_cell_lines)], + TISSUE_IDENTIFIER: tissues, + }, + index=pd.Index(cell_line_ids, name="cellosaurus_id"), + ), + var=pd.DataFrame( + {"drug_name": [f"synthdrug{index}" for index in range(n_drugs)]}, + index=pd.Index(drug_ids, name="pubchem_id"), + ), + ) + response.layers[MEASURE] = _ln_ic50_from_pec50(matrix) + for name, layer in _quality_layers(rng, matrix, low_quality_fraction=low_quality_fraction).items(): + response.layers[name] = layer + return response + + +def _ln_ic50_from_pec50(pec50: np.ndarray) -> np.ndarray: + """Derive an ``LN_IC50`` layer from a pEC50 matrix. + + Uses the ideal-curve identity ``EC50 [uM] = 10 ** (6 - pEC50)``, i.e. the + ``IC50 == EC50`` case of the closed-form solution + :func:`drevalpy.curation._postprocess._compute_ic50` inverts. That keeps the + layer monotone in ``X`` and on a plausible scale without pretending the + fixture has curve plateaus it never fitted. + + :param pec50: pEC50 matrix, possibly containing NaN. + :returns: Matching ``LN_IC50`` matrix, NaN preserved. + """ + return ((6.0 - pec50) * np.log(10.0)).astype(np.float32) + + +def _omics_coverage(omics: Sequence[str] | Mapping[str, int] | None, n_cell_lines: int) -> dict[str, int]: + """Normalize the *omics* argument into a public-name to coverage map.""" + if omics is None: + return {} + if isinstance(omics, Mapping): + return {name: int(count) for name, count in omics.items()} + return dict.fromkeys(omics, n_cell_lines) + + +def _quality_layers( + rng: np.random.Generator, + matrix: np.ndarray, + *, + low_quality_fraction: float, +) -> dict[str, np.ndarray]: + """Build the CurveCurator quality layers the splitters filter on. + + Every metric is derived from a single per-pair "is this curve good" draw, so + the layers agree with each other: a pair that fails ``relevance_score`` does + not also claim ``R2 = 0.99``. Filtering on any one option therefore selects + the same pairs, which is what makes a non-default option testable. + + Args: + rng: Source of randomness. + matrix: Response matrix, used only for its shape. + low_quality_fraction: Share of pairs to mark as failing the default + thresholds. The failing pairs are chosen with the same per-row and + per-column guarantee ``_punch_holes`` uses, because a quality filter + that empties a cell line breaks the leave-one-out splitters exactly + as an unmeasured one does. + + Returns: + Layer name to matrix, ready to assign into ``response.layers``. + """ + good = np.ones(matrix.shape, dtype=bool) + if low_quality_fraction > 0: + holes = np.zeros(matrix.shape, dtype=np.float32) + _punch_holes(holes, rng, low_quality_fraction) + good = ~np.isnan(holes) + + def _pick(good_value: float, bad_value: float) -> np.ndarray: + return np.where(good, good_value, bad_value).astype(np.float32) + + # Values sit far from the thresholds on both sides, so a boundary change in + # curve_quality_mask cannot silently reclassify a synthetic pair. + return { + "relevance_score": _pick(9.0, 0.01), + "fold_change": _pick(-2.0, -0.01), + "p_value": _pick(1e-9, 0.9), + "log_p_value": _pick(9.0, 0.05), + "f_value": _pick(400.0, 0.5), + "f_value_sam": _pick(80.0, 0.001), + "R2": _pick(0.99, 0.02), + "RMSE": _pick(0.02, 0.8), + "signal_quality": _pick(1.0, 0.0), + "slope": _pick(3.0, 0.05), + "front": _pick(1.0, 0.1), + "back": _pick(0.05, 5.0), + "regulation": _pick(-1.0, 0.0), + # Not filter options, but layers every curated dataset carries: the + # per-parameter standard errors CurveCurator derives from the fit's + # Jacobian. A bad curve gets a wide error, matching the draw above. + "pec50_error": _pick(0.05, 20.0), + "slope_error": _pick(0.1, 50.0), + "front_error": _pick(0.01, 5.0), + "back_error": _pick(0.01, 5.0), + } + + +def _omics_anndata( + rng: np.random.Generator, + cell_line_ids: Sequence[str], + *, + n_covered: int, + feature_names: Sequence[str], +) -> ad.AnnData: + """Build one omics modality covering the first *n_covered* cell lines.""" + covered = list(cell_line_ids[:n_covered]) + return ad.AnnData( + X=rng.normal(6.0, 1.5, size=(len(covered), len(feature_names))).astype(np.float32), + obs=pd.DataFrame(index=pd.Index(covered, name="cellosaurus_id")), + var=pd.DataFrame(index=pd.Index(list(feature_names), name="feature")), + ) + + +def build_synthetic_dataset( + *, + name: str = DATASET_NAME, + n_cell_lines: int = N_CELL_LINES, + n_drugs: int = N_DRUGS, + n_tissues: int = N_TISSUES, + omics: Sequence[str] | Mapping[str, int] | None = None, + feature_names: Sequence[str] | None = None, + n_features: int = N_FEATURES, + missing_fraction: float = 0.05, + low_quality_fraction: float = 0.0, + seed: int = SEED, +) -> Dataset: + """Build a deterministic in-memory dataset for tests. + + Args: + name: Dataset name recorded on the returned object. + n_cell_lines: Number of cell lines on the ``obs`` axis. + n_drugs: Number of drugs on the ``var`` axis. + n_tissues: Number of distinct tissue labels to cycle through. + omics: Public omics names to add as cell-line modalities. A mapping + additionally sets each modality's cell-line coverage, so a value + below *n_cell_lines* leaves trailing cell lines unmeasured and + exercises a component's NaN handling. + feature_names: Column names for every omics modality. Defaults to + generated ``FEATURE0000`` style names. + n_features: Number of generated column names when *feature_names* is + ``None``. + missing_fraction: Share of response pairs left unmeasured. Every cell + line and drug keeps at least one measurement regardless. + low_quality_fraction: Share of response pairs whose curve-quality + metrics fail the thresholds + :func:`~drevalpy.data.quality.curve_quality_mask` applies, so the + built-in splitters drop them. Defaults to ``0.0``, i.e. every curve + passes and a split is determined by *missing_fraction* alone. + seed: Seed for every drawn matrix, making the dataset reproducible. + + Returns: + A dataset carrying a ``response`` modality with cell-line metadata, + tissue labels, drug identifiers and the CurveCurator quality layers, + plus any requested omics modality. + """ + rng = np.random.default_rng(seed) + response = _response_anndata( + rng, + n_cell_lines=n_cell_lines, + n_drugs=n_drugs, + n_tissues=n_tissues, + missing_fraction=missing_fraction, + low_quality_fraction=low_quality_fraction, + ) + columns = feature_names if feature_names is not None else [f"FEATURE{index:04d}" for index in range(n_features)] + + modalities: dict[str, ad.AnnData] = {"response": response} + for public_name, n_covered in _omics_coverage(omics, n_cell_lines).items(): + modalities[resolve_omics_accessor(public_name)] = _omics_anndata( + rng, + list(response.obs_names), + n_covered=n_covered, + feature_names=columns, + ) + + md.set_options(pull_on_update=False) + mdata = md.MuData(modalities) + mdata.obs = response.obs.copy() + return Dataset(mdata, name=name) diff --git a/drevalpy/types/__init__.py b/drevalpy/types/__init__.py new file mode 100644 index 000000000..c5bee3597 --- /dev/null +++ b/drevalpy/types/__init__.py @@ -0,0 +1,52 @@ +"""Shared types and data structures for drevalpy.""" + +from .data import Dataset, MuDataLike, ResponseBatch, SplitMask, SplitMasks +from .data.batch import ( + BlockSpec, + FeatureBlock, + ModelInputBatch, + build_model_input_batch, + graph_feature_block, + merge_feature_blocks, + metadata_feature_block, + numeric_feature_block, + pair_cell_line_indices, + pair_drug_indices, + ragged_feature_block, +) +from .data.feature_source import ( + CellLineFeatureSource, + DrugFeatureSource, + FeatureSource, +) +from .enums import LiteratureReference, ModelScope, PredictionMode +from .results import ExperimentResult, ModelResult, RunResult, TrialResult + +__all__ = [ + "BlockSpec", + "CellLineFeatureSource", + "Dataset", + "DrugFeatureSource", + "ExperimentResult", + "FeatureBlock", + "FeatureSource", + "LiteratureReference", + "ModelInputBatch", + "ModelResult", + "ModelScope", + "MuDataLike", + "PredictionMode", + "ResponseBatch", + "RunResult", + "SplitMask", + "SplitMasks", + "TrialResult", + "build_model_input_batch", + "graph_feature_block", + "merge_feature_blocks", + "metadata_feature_block", + "numeric_feature_block", + "pair_cell_line_indices", + "pair_drug_indices", + "ragged_feature_block", +] diff --git a/drevalpy/types/data/__init__.py b/drevalpy/types/data/__init__.py new file mode 100644 index 000000000..0dde4e41a --- /dev/null +++ b/drevalpy/types/data/__init__.py @@ -0,0 +1,39 @@ +"""Data-related types: Dataset, splits, and response batches.""" + +from .batch import ( + BlockSpec, + FeatureBlock, + ModelInputBatch, + build_model_input_batch, + graph_feature_block, + merge_feature_blocks, + metadata_feature_block, + numeric_feature_block, + ragged_feature_block, +) +from .batch.response_batch import ResponseBatch +from .dataset import Dataset +from .feature_source import CellLineFeatureSource, DrugFeatureSource, FeatureSource +from .mudatalike import MuDataLike +from .split_mask import SplitMask +from .split_masks import SplitMasks + +__all__ = [ + "BlockSpec", + "CellLineFeatureSource", + "Dataset", + "DrugFeatureSource", + "FeatureBlock", + "FeatureSource", + "ModelInputBatch", + "MuDataLike", + "ResponseBatch", + "SplitMask", + "SplitMasks", + "build_model_input_batch", + "graph_feature_block", + "merge_feature_blocks", + "metadata_feature_block", + "numeric_feature_block", + "ragged_feature_block", +] diff --git a/drevalpy/types/data/batch/__init__.py b/drevalpy/types/data/batch/__init__.py new file mode 100644 index 000000000..82badd35b --- /dev/null +++ b/drevalpy/types/data/batch/__init__.py @@ -0,0 +1,27 @@ +"""Batch construction: ModelInputBatch, feature blocks, and pair indexing.""" + +from .feature_block import ( + BlockSpec, + FeatureBlock, + graph_feature_block, + merge_feature_blocks, + metadata_feature_block, + numeric_feature_block, + ragged_feature_block, +) +from .model_input_batch import ModelInputBatch, pair_cell_line_indices, pair_drug_indices +from .model_input_build import build_model_input_batch + +__all__ = [ + "BlockSpec", + "FeatureBlock", + "ModelInputBatch", + "build_model_input_batch", + "graph_feature_block", + "merge_feature_blocks", + "metadata_feature_block", + "numeric_feature_block", + "pair_cell_line_indices", + "pair_drug_indices", + "ragged_feature_block", +] diff --git a/drevalpy/types/data/batch/feature_block.py b/drevalpy/types/data/batch/feature_block.py new file mode 100644 index 000000000..47b82c571 --- /dev/null +++ b/drevalpy/types/data/batch/feature_block.py @@ -0,0 +1,117 @@ +"""Typed featurizer block payloads and configuration-time block specs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat + + +@dataclass(frozen=True) +class BlockSpec: + """Declare a named block emitted by a featurizer for compatibility checks.""" + + name: str + format: FeatureFormat + metadata: bool = False + + +@dataclass(frozen=True) +class FeatureBlock: + """Named featurizer output with format and optional feature metadata. + + ``values`` is always an ``np.ndarray``. Dense numeric blocks use float arrays; + graph and ragged blocks use object-dtype arrays whose elements are arbitrary payloads. + """ + + values: np.ndarray + format: FeatureFormat + feature_names: tuple[str, ...] | None = None + metadata: Mapping[str, object] | None = None + entity_aligned: bool = True + + def __post_init__(self) -> None: + """Freeze metadata into an immutable mapping when provided.""" + if self.metadata is not None and not isinstance(self.metadata, MappingProxyType): + object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata))) + + +def numeric_feature_block( + values: np.ndarray, + *, + feature_names: tuple[str, ...] | None = None, + metadata: Mapping[str, object] | None = None, +) -> FeatureBlock: + """Build a dense numeric matrix block. + + :param values: Entity-aligned 2D float array. + :param feature_names: Optional column names for the matrix. + :param metadata: Optional per-block metadata mapping. + + :returns: ``FeatureBlock`` with ``FeatureFormat.NUMERIC_MATRIX``. + """ + return FeatureBlock( + values=values, + format=FeatureFormat.NUMERIC_MATRIX, + feature_names=feature_names, + metadata=metadata, + ) + + +def graph_feature_block(values: np.ndarray) -> FeatureBlock: + """Build a graph payload block without dtype coercion. + + :param values: Object-dtype array of graph payloads, one row per entity. + + :returns: ``FeatureBlock`` with ``FeatureFormat.GRAPH``. + """ + return FeatureBlock(values=values, format=FeatureFormat.GRAPH) + + +def ragged_feature_block(values: np.ndarray) -> FeatureBlock: + """Build a ragged sequence payload block without dtype coercion. + + :param values: Object-dtype array of variable-length sequence payloads. + + :returns: ``FeatureBlock`` with ``FeatureFormat.RAGGED_SEQUENCE``. + """ + return FeatureBlock(values=values, format=FeatureFormat.RAGGED_SEQUENCE) + + +def metadata_feature_block(values: np.ndarray) -> FeatureBlock: + """Build a global metadata block that is not indexed per entity. + + :param values: Payload array stored once for the whole batch. + + :returns: ``FeatureBlock`` marked with ``entity_aligned=False``. + """ + return FeatureBlock( + values=values, + format=FeatureFormat.NUMERIC_MATRIX, + entity_aligned=False, + ) + + +def merge_feature_blocks( + *block_maps: Mapping[str, FeatureBlock], +) -> dict[str, FeatureBlock]: + """Merge child block mappings, rejecting duplicate emitted names. + + :param block_maps: One or more block mappings to combine in order. + + :returns: Single mapping containing every block from ``block_maps``. + + :raises ValueError: If the same block name appears in more than one mapping. + """ + merged: dict[str, FeatureBlock] = {} + for block_map in block_maps: + for name, block in block_map.items(): + if name in merged: + msg = f"Duplicate featurizer block name {name!r}" + raise ValueError(msg) + merged[name] = block + return merged diff --git a/drevalpy/types/data/batch/model_input_batch.py b/drevalpy/types/data/batch/model_input_batch.py new file mode 100644 index 000000000..d59a4d959 --- /dev/null +++ b/drevalpy/types/data/batch/model_input_batch.py @@ -0,0 +1,289 @@ +"""Canonical predictor input batch for component-based models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + +from drevalpy.components.contracts.training_context import TrainingContext +from drevalpy.types.data.batch.feature_block import FeatureBlock +from drevalpy.types.data.batch.response_batch import ResponseBatch + + +def _map_pair_indices( + entity_ids: np.ndarray, + id_to_row: dict[str, int], + *, + side: str, +) -> np.ndarray: + """Map pair identifiers to featurizer row indices with contextual errors. + + :param entity_ids: Entity id per response pair. + :param id_to_row: Mapping from entity id to featurizer row index. + :param side: Human-readable side label used in error messages. + :returns: Integer array of row indices aligned with *entity_ids*. + :raises ValueError: If any pair id is missing from *id_to_row*. + """ + missing: list[str] = [] + rows: list[int] = [] + for entity_id in entity_ids: + key = str(entity_id) + row = id_to_row.get(key) + if row is None: + missing.append(key) + else: + rows.append(row) + if missing: + preview = ", ".join(repr(item) for item in missing[:5]) + suffix = f" (+{len(missing) - 5} more)" if len(missing) > 5 else "" + msg = f"Missing {side} identifiers in featurizer rows: {preview}{suffix}" + raise ValueError(msg) + return np.asarray(rows, dtype=np.int64) + + +def pair_cell_line_indices( + cell_line_ids: np.ndarray, + cell_line_id_to_row: dict[str, int], +) -> np.ndarray: + """Map pair cell-line identifiers to featurizer row indices. + + :param cell_line_ids: Cell-line id per response pair. + :param cell_line_id_to_row: Mapping from entity id to featurizer row index. + :returns: Integer array of row indices aligned with *cell_line_ids*. + """ + return _map_pair_indices(cell_line_ids, cell_line_id_to_row, side="cell-line") + + +def pair_drug_indices( + drug_ids: np.ndarray, + drug_id_to_row: dict[str, int], +) -> np.ndarray: + """Map pair drug identifiers to featurizer row indices. + + :param drug_ids: Drug id per response pair. + :param drug_id_to_row: Mapping from entity id to featurizer row index. + :returns: Integer array of row indices aligned with *drug_ids*. + """ + return _map_pair_indices(drug_ids, drug_id_to_row, side="drug") + + +@dataclass +class ModelInputBatch: + """Featurized training or prediction batch handed to predictors.""" + + cell_line_ids: np.ndarray + drug_ids: np.ndarray + response: np.ndarray | None + cell_line_entity_ids: np.ndarray + drug_entity_ids: np.ndarray | None + cell_line_features: np.ndarray + drug_features: np.ndarray | None + cell_line_pair_idx: np.ndarray + drug_pair_idx: np.ndarray | None + cell_line_blocks: dict[str, FeatureBlock] = field(default_factory=dict) + drug_blocks: dict[str, FeatureBlock] = field(default_factory=dict) + early_stopping_response: ResponseBatch | None = None + training_context: TrainingContext = field(default_factory=TrainingContext) + + def __post_init__(self) -> None: + """Validate structural consistency of the batch. + + :raises ValueError: If array lengths are inconsistent with n_pairs. + """ + n = len(self.cell_line_ids) + if len(self.drug_ids) != n: + msg = f"drug_ids length ({len(self.drug_ids)}) must match cell_line_ids length ({n})" + raise ValueError(msg) + if self.response is not None and len(self.response) != n: + msg = f"response length ({len(self.response)}) must match n_pairs ({n})" + raise ValueError(msg) + if len(self.cell_line_pair_idx) != n: + msg = f"cell_line_pair_idx length ({len(self.cell_line_pair_idx)}) must match n_pairs ({n})" + raise ValueError(msg) + if self.drug_pair_idx is not None and len(self.drug_pair_idx) != n: + msg = f"drug_pair_idx length ({len(self.drug_pair_idx)}) must match n_pairs ({n})" + raise ValueError(msg) + if self.response is not None: + self.response = np.asarray(self.response, dtype=np.float64) + + @property + def n_pairs(self) -> int: + """Return the number of cell-line/drug pairs in the batch. + + :returns: Result. + """ + return len(self.cell_line_ids) + + @classmethod + def from_response( + cls, + response: ResponseBatch, + *, + cell_line_entity_ids: np.ndarray, + drug_entity_ids: np.ndarray | None, + cell_line_features: np.ndarray, + drug_features: np.ndarray | None, + cell_line_pair_idx: np.ndarray, + drug_pair_idx: np.ndarray | None, + cell_line_blocks: dict[str, FeatureBlock] | None = None, + drug_blocks: dict[str, FeatureBlock] | None = None, + early_stopping_response: ResponseBatch | None = None, + training_context: TrainingContext | None = None, + ) -> ModelInputBatch: + """Build a predictor input batch from a response dataset and featurizer outputs. + + :param response: Cell-line/drug pairs and optional response values. + :param cell_line_entity_ids: Entity ids aligned with cell-line feature rows. + :param drug_entity_ids: Entity ids aligned with drug feature rows, or ``None``. + :param cell_line_features: Dense or object cell-line feature matrix. + :param drug_features: Dense or object drug feature matrix, or ``None``. + :param cell_line_pair_idx: Row index into cell-line features for each pair. + :param drug_pair_idx: Row index into drug features for each pair, or ``None``. + :param cell_line_blocks: Named cell-line feature blocks from featurizers. + :param drug_blocks: Named drug feature blocks from featurizers. + :param early_stopping_response: Optional validation pairs for early stopping. + :param training_context: Runtime metadata for the training call. + :returns: ``ModelInputBatch`` ready for predictor ``fit`` or ``predict``. + """ + return cls( + cell_line_ids=response.cell_line_ids, + drug_ids=response.drug_ids, + response=response.response, + cell_line_entity_ids=cell_line_entity_ids, + drug_entity_ids=drug_entity_ids, + cell_line_features=cell_line_features, + drug_features=drug_features, + cell_line_pair_idx=cell_line_pair_idx, + drug_pair_idx=drug_pair_idx, + cell_line_blocks=dict(cell_line_blocks or {}), + drug_blocks=dict(drug_blocks or {}), + early_stopping_response=early_stopping_response, + training_context=training_context or TrainingContext(), + ) + + def _pair_indices_for(self, response: ResponseBatch) -> tuple[np.ndarray, np.ndarray | None]: + if self.cell_line_entity_ids.size == 0: + cell_line_pair_idx = np.zeros(len(response), dtype=np.int64) + else: + cell_line_map = {str(entity_id): row for row, entity_id in enumerate(self.cell_line_entity_ids)} + cell_line_pair_idx = pair_cell_line_indices(response.cell_line_ids, cell_line_map) + + drug_pair_idx = None + if self.drug_entity_ids is not None and self.drug_features is not None: + if self.drug_entity_ids.size == 0: + drug_pair_idx = np.zeros(len(response), dtype=np.int64) + else: + drug_map = {str(entity_id): row for row, entity_id in enumerate(self.drug_entity_ids)} + drug_pair_idx = pair_drug_indices(response.drug_ids, drug_map) + return cell_line_pair_idx, drug_pair_idx + + def feature_matrix_for(self, response: ResponseBatch) -> np.ndarray: + """Return a dense design matrix for an alternate response dataset. + + :param response: Pairs whose features should be materialized from stored + + :returns: Design matrix with one row per pair in *response*. + + :raises ValueError: If drug features are present but pair indices are missing. + """ + n_pairs = len(response) + if n_pairs == 0: + return np.empty((0, 0), dtype=np.float32) + cell_line_pair_idx, drug_pair_idx = self._pair_indices_for(response) + if self.drug_features is None or self.drug_features.size == 0: + if self.cell_line_features.size == 0: + return np.empty((n_pairs, 0), dtype=np.float32) + return self.cell_line_features[cell_line_pair_idx] + if self.cell_line_features.size == 0: + if drug_pair_idx is None: + msg = "drug_pair_idx is required when only drug features are present" + raise ValueError(msg) + return self.drug_features[drug_pair_idx] + if drug_pair_idx is None: + msg = "drug_pair_idx is required when drug features are present" + raise ValueError(msg) + from drevalpy.components.featurizers._matrix import stack_pair_features + + return stack_pair_features( + self.cell_line_features, + self.drug_features, + cell_line_pair_idx, + drug_pair_idx, + ) + + def early_stopping_feature_matrix(self) -> np.ndarray | None: + """Return validation features when early-stopping pairs are present. + + :returns: Design matrix for ``early_stopping_response``, or ``None`` when early stopping is disabled. + """ + if self.early_stopping_response is None or len(self.early_stopping_response) == 0: + return None + return self.feature_matrix_for(self.early_stopping_response) + + def to_feature_matrix(self) -> np.ndarray: + """Return a dense design matrix with one row per response pair. + + :returns: Design matrix for the batch's primary ``response`` pairs. + + :raises ValueError: If ``response`` is ``None``. + """ + if self.response is None: + msg = "ModelInputBatch.response is required to build a feature matrix" + raise ValueError(msg) + response = ResponseBatch( + response=self.response, + cell_line_ids=self.cell_line_ids, + drug_ids=self.drug_ids, + ) + return self.feature_matrix_for(response) + + def subset_pairs(self, mask: np.ndarray) -> ModelInputBatch: + """Return a batch containing only the selected response pairs. + + Early-stopping pairs are narrowed to the set of drugs that survive + *mask*, so a multi-drug subset keeps a multi-drug validation set. A + single-drug subset is the one-element case of that same rule, which is + what the single-drug predictors rely on. ``PredictorBase.fit`` needs the + multi-drug case because it filters NaN pairs across the whole batch. + + :param mask: One-dimensional boolean array with length ``n_pairs``. + + :returns: New batch referencing the same entity-level features. + + :raises ValueError: If *mask* is not a one-dimensional boolean array of length ``n_pairs``. + """ + mask = np.asarray(mask, dtype=bool) + if mask.ndim != 1 or mask.shape[0] != self.n_pairs: + msg = "subset mask must be a one-dimensional boolean array matching n_pairs" + raise ValueError(msg) + + early_stopping = self.early_stopping_response + if early_stopping is not None and np.any(mask): + selected_drugs = np.unique(self.drug_ids[mask]) + es_mask = np.isin(early_stopping.drug_ids, selected_drugs) + if not np.any(es_mask): + early_stopping = None + else: + early_stopping = ResponseBatch( + response=early_stopping.response[es_mask], + cell_line_ids=early_stopping.cell_line_ids[es_mask], + drug_ids=early_stopping.drug_ids[es_mask], + ) + + drug_pair_idx = self.drug_pair_idx + return ModelInputBatch( + cell_line_ids=self.cell_line_ids[mask], + drug_ids=self.drug_ids[mask], + response=None if self.response is None else self.response[mask], + cell_line_entity_ids=self.cell_line_entity_ids, + drug_entity_ids=self.drug_entity_ids, + cell_line_features=self.cell_line_features, + drug_features=self.drug_features, + cell_line_pair_idx=self.cell_line_pair_idx[mask], + drug_pair_idx=None if drug_pair_idx is None else drug_pair_idx[mask], + cell_line_blocks=dict(self.cell_line_blocks), + drug_blocks=dict(self.drug_blocks), + early_stopping_response=early_stopping, + training_context=self.training_context, + ) diff --git a/drevalpy/types/data/batch/model_input_build.py b/drevalpy/types/data/batch/model_input_build.py new file mode 100644 index 000000000..1ea0a1117 --- /dev/null +++ b/drevalpy/types/data/batch/model_input_build.py @@ -0,0 +1,105 @@ +"""Build `ModelInputBatch` from featurizer outputs.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.contracts.training_context import TrainingContext +from drevalpy.types.data.batch.feature_block import FeatureBlock +from drevalpy.types.data.batch.model_input_batch import ( + ModelInputBatch, + pair_cell_line_indices, + pair_drug_indices, +) +from drevalpy.types.data.batch.response_batch import ResponseBatch + + +def _validate_entity_feature_alignment( + entity_ids: np.ndarray, + features: np.ndarray | None, + *, + side: str, +) -> None: + if features is None or features.size == 0: + return + if entity_ids.size == 0: + msg = f"{side}_entity_ids must be non-empty when {side}_features are present" + raise ValueError(msg) + if features.ndim == 0 or features.shape[0] != entity_ids.size: + msg = ( + f"{side}_entity_ids length ({entity_ids.size}) must match " + f"{side}_features rows ({0 if features.ndim == 0 else features.shape[0]})" + ) + raise ValueError(msg) + + +def _cell_line_pair_indices( + response: ResponseBatch, + cell_line_entity_ids: np.ndarray, +) -> np.ndarray: + n_pairs = len(response) + if cell_line_entity_ids.size == 0: + return np.zeros(n_pairs, dtype=np.int64) + cell_line_map = {str(entity_id): row for row, entity_id in enumerate(cell_line_entity_ids)} + return pair_cell_line_indices(response.cell_line_ids, cell_line_map) + + +def _drug_pair_indices( + response: ResponseBatch, + drug_entity_ids: np.ndarray, +) -> np.ndarray | None: + n_pairs = len(response) + if drug_entity_ids.size == 0: + return np.zeros(n_pairs, dtype=np.int64) + drug_map = {str(entity_id): row for row, entity_id in enumerate(drug_entity_ids)} + return pair_drug_indices(response.drug_ids, drug_map) + + +def build_model_input_batch( + response: ResponseBatch, + *, + cell_line_entity_ids: np.ndarray, + drug_entity_ids: np.ndarray | None, + cell_line_features: np.ndarray, + drug_features: np.ndarray | None, + cell_line_blocks: dict[str, FeatureBlock] | None = None, + drug_blocks: dict[str, FeatureBlock] | None = None, + early_stopping_response: ResponseBatch | None = None, + training_context: TrainingContext | None = None, +) -> ModelInputBatch: + """Index entity-level featurizer outputs for each response pair. + + :param response: response. + :param cell_line_entity_ids: cell line entity ids. + :param drug_entity_ids: drug entity ids. + :param cell_line_features: cell line features. + :param drug_features: drug features. + :param cell_line_blocks: cell line blocks. + :param drug_blocks: drug blocks. + :param early_stopping_response: early stopping response. + :param training_context: training context. + :returns: Result. + """ + _validate_entity_feature_alignment(cell_line_entity_ids, cell_line_features, side="cell_line") + if drug_entity_ids is not None: + _validate_entity_feature_alignment(drug_entity_ids, drug_features, side="drug") + + cell_line_pair_idx = _cell_line_pair_indices(response, cell_line_entity_ids) + + drug_pair_idx = None + if drug_entity_ids is not None and drug_features is not None: + drug_pair_idx = _drug_pair_indices(response, drug_entity_ids) + + return ModelInputBatch.from_response( + response, + cell_line_entity_ids=cell_line_entity_ids, + drug_entity_ids=drug_entity_ids, + cell_line_features=cell_line_features, + drug_features=drug_features, + cell_line_pair_idx=cell_line_pair_idx, + drug_pair_idx=drug_pair_idx, + cell_line_blocks=cell_line_blocks, + drug_blocks=drug_blocks, + early_stopping_response=early_stopping_response, + training_context=training_context, + ) diff --git a/drevalpy/types/data/batch/response_batch.py b/drevalpy/types/data/batch/response_batch.py new file mode 100644 index 000000000..8402ae057 --- /dev/null +++ b/drevalpy/types/data/batch/response_batch.py @@ -0,0 +1,27 @@ +"""Lightweight immutable container for response triples passed to predictors.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True, slots=True) +class ResponseBatch: + """Lightweight immutable container for response triples passed to predictors. + + Provides ``.response``, ``.cell_line_ids``, + ``.drug_ids``, and ``__len__``. + """ + + response: np.ndarray + cell_line_ids: np.ndarray + drug_ids: np.ndarray + + def __len__(self) -> int: + """Return the number of response pairs. + + :returns: Length of the response array. + """ + return len(self.response) diff --git a/drevalpy/types/data/dataset.py b/drevalpy/types/data/dataset.py new file mode 100644 index 000000000..2c17c12c4 --- /dev/null +++ b/drevalpy/types/data/dataset.py @@ -0,0 +1,460 @@ +"""MuData-backed dataset class for drevalpy. + +``Dataset`` wraps a MuData object and provides typed access to response data, +cell-line and drug features, metadata, and auxiliary model data backed by an +.h5mu file. + +``mudata`` and ``pandas`` are imported lazily. This module is on the critical +path of ``import drevalpy`` (the registration path reaches it through +:mod:`drevalpy.types`), and ``mudata`` costs ~0.3s because it pulls in +``anndata``, ``dask.array``, ``zarr`` and ``scipy.stats``. Every annotation +referring to them is a string thanks to ``from __future__ import annotations``, +so only the handful of runtime uses need a function-local import. See +``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np +from rich.progress import Progress +from upath import UPath as Path + +from drevalpy.log import get_logger + +from .dataset_utils._dense import to_dense +from .dataset_utils.feature_access import FeatureAccessMixin +from .dataset_utils.randomization import RandomizationMixin +from .dataset_utils.sampling import _sample_hp_configs +from .modalities import backing_modality +from .mudatalike import MuDataLike + +if TYPE_CHECKING: + import mudata as md + import pandas as pd + +logger = get_logger(__name__) + + +class Dataset(FeatureAccessMixin, RandomizationMixin, MuDataLike): + """Single entry point for all dataset access in drevalpy. + + Wraps a MuData object containing a "response" modality (cell_line x drug + matrix with pEC50 as X) plus any number of cell-line feature modalities + (gene_expression, proteomics, etc.). Every other curve metric - ``LN_IC50``, + ``AUC``, ``IC50``, the goodness-of-fit columns - is a named layer; see + :func:`drevalpy.curation.curate`. + + Drug features are stored as ``response.varm`` entries, drug graphs in + ``mdata.uns["drug_graphs"]``, and model-specific auxiliary data in other + ``mdata.uns`` keys. + """ + + def __init__( + self, + mdata: md.MuData, + *, + name: str, + randomization: tuple[str, str] | None = None, + ) -> None: + """Wrap an existing MuData object. + + Args: + mdata: A MuData object with at least a "response" modality. + name: Human-readable dataset name. + randomization: Optional (mode, view) tuple describing which + randomization was applied. + + Raises: + KeyError: If the "response" modality is missing. + """ + if "response" not in mdata.mod: + raise KeyError("MuData must contain a 'response' modality.") + self._mdata = mdata + self._name = name + self.randomization = randomization + + @classmethod + def load(cls, path: str | Path) -> Dataset: + """Read a Dataset from an .h5mu file on disk. + + :param path: Path to the .h5mu file. + :returns: A Dataset wrapping the loaded MuData. + """ + import mudata as md + + resolved = Path(path) + md.set_options(pull_on_update=False) + mdata = md.read_h5mu(resolved) + + stored_name = mdata.uns.get("dataset_name") + name = stored_name if isinstance(stored_name, str) else resolved.stem + + randomization = None + stored_rand = mdata.uns.get("randomization") + if isinstance(stored_rand, (list, tuple)) and len(stored_rand) == 2: + randomization = (str(stored_rand[0]), str(stored_rand[1])) + + return cls(mdata, name=name, randomization=randomization) + + def save(self, path: str | Path) -> None: + """Write this Dataset to an .h5mu file, preserving name and randomization. + + :param path: Output file path. + """ + resolved = Path(path) + resolved.parent.mkdir(parents=True, exist_ok=True) + + self._mdata.uns["dataset_name"] = self._name + if self.randomization is not None: + self._mdata.uns["randomization"] = list(self.randomization) + elif "randomization" in self._mdata.uns: + del self._mdata.uns["randomization"] + + self._mdata.write(str(resolved)) + + def precompute( + self, + featurizer_cls: type, + hyperparameters: list[dict] | int, + view: str | None = None, + ) -> None: + """Pre-compute and store featurizer representations for given HP configs. + + For independent featurizers (those with ``_compute_from_source``), calls + that method directly, bypassing fit/transform. Always includes the + default HP config in addition to sampled variants. + + :param featurizer_cls: Registered featurizer class (knows its own side). + :param hyperparameters: Either a list of explicit HP dicts, + or an int N to sample N configs from the featurizer's HP space. + :param view: View name for view-parameterized featurizers (e.g., "gene_expression"). + """ + from drevalpy.types.data.feature_source import CellLineFeatureSource, DrugFeatureSource + + if isinstance(hyperparameters, int): + configs = _sample_hp_configs(featurizer_cls, hyperparameters) + else: + configs = list(hyperparameters) + + default_config = featurizer_cls.get_default_hyperparameters() + if default_config not in configs: + configs.insert(0, default_config) + + side = getattr(featurizer_cls, "side", "cell_line") + if side == "cell_line": + entity_ids = self.cell_line_ids + source = CellLineFeatureSource(self, entity_ids) + else: + entity_ids = self.drug_ids + source = DrugFeatureSource(self, entity_ids) + + base_kwargs: dict = {} + if view is not None: + base_kwargs["view"] = view + + with Progress() as progress: + task = progress.add_task(f"Precomputing {featurizer_cls.storage_key}", total=len(configs)) + for config in configs: + featurizer = featurizer_cls(**{**base_kwargs, **config}) + if hasattr(featurizer, "_compute_from_source"): + matrix = featurizer._compute_from_source(source, entity_ids) + else: + featurizer.fit(source, entity_ids=entity_ids) + matrix = featurizer.transform(source, entity_ids) + featurizer.store(self._mdata, entity_ids, matrix, hyperparameters=config) + progress.advance(task) + + def precompute_all(self, n_variants: int = 10) -> None: + """Pre-compute all fixed featurizers with N HP variants each. + + Iterates all registered featurizers (cell-line + drug), skips those + not marked for precomputation, and pre-computes N sampled HP + configurations for the rest. Featurizers without any HP space get a + single default-params variant. + + :param n_variants: Number of HP configurations to sample for featurizers + that have a tunable HP space. + """ + from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry + from drevalpy.registry.drug_featurizer import drug_featurizer_registry + + for registry in (cell_line_featurizer_registry, drug_featurizer_registry): + for name in registry.list_names(): + self._precompute_single(registry.get(name), n_variants) + + def _precompute_single(self, cls: type, n_variants: int) -> None: + """Pre-compute one featurizer class if eligible.""" + name = getattr(cls, "registry_name", cls.__name__) + if not cls.precompute: + return + if cls.entity_id_only: + logger.debug("Skipping %s: entity_id_only", name) + return + source_views = getattr(cls, "source_views", None) + if source_views and not self._has_source_data(source_views, cls): + logger.debug("Skipping %s: required source data not available", name) + return + hp_space = cls.get_hyperparameter_space() + effective_n = n_variants if hp_space else 1 + view: str | None = None + if cls.requires_view: + if cls.input_views: + view = cls.input_views[0] + else: + logger.debug("Skipping %s: requires_view but no input_views declared", name) + return + try: + logger.info("Precomputing %s (%d variants)", name, effective_n) + self.precompute(cls, effective_n, view=view) + except (ValueError, TypeError, KeyError, ImportError) as exc: + logger.warning("Failed to precompute %s: %s", name, exc) + + def _has_source_data(self, source_views: tuple[str, ...], cls: type) -> bool: + """Check if the dataset has the raw source data needed for a featurizer.""" + side = getattr(cls, "side", "cell_line") + return all(self._has_single_source(view, side) for view in source_views) + + def _has_single_source(self, view: str, side: str) -> bool: + """Check availability of a single source view.""" + if view == "canonical_smiles": + response = self._mdata.mod.get("response") + return response is not None and "canonical_smiles" in response.var.columns + if side == "cell_line": + return backing_modality(view, self._mdata.mod) is not None + response = self._mdata.mod.get("response") + return response is not None and response.varm is not None and view in response.varm + + def _has_required_views(self, views: tuple[str, ...]) -> bool: + """Check if all required views are available in this dataset.""" + available_mods = set(self._mdata.mod.keys()) - {"response"} + response = self._mdata.mod.get("response") + available_varm = set(response.varm.keys()) if response is not None and response.varm is not None else set() + available_obsm = set(response.obsm.keys()) if response is not None and response.obsm is not None else set() + available = available_mods | available_varm | available_obsm + return all(backing_modality(v, available) is not None for v in views) + + @property + def name(self) -> str: + """Human-readable dataset name.""" + return self._name + + @property + def mdata(self) -> md.MuData: + """Return the underlying MuData object.""" + return self._mdata + + # ------------------------------------------------------------------ + # Response access + # ------------------------------------------------------------------ + + @property + def response(self) -> md.AnnData: + """Return the response AnnData (cell_lines x drugs).""" + return self._mdata.mod["response"] + + @property + def response_matrix(self) -> np.ndarray: + """pEC50 response matrix (n_cell_lines x n_drugs). + + ``X`` holds pEC50. Use :meth:`get_response_layer` for any other measure, + for example ``get_response_layer("LN_IC50")``. + + Returns: + Dense float32 array of shape (n_cell_lines, n_drugs). + """ + return np.asarray(to_dense(self.response.X), dtype=np.float32) + + @property + def cell_line_ids(self) -> np.ndarray: + """Cell line identifiers (obs_names of the response modality). + + Returns: + 1-D string array of cellosaurus IDs. + """ + return np.asarray(self.response.obs_names) + + @property + def drug_ids(self) -> np.ndarray: + """Drug identifiers (var_names of the response modality). + + Returns: + 1-D string array of PubChem IDs. + """ + return np.asarray(self.response.var_names) + + def get_response_layer(self, name: str) -> np.ndarray: + """Retrieve a named response layer (e.g. "AUC"). + + Args: + name: Layer name within the response AnnData. + + Returns: + Dense float32 array of shape (n_cell_lines, n_drugs). + + Raises: + KeyError: If the layer does not exist. + """ + if name not in self.response.layers: + raise KeyError(f"Response layer '{name}' not found. Available: {self.response_layer_names()}") + return np.asarray(to_dense(self.response.layers[name]), dtype=np.float32) + + def response_layer_names(self) -> list[str]: + """Names of the available response layers. + + Returns: + Layer names of the response modality, in insertion order. + """ + # anndata 0.13 yields a spurious ``None`` from ``layers.keys()``, even for + # a freshly constructed AnnData with a single layer, so filter to the real + # names rather than leaking a non-``str`` into a ``list[str]``. + return [name for name in self.response.layers.keys() if isinstance(name, str)] + + # ------------------------------------------------------------------ + # Metadata + # ------------------------------------------------------------------ + + @property + def cell_line_meta(self) -> pd.DataFrame: + """Global cell-line metadata (cell_line_name, tissue, etc.). + + Returns: + DataFrame indexed by cellosaurus_id from mdata.obs. + """ + return self._mdata.obs + + def get_tissue(self, ids: np.ndarray) -> np.ndarray: + """Get tissue labels for the given cell line IDs. + + Args: + ids: 1-D array of cellosaurus IDs. + + Returns: + 1-D string array of tissue labels (NaN for unknown IDs). + """ + import pandas as pd + + ids = np.asarray(ids, dtype=str) + idx = pd.Index(self._mdata.obs.index) + positions = idx.get_indexer(ids) + + tissues = self._mdata.obs["tissue"].values + result = np.full(len(ids), np.nan, dtype=object) + valid = positions >= 0 + result[valid] = tissues[positions[valid]] + return result + + # ------------------------------------------------------------------ + # Subsetting + # ------------------------------------------------------------------ + + def subset_cell_lines(self, ids: np.ndarray) -> Dataset: + """Return a new Dataset restricted to the given cell lines. + + Only keeps cell lines present in the response modality. Other modalities + are also subset to their intersection with *ids*. + + Args: + ids: 1-D array of cellosaurus IDs to keep. + + Returns: + New Dataset backed by a view of the underlying MuData. + """ + import mudata as md + + ids = np.asarray(ids, dtype=str) + response_mask = np.isin(self.response.obs_names, ids) + kept_cell_lines = self.response.obs_names[response_mask] + + new_mods: dict[str, md.AnnData] = {} + for mod_name, mod_adata in self._mdata.mod.items(): + mod_mask = np.isin(mod_adata.obs_names, kept_cell_lines) + new_mods[mod_name] = mod_adata[mod_mask].copy() + + md.set_options(pull_on_update=False) + new_mdata = md.MuData(new_mods) + new_mdata.obs = self._mdata.obs.loc[self._mdata.obs.index.isin(kept_cell_lines)].copy() + for key, val in self._mdata.uns.items(): + new_mdata.uns[key] = val + return Dataset(new_mdata, name=self._name) + + def subset_drugs(self, ids: np.ndarray) -> Dataset: + """Return a new Dataset restricted to the given drugs. + + Only the response modality has a drug axis; it is subset on var. + Other modalities (cell-line features) are kept unchanged. + + Args: + ids: 1-D array of PubChem drug IDs to keep. + + Returns: + New Dataset backed by a view of the underlying MuData. + """ + import mudata as md + + ids = np.asarray(ids, dtype=str) + drug_mask = np.isin(self.response.var_names, ids) + + new_mods: dict[str, md.AnnData] = {} + for mod_name, mod_adata in self._mdata.mod.items(): + if mod_name == "response": + new_mods[mod_name] = mod_adata[:, drug_mask].copy() + else: + new_mods[mod_name] = mod_adata.copy() + + md.set_options(pull_on_update=False) + new_mdata = md.MuData(new_mods) + new_mdata.obs = self._mdata.obs.copy() + for key, val in self._mdata.uns.items(): + new_mdata.uns[key] = val + return Dataset(new_mdata, name=self._name) + + # ------------------------------------------------------------------ + # Auxiliary data + # ------------------------------------------------------------------ + + def get_uns(self, key: str) -> Any: + """Access arbitrary data from mdata.uns. + + Args: + key: Key in the uns dict. + + Returns: + The stored value. + + Raises: + KeyError: If the key does not exist. + """ + if key not in self._mdata.uns: + raise KeyError(f"uns key '{key}' not found. Available: {list(self._mdata.uns.keys())}") + return self._mdata.uns[key] + + # ------------------------------------------------------------------ + # Dunder methods + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + """Return a formatted summary.""" + n_cl = len(self.cell_line_ids) + n_dr = len(self.drug_ids) + response = self.response_matrix + n_measured = int(np.sum(~np.isnan(response))) + mods = [m for m in self._mdata.mod.keys() if m != "response"] + + lines = [ + "Dataset", + f" Name: {self._name}", + f" Cell lines: {n_cl}", + f" Drugs: {n_dr}", + f" Measured pairs: {n_measured}", + f" Randomization: {self.randomization[0]} ({self.randomization[1]})" + if self.randomization + else " Randomization: None", + " Modalities:", + ] + for mod in mods: + shape = self._mdata.mod[mod].X.shape + lines.append(f" {mod}: {shape[0]} × {shape[1]}") + + return "\n".join(lines) diff --git a/drevalpy/types/data/dataset_utils/__init__.py b/drevalpy/types/data/dataset_utils/__init__.py new file mode 100644 index 000000000..fd84fd653 --- /dev/null +++ b/drevalpy/types/data/dataset_utils/__init__.py @@ -0,0 +1,25 @@ +"""Dataset utility modules: feature access, randomization, sampling, and aligned fetch.""" + +from .aligned_fetch import _aligned_fetch +from .feature_access import FeatureAccessMixin +from .randomization import ( + RandomizationMixin, + _degree_preserving_rewire, + _is_graph_dict, + _randomize_graph, + _randomize_matrix, + _randomize_single_view, +) +from .sampling import _sample_hp_configs + +__all__ = [ + "FeatureAccessMixin", + "RandomizationMixin", + "_aligned_fetch", + "_degree_preserving_rewire", + "_is_graph_dict", + "_randomize_graph", + "_randomize_matrix", + "_randomize_single_view", + "_sample_hp_configs", +] diff --git a/drevalpy/types/data/dataset_utils/_dense.py b/drevalpy/types/data/dataset_utils/_dense.py new file mode 100644 index 000000000..4c9eb820a --- /dev/null +++ b/drevalpy/types/data/dataset_utils/_dense.py @@ -0,0 +1,22 @@ +"""Densification helper shared by every matrix read on the dataset hot path.""" + +from __future__ import annotations + +from typing import Any + + +def to_dense(x: Any) -> Any: + """Return *x* densified if it is a SciPy sparse matrix, otherwise unchanged. + + AnnData stores ``X``, layers and ``varm`` entries either as dense arrays or as + SciPy sparse matrices depending on how the ``.h5mu`` was written, so callers + cannot know which they hold. Only sparse containers expose ``toarray``. + + Args: + x: Matrix-like object, dense or sparse. + + Returns: + A dense array-like: ``x.toarray()`` when *x* is sparse, else *x* itself. + """ + to_array = getattr(x, "toarray", None) + return to_array() if callable(to_array) else x diff --git a/drevalpy/types/data/dataset_utils/aligned_fetch.py b/drevalpy/types/data/dataset_utils/aligned_fetch.py new file mode 100644 index 000000000..bf0f0c3ec --- /dev/null +++ b/drevalpy/types/data/dataset_utils/aligned_fetch.py @@ -0,0 +1,51 @@ +"""Generic aligned-fetch utility for retrieving rows by ID.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.log import get_logger + +if TYPE_CHECKING: + import pandas as pd + +logger = get_logger(__name__) + + +def _aligned_fetch( + index: pd.Index, + ids: np.ndarray, + data: np.ndarray, + *, + strict: bool, + entity_label: str, +) -> np.ndarray: + """Fetch rows from *data* aligned to *ids* using *index*, filling NaN for missing. + + Args: + index: pd.Index mapping entity names to row positions in *data*. + ids: 1-D array of requested entity IDs. + data: 2-D source array to fetch rows from. + strict: If True, raise KeyError for missing IDs instead of warning. + entity_label: Human-readable label for error messages (e.g. "cell line"). + + Returns: + Float32 array of shape (len(ids), data.shape[1]). + """ + positions = index.get_indexer(ids) + missing_mask = positions == -1 + if missing_mask.any(): + n_missing = int(missing_mask.sum()) + sample = ids[missing_mask][:5].tolist() + msg = f"{n_missing} of {len(ids)} {entity_label} IDs not found (first few: {sample}). Returning NaN rows." + if strict: + raise KeyError(msg) + logger.warning(msg) + + n_features = data.shape[1] + result = np.full((len(ids), n_features), np.nan, dtype=np.float32) + valid = positions >= 0 + result[valid] = np.asarray(data[positions[valid]], dtype=np.float32) + return result diff --git a/drevalpy/types/data/dataset_utils/feature_access.py b/drevalpy/types/data/dataset_utils/feature_access.py new file mode 100644 index 000000000..55fe4df82 --- /dev/null +++ b/drevalpy/types/data/dataset_utils/feature_access.py @@ -0,0 +1,234 @@ +"""Mixin providing cell-line and drug feature access from MuData.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.types.data.modalities import backing_modality, public_omics_name + +from ._dense import to_dense +from .aligned_fetch import _aligned_fetch + +if TYPE_CHECKING: + import mudata as md + + +class FeatureAccessMixin: + """Mixin that provides cell-line and drug feature access methods. + + Expects ``self._mdata`` to be a MuData object with a "response" modality. + + Cell-line omics are addressed by their **public** name (the name configs, + recipes and predictors use). Published ``.h5mu`` files do not necessarily + store them under that name, so every lookup of ``self._mdata.mod`` by a + caller-supplied name goes through + :func:`~drevalpy.types.data.modalities.backing_modality`. This is the + boundary at which a public name becomes a physical modality key; above it + everything stays public. + """ + + _mdata: md.MuData + + def _omics_modality(self, name: str) -> str: + """Resolve a public omics name to a stored modality key, or raise. + + Args: + name: Public omics name as written in a config or predictor. + + Returns: + The modality key present in ``self._mdata.mod``. + + Raises: + KeyError: If no modality backs *name*. + """ + modality = backing_modality(name, self._mdata.mod) + if modality is None: + available = sorted(public_omics_name(key) for key in self._mdata.mod) + raise KeyError(f"Modality '{name}' not found. Available: {available}") + return modality + + def get_cell_line_features(self, modality: str, ids: np.ndarray, *, strict: bool = False) -> np.ndarray: + """Get a feature matrix for the specified cell lines from a modality. + + Args: + modality: Public name of the modality (e.g. "gene_expression"). + ids: 1-D array of cell line IDs to retrieve. + strict: If True, raise KeyError for missing IDs instead of warning. + + Returns: + Float32 array of shape (len(ids), n_features), rows aligned to *ids*. + + Raises: + KeyError: If the modality is not present, or if *strict* and IDs are missing. + """ + import pandas as pd + + ids = np.asarray(ids, dtype=str) + + if modality == "pathway_features": + return self._get_obsm_features("pathway_features", ids, strict=strict) + + adata = self._mdata.mod[self._omics_modality(modality)] + x = np.asarray(to_dense(adata.X)) + return _aligned_fetch(pd.Index(adata.obs_names), ids, x, strict=strict, entity_label="cell line") + + def _get_obsm_features(self, key: str, ids: np.ndarray, *, strict: bool = False) -> np.ndarray: + """Retrieve cell-line features stored in response.obsm.""" + import pandas as pd + + response = self._mdata.mod["response"] + if key not in response.obsm: + raise KeyError(f"obsm key '{key}' not found in response modality.") + + obsm_data = np.asarray(response.obsm[key]) + return _aligned_fetch(pd.Index(response.obs_names), ids, obsm_data, strict=strict, entity_label="cell line") + + def get_cell_line_feature_names(self, view: str) -> tuple[str, ...] | None: + """Return the feature (column) names for a cell-line view. + + Args: + view: Public name of the modality. + + Returns: + Tuple of feature names, or None if names are unavailable. + """ + if view == "pathway_features": + return None + modality = backing_modality(view, self._mdata.mod) + if modality is None: + return None + return tuple(self._mdata.mod[modality].var_names) + + def _resolve_varm_key(self, name: str) -> str | None: + """Resolve a varm key by exact match or prefix match (name:variant).""" + varm = self._mdata.mod["response"].varm + if varm is None: + return None + if name in varm: + return name + for key in varm.keys(): + if key.startswith(name + ":"): + return key + return None + + @property + def available_drug_views(self) -> list[str]: + """Sorted list of drug feature varm keys.""" + response = self._mdata.mod["response"] + if response.varm is None: + return [] + return sorted(response.varm.keys()) + + def get_drug_features(self, name: str, ids: np.ndarray, *, strict: bool = False) -> np.ndarray: + """Get a drug feature matrix from response.varm, aligned to given IDs. + + Args: + name: Key in ``response.varm`` (e.g. "chemberta", "morgan_fingerprint"). + ids: 1-D array of drug (PubChem) IDs. + strict: If True, raise KeyError for missing IDs instead of warning. + + Returns: + Float32 array of shape (len(ids), n_features), rows aligned to *ids*. + + Raises: + KeyError: If the varm key does not exist, or if *strict* and IDs are missing. + """ + import pandas as pd + + varm_key = self._resolve_varm_key(name) + if varm_key is None: + raise KeyError(f"Drug feature '{name}' not found. Available varm keys: {self.available_drug_views}") + + response = self._mdata.mod["response"] + ids = np.asarray(ids, dtype=str) + varm_data = np.asarray(response.varm[varm_key]) + return _aligned_fetch(pd.Index(response.var_names), ids, varm_data, strict=strict, entity_label="drug") + + def get_drug_feature_names(self, view: str) -> tuple[str, ...] | None: + """Return the feature (column) names for a drug view stored in response.varm. + + Args: + view: Drug view name (e.g. "chemberta", "morgan_fingerprint"). + + Returns: + Tuple of column name strings, or None if the view does not exist. + """ + varm_key = self._resolve_varm_key(view) + if varm_key is None: + return None + varm_data = self._mdata.mod["response"].varm[varm_key] + if hasattr(varm_data, "columns"): + return tuple(varm_data.columns.astype(str)) + return tuple(str(i) for i in range(varm_data.shape[1])) + + def get_drug_graphs(self, ids: np.ndarray) -> list[dict[str, np.ndarray] | None]: + """Get PyTorch Geometric graph data for the specified drugs. + + Each graph dict has keys "x", "edge_index", "edge_attr" with numpy arrays. + Returns None for drugs without a stored graph. + + Args: + ids: 1-D array of drug (PubChem) IDs. + + Returns: + List of graph dicts (or None) aligned to *ids*. + + Raises: + KeyError: If "drug_graphs" is not in mdata.uns. + """ + if "drug_graphs" not in self._mdata.uns: + raise KeyError("'drug_graphs' not found in mdata.uns.") + + ids = np.asarray(ids, dtype=str) + graphs = self._mdata.uns["drug_graphs"] + return [graphs.get(drug_id) for drug_id in ids] + + def entities_with_modality(self, modality: str, *, side: str = "cell_line") -> frozenset[str]: + """Return entity IDs that have actual feature data for a modality. + + Args: + modality: Modality or view name (e.g. "gene_expression", "fingerprints"). + side: Either "cell_line" or "drug". + + Returns: + Frozenset of entity IDs that have non-NaN data for the modality. + + Raises: + KeyError: If the modality/view is not found. + """ + if side == "cell_line": + return self._cell_line_entities_for_modality(modality) + return self._drug_entities_for_view(modality) + + def _cell_line_entities_for_modality(self, modality: str) -> frozenset[str]: + """Cell line IDs present in a given modality.""" + response = self._mdata.mod["response"] + if modality == "pathway_features": + if "pathway_features" not in response.obsm: + return frozenset() + data = np.asarray(response.obsm["pathway_features"]) + valid = ~np.all(np.isnan(data), axis=1) + return frozenset(np.asarray(response.obs_names)[valid]) + + adata = self._mdata.mod[self._omics_modality(modality)] + x = np.asarray(to_dense(adata.X)) + valid = ~np.all(np.isnan(x), axis=1) + return frozenset(np.asarray(adata.obs_names)[valid]) + + def _drug_entities_for_view(self, name: str) -> frozenset[str]: + """Drug IDs present in a given drug feature view.""" + if name == "drug_graph": + if "drug_graphs" not in self._mdata.uns: + return frozenset() + return frozenset(str(k) for k in self._mdata.uns["drug_graphs"].keys()) + + varm_key = self._resolve_varm_key(name) + if varm_key is None: + raise KeyError(f"Drug feature '{name}' not found. Available varm keys: {self.available_drug_views}") + + response = self._mdata.mod["response"] + varm_data = np.asarray(response.varm[varm_key]) + valid = ~np.all(np.isnan(varm_data), axis=1) + return frozenset(np.asarray(response.var_names)[valid]) diff --git a/drevalpy/types/data/dataset_utils/randomization.py b/drevalpy/types/data/dataset_utils/randomization.py new file mode 100644 index 000000000..71e6cd7d1 --- /dev/null +++ b/drevalpy/types/data/dataset_utils/randomization.py @@ -0,0 +1,225 @@ +"""Mixin providing view randomization for Dataset.""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING, Any + +import numpy as np + +from drevalpy.log import get_logger +from drevalpy.types.data.modalities import backing_modality + +from ._dense import to_dense + +if TYPE_CHECKING: + import mudata as md + + from drevalpy.types.data.dataset import Dataset + +logger = get_logger(__name__) + + +def _randomize_matrix(data: np.ndarray, rng: np.random.Generator, randomization_type: str) -> np.ndarray: + """Apply randomization to a 2-D feature matrix.""" + if randomization_type == "permutation": + perm = rng.permutation(data.shape[0]) + return data[perm] + return np.array( + [rng.normal(row.mean(), max(row.std(), 1e-8), row.shape) for row in data], + dtype=np.float32, + ) + + +def _degree_preserving_rewire( + edge_index: np.ndarray, rng: np.random.Generator, n_swaps: int | None = None +) -> np.ndarray: + """Rewire edges while preserving each node's degree. + + Uses pairwise edge swaps: pick two edges (u,v) and (x,y), replace with + (u,y) and (x,v) if neither already exists (avoiding self-loops and + multi-edges). + + Args: + edge_index: (2, E) array of edge endpoints. + rng: Numpy random generator. + n_swaps: Number of swap attempts. Defaults to 10 * num_edges. + + Returns: + Rewired (2, E) edge_index with identical degree sequence. + """ + edges = edge_index.T.copy() + num_edges = edges.shape[0] + if num_edges < 2: + return edge_index.copy() + + if n_swaps is None: + n_swaps = 10 * num_edges + + edge_set: set[tuple[int, int]] = {(int(edges[i, 0]), int(edges[i, 1])) for i in range(num_edges)} + + for _ in range(n_swaps): + i, j = rng.integers(0, num_edges, size=2) + if i == j: + continue + u, v = int(edges[i, 0]), int(edges[i, 1]) + x, y = int(edges[j, 0]), int(edges[j, 1]) + + if u == y or x == v: + continue + if (u, y) in edge_set or (x, v) in edge_set: + continue + + edge_set.discard((u, v)) + edge_set.discard((x, y)) + edge_set.add((u, y)) + edge_set.add((x, v)) + edges[i] = [u, y] + edges[j] = [x, v] + + return edges.T + + +def _randomize_graph(graph: dict[str, np.ndarray], rng: np.random.Generator) -> dict[str, np.ndarray]: + """Apply invariant randomization to a single drug graph. + + Preserves degree distribution (edge_index), and replaces node/edge + features with Gaussian samples matching per-row mean and std. + """ + result: dict[str, np.ndarray] = {} + for key, val in graph.items(): + arr = np.asarray(val) + if key == "edge_index": + result[key] = _degree_preserving_rewire(arr, rng) + elif arr.ndim == 2: + result[key] = _randomize_matrix(arr, rng, "invariant") + else: + result[key] = arr.copy() + return result + + +def _is_graph_dict(data: dict[str, Any]) -> bool: + """Check if a dict-of-dicts looks like a collection of graph dicts. + + Heuristic: at least one value is itself a dict containing an "edge_index" key. + """ + return any(isinstance(v, dict) and "edge_index" in v for v in data.values()) + + +def _randomize_uns_view( + data: Any, + view: str, + new_uns: dict[str, Any], + rng: np.random.Generator, + randomization_type: str, +) -> None: + """Randomize a view stored in uns (dict of dicts or plain dict).""" + if not isinstance(data, dict): + logger.warning("Cannot randomize uns key '%s' (not a dict). Skipping.", view) + return + + if randomization_type == "invariant" and _is_graph_dict(data): + new_uns[view] = {key: _randomize_graph(val, rng) if isinstance(val, dict) else val for key, val in data.items()} + else: + keys = list(data.keys()) + shuffled_keys = rng.permutation(keys).tolist() + new_uns[view] = dict(zip(shuffled_keys, data.values(), strict=True)) + + +def _randomize_single_view( + dataset: Any, + view: str, + new_mods: dict[str, md.AnnData], + new_uns: dict[str, Any], + rng: np.random.Generator, + randomization_type: str, +) -> None: + """Randomize a single view in-place within new_mods/new_uns. + + *view* is a public name, so the omics modalities are looked up through the + accessor map; varm, obsm and uns keys are not omics and stay verbatim. + """ + import anndata + + modality = backing_modality(view, new_mods) + if modality is not None and modality != "response": + adata = new_mods[modality] + x = _randomize_matrix(np.asarray(to_dense(adata.X), dtype=np.float32), rng, randomization_type) + new_mods[modality] = anndata.AnnData(X=x, obs=adata.obs.copy(), var=adata.var.copy()) + elif "response" in new_mods and view in (new_mods["response"].varm or {}): + resp = new_mods["response"] + varm_data = np.asarray(resp.varm[view], dtype=np.float32) + resp.varm[view] = _randomize_matrix(varm_data, rng, randomization_type) + elif "response" in new_mods and view in (new_mods["response"].obsm or {}): + resp = new_mods["response"] + obsm_data = np.asarray(resp.obsm[view], dtype=np.float32) + resp.obsm[view] = _randomize_matrix(obsm_data, rng, randomization_type) + elif view in new_uns: + _randomize_uns_view(new_uns[view], view, new_uns, rng, randomization_type) + else: + logger.warning("View '%s' not found in any storage location. Skipping randomization.", view) + + +class RandomizationMixin: + """Mixin that provides view randomization for Dataset. + + Expects ``self._mdata`` to be a MuData object and ``self._name`` to be the dataset name. + """ + + _mdata: md.MuData + _name: str + + def with_randomized_views( + self, + views: list[str], + randomization_type: str = "permutation", + random_state: int | None = None, + *, + randomization: tuple[str, str] | None = None, + ) -> Dataset: + """Return a copy of this Dataset with specified views randomized. + + For cell-line views (modalities or obsm keys), rows are permuted across + cell lines. For drug views (varm keys), rows are permuted across drugs. + For uns dict keys, values are reassigned to shuffled keys. + + Args: + views: View names to randomize. + randomization_type: "permutation" shuffles rows; "invariant" replaces + each row with a random sample matching its mean and std. + random_state: Seed for reproducibility. + randomization: Optional (mode, view) tuple to attach to the new dataset. + + Returns: + A new Dataset with the specified views randomized. + + Raises: + ValueError: If randomization_type is not recognized. + KeyError: If a view is not found in any storage location. + """ + import mudata as md + + from drevalpy.types.data.dataset import Dataset as DatasetCls + + if randomization_type not in ("permutation", "invariant"): + raise ValueError(f"Unknown randomization_type {randomization_type!r}. Use 'permutation' or 'invariant'.") + + rng = np.random.default_rng(random_state) + + new_mods: dict[str, md.AnnData] = {} + for mod_name, mod_adata in self._mdata.mod.items(): + new_mods[mod_name] = mod_adata.copy() + + new_uns: dict[str, Any] = { + key: copy.deepcopy(val) if isinstance(val, dict) else val for key, val in self._mdata.uns.items() + } + + for view in views: + _randomize_single_view(self, view, new_mods, new_uns, rng, randomization_type) + + md.set_options(pull_on_update=False) + new_mdata = md.MuData(new_mods) + new_mdata.obs = self._mdata.obs.copy() + for key, val in new_uns.items(): + new_mdata.uns[key] = val + return DatasetCls(new_mdata, name=self._name, randomization=randomization) diff --git a/drevalpy/types/data/dataset_utils/sampling.py b/drevalpy/types/data/dataset_utils/sampling.py new file mode 100644 index 000000000..d329236bb --- /dev/null +++ b/drevalpy/types/data/dataset_utils/sampling.py @@ -0,0 +1,27 @@ +"""Hyperparameter sampling utility using Optuna.""" + +from __future__ import annotations + + +def _sample_hp_configs(featurizer_cls: type, n: int) -> list[dict]: + """Sample N hyperparameter configs from a featurizer's HP space using Optuna. + + Respects declared distributions (log-uniform, integer, categorical, etc.). + """ + import optuna + + from drevalpy.models.tuning.search_space import sample_from_optuna_trial + + optuna.logging.set_verbosity(optuna.logging.WARNING) + space = featurizer_cls.get_hyperparameter_space() + if not space: + return [{}] * n + + study = optuna.create_study() + configs: list[dict] = [] + for _ in range(n): + trial = study.ask() + config = sample_from_optuna_trial(trial, space) + study.tell(trial, 0.0) + configs.append(config) + return configs diff --git a/drevalpy/types/data/feature_source.py b/drevalpy/types/data/feature_source.py new file mode 100644 index 000000000..71a76c15b --- /dev/null +++ b/drevalpy/types/data/feature_source.py @@ -0,0 +1,111 @@ +"""FeatureSource ABC and Dataset adapter classes. + +``FeatureSource`` is the abstract base class for feature access consumed by +featurizers. It provides the shared Dataset-backed logic (init, identifiers, +mdata, get_metadata) so concrete adapters only implement entity-specific +dispatch methods. + +``CellLineFeatureSource`` and ``DrugFeatureSource`` are the concrete adapters. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +import numpy as np + +from drevalpy.types.data.dataset import Dataset + + +class FeatureSource(ABC): + """Abstract base class for feature access consumed by featurizers. + + Provides shared Dataset-backed initialisation and common accessors. + Subclasses only need to implement ``get_view_matrix``, + ``get_feature_names``, and ``get_entity_view``. + """ + + def __init__(self, dataset: Dataset, entity_ids: np.ndarray) -> None: + """Wrap a Dataset for entity feature access. + + Args: + dataset: The backing dataset. + entity_ids: Entity IDs this source exposes. + """ + self._dataset = dataset + self._ids = np.asarray(entity_ids, dtype=str) + + @property + def identifiers(self) -> np.ndarray: + """All available entity IDs.""" + return self._ids + + @property + def mdata(self) -> Any: + """Underlying MuData object.""" + return self._dataset.mdata + + def get_metadata(self, key: str) -> Any: + """Return arbitrary metadata from the underlying Dataset.""" + return self._dataset.get_uns(key) + + @abstractmethod + def get_view_matrix(self, view: str, entity_ids: np.ndarray) -> np.ndarray: + """Return (len(ids), n_features) float array for a dense numeric view.""" + ... + + @abstractmethod + def get_feature_names(self, view: str) -> tuple[str, ...] | None: + """Return ordered feature/column names for a view, or None.""" + ... + + @abstractmethod + def get_entity_view(self, entity_id: str, view: str) -> Any: + """Return the raw per-entity object for non-numeric views (graphs, etc.).""" + ... + + +class CellLineFeatureSource(FeatureSource): + """Adapts Dataset for cell-line featurizers.""" + + def get_view_matrix(self, view: str, entity_ids: np.ndarray) -> np.ndarray: + """Return feature matrix for the given cell lines and view.""" + return self._dataset.get_cell_line_features(view, entity_ids) + + def get_feature_names(self, view: str) -> tuple[str, ...] | None: + """Return feature names for a cell-line view.""" + return self._dataset.get_cell_line_feature_names(view) + + def get_entity_view(self, entity_id: str, view: str) -> Any: + """Return a per-entity value for a single cell line. + + For metadata keys like "tissue", delegates to Dataset.get_tissue(). + For omics modalities, returns the feature vector from that modality. + """ + if view == "tissue": + return self._dataset.get_tissue(np.array([entity_id]))[0] + return self._dataset.get_cell_line_features(view, np.array([entity_id]))[0] + + +class DrugFeatureSource(FeatureSource): + """Adapts Dataset for drug featurizers.""" + + def get_view_matrix(self, view: str, entity_ids: np.ndarray) -> np.ndarray: + """Return feature matrix for the given drugs and view.""" + return self._dataset.get_drug_features(view, entity_ids) + + def get_feature_names(self, view: str) -> tuple[str, ...] | None: + """Return feature names for a drug view.""" + return self._dataset.get_drug_feature_names(view) + + def get_entity_view(self, entity_id: str, view: str) -> Any: + """Return a per-entity value for a single drug. + + For graph views (stored in mdata.uns["drug_graphs"]), returns the graph + dict. For other views (varm-backed embeddings), returns the feature vector. + """ + if view == "drug_graph": + graphs = self._dataset.get_drug_graphs(np.array([entity_id])) + return graphs[0] if graphs else None + return self._dataset.get_drug_features(view, np.array([entity_id]))[0] diff --git a/drevalpy/types/data/modalities.py b/drevalpy/types/data/modalities.py new file mode 100644 index 000000000..46b505eac --- /dev/null +++ b/drevalpy/types/data/modalities.py @@ -0,0 +1,97 @@ +"""Single source of truth for the MuData accessors backing each omics view. + +The public omics names used in configs, recipes and predictor code are stable. The +modality keys actually present in the published ``.h5mu`` files are not necessarily +identical to them, so every omics access in the package resolves through +:func:`backing_modality` rather than hard-coding a modality string. + +Resolution prefers the name as written and falls back to :data:`OMICS_ACCESSORS`, +so a single code path reads both dataset generations: files storing the physical +name and files already carrying the public one. +""" + +from __future__ import annotations + +from collections.abc import Container, Mapping +from types import MappingProxyType +from typing import Final + +#: Maps the stable public omics name to the modality key stored in the MuData. +#: +#: ``copy_number_variation_gistic`` is the name the library, the zoo presets and user +#: recipes all use, but the published datasets store the modality as +#: ``copy_number_variation``. Adding the ``_gistic`` suffix to the datasets is the +#: planned long-term fix. Because :func:`backing_modality` prefers whichever name the +#: file actually has, datasets can be renamed one at a time without touching any code; +#: this entry can then be reduced to an identity mapping, or dropped, at leisure. +OMICS_ACCESSORS: Final[Mapping[str, str]] = MappingProxyType( + { + "gene_expression": "gene_expression", + "methylation": "methylation", + "mutations": "mutations", + "proteomics": "proteomics", + "copy_number_variation_gistic": "copy_number_variation", + } +) + + +#: Inverse of :data:`OMICS_ACCESSORS`, derived rather than maintained by hand so the +#: dict above stays the only thing to edit. The mapping is injective, so this is exact. +_PUBLIC_NAMES: Final[Mapping[str, str]] = MappingProxyType( + {accessor: public for public, accessor in OMICS_ACCESSORS.items()} +) + + +def resolve_omics_accessor(name: str) -> str: + """Translate a public omics name into the modality key to read from the MuData. + + Names that are not registered omics views are returned unchanged, so custom + matrices and non-omics views keep working. + + Args: + name: Public omics name, e.g. ``"copy_number_variation_gistic"``. + + Returns: + The modality key to look up in the MuData. + """ + return OMICS_ACCESSORS.get(name, name) + + +def public_omics_name(accessor: str) -> str: + """Translate a stored modality key back into the name users write. + + The inverse of :func:`resolve_omics_accessor`, for messages and listings: a + dataset physically holding ``copy_number_variation`` is reported as offering + ``copy_number_variation_gistic``, which is the name a config may ask for. + + Args: + accessor: Modality key as stored in the MuData. + + Returns: + The public omics name, or *accessor* unchanged if it is not an omics view. + """ + return _PUBLIC_NAMES.get(accessor, accessor) + + +def backing_modality(name: str, available: Container[str]) -> str | None: + """Pick the modality key in *available* that backs the public omics *name*. + + The name as written wins, and :data:`OMICS_ACCESSORS` is only a fallback. That + ordering is what lets one code path serve both dataset generations: files that + store ``copy_number_variation`` are reached through the map, and files that + already carry the suffixed name are reached directly. So the eventual dataset + rename needs no code change at all, not even to the map. + + Args: + name: Public omics name, e.g. ``"copy_number_variation_gistic"``. + available: Modality keys present in the MuData. + + Returns: + The key to read, or ``None`` when nothing in *available* backs *name*. + """ + if name in available: + return name + accessor = OMICS_ACCESSORS.get(name) + if accessor is not None and accessor in available: + return accessor + return None diff --git a/drevalpy/types/data/mudatalike.py b/drevalpy/types/data/mudatalike.py new file mode 100644 index 000000000..d7446c1de --- /dev/null +++ b/drevalpy/types/data/mudatalike.py @@ -0,0 +1,46 @@ +"""Protocol for Dataset-compatible objects. + +This is used to simplify testing, so tests don't have to implement all features of the Dataset class. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +import numpy as np + + +@runtime_checkable +class MuDataLike(Protocol): + """Minimal interface expected from a Dataset-compatible object. + + This allows splitters and other components to work with the real Dataset + or with any object satisfying the protocol for testing. + """ + + @property + def cell_line_ids(self) -> np.ndarray: + """1-D array of cell line identifiers (obs_names of the response modality).""" + ... + + @property + def drug_ids(self) -> np.ndarray: + """1-D array of drug identifiers (var_names of the response modality).""" + ... + + @property + def response_matrix(self) -> np.ndarray: + """2-D float array (n_cell_lines x n_drugs). NaN where no measurement.""" + ... + + def get_tissue(self, ids: np.ndarray) -> np.ndarray: + """Return tissue labels for the given cell line IDs.""" + ... + + def response_layer_names(self) -> list[str]: + """Names of the available response layers.""" + ... + + def get_response_layer(self, name: str) -> np.ndarray: + """2-D float array (n_cell_lines x n_drugs) for a named response layer.""" + ... diff --git a/drevalpy/types/data/split_mask.py b/drevalpy/types/data/split_mask.py new file mode 100644 index 000000000..256bbf32a --- /dev/null +++ b/drevalpy/types/data/split_mask.py @@ -0,0 +1,91 @@ +"""Single 2D boolean mask for train/predict operations.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + + +@dataclass(frozen=True) +class SplitMask: + """2D boolean mask defining which (cell_line, drug) pairs to operate on. + + The ``mask`` array has shape (n_cell_lines, n_drugs) with True at positions + that should be included. When ``_pair_seed`` is set, ``.pairs`` returns + the indices in a shuffled order (for robustness testing). + """ + + mask: np.ndarray + _pair_seed: int | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + """Ensure mask is stored as a boolean numpy array.""" + object.__setattr__(self, "mask", np.asarray(self.mask, dtype=bool)) + + @classmethod + def from_pairs(cls, pairs: np.ndarray, shape: tuple[int, int]) -> SplitMask: + """Construct from a (n_pairs, 2) index array and matrix shape.""" + mask = np.zeros(shape, dtype=bool) + if len(pairs) > 0: + mask[pairs[:, 0], pairs[:, 1]] = True + return cls(mask) + + def shuffled(self, seed: int) -> SplitMask: + """Return a new SplitMask whose .pairs are in shuffled order. + + :param seed: Random seed for reproducible pair ordering. + :returns: SplitMask with same content but shuffled .pairs output. + """ + return SplitMask(mask=self.mask, _pair_seed=seed) + + @property + def pairs(self) -> np.ndarray: + """Pair indices as (n_pairs, 2) array. + + Order is deterministic (row-major) unless a shuffle seed is set. + """ + p = np.argwhere(self.mask) + if self._pair_seed is not None: + rng = np.random.default_rng(self._pair_seed) + p = rng.permutation(p) + return p + + @property + def shape(self) -> tuple[int, int]: + """Shape of the underlying mask.""" + return self.mask.shape # type: ignore[return-value] + + def __len__(self) -> int: + """Number of True entries in the mask.""" + return int(self.mask.sum()) + + def __or__(self, other: SplitMask) -> SplitMask: + """Logical OR of two masks.""" + return SplitMask(self.mask | other.mask) + + def __and__(self, other: SplitMask) -> SplitMask: + """Logical AND of two masks.""" + return SplitMask(self.mask & other.mask) + + def __invert__(self) -> SplitMask: + """Logical NOT of the mask.""" + return SplitMask(~self.mask) + + def any(self) -> bool: + """Whether any entry is True.""" + return bool(self.mask.any()) + + def sum(self) -> int: + """Number of True entries.""" + return int(self.mask.sum()) + + def __eq__(self, other: object) -> bool: + """Equality based on mask contents.""" + if not isinstance(other, SplitMask): + return NotImplemented + return np.array_equal(self.mask, other.mask) + + def __hash__(self) -> int: + """Hash based on mask bytes.""" + return hash(self.mask.tobytes()) diff --git a/drevalpy/types/data/split_masks.py b/drevalpy/types/data/split_masks.py new file mode 100644 index 000000000..d637af6d9 --- /dev/null +++ b/drevalpy/types/data/split_masks.py @@ -0,0 +1,99 @@ +"""Unified split masks for cross-validation folds.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from upath import UPath as Path + +from .split_mask import SplitMask + + +@dataclass(frozen=True, slots=True) +class SplitMasks: + """Collection of train/test/val masks for a single cross-validation fold. + + Each field is a ``SplitMask`` with shape (n_cell_lines, n_drugs). + This format is uniform across all split modes (LPO, LCO, LDO, LTO). + """ + + train: SplitMask + test: SplitMask + val: SplitMask + metadata: dict[str, Any] = field(default_factory=dict, hash=False) + + @property + def shape(self) -> tuple[int, int]: + """Shape of the response matrix (n_cell_lines, n_drugs).""" + return self.train.shape + + @property + def train_val(self) -> SplitMask: + """Merged train | val mask for final retraining.""" + return self.train | self.val + + def early_stopping_mask(self, fraction: float = 0.25) -> tuple[SplitMask, SplitMask]: + """Split the val mask into early-stopping and remaining validation. + + :param fraction: Fraction of val pairs to reserve for early stopping. + :returns: Tuple of (early_stopping_mask, remaining_val_mask). + """ + val_pairs = self.val.pairs + n_val = len(val_pairs) + n_es = max(1, int(n_val * fraction)) + + es_arr = np.zeros(self.shape, dtype=bool) + es_arr[val_pairs[:n_es, 0], val_pairs[:n_es, 1]] = True + + remaining_arr = np.zeros(self.shape, dtype=bool) + remaining_arr[val_pairs[n_es:, 0], val_pairs[n_es:, 1]] = True + + return SplitMask(es_arr), SplitMask(remaining_arr) + + def save(self, path: str | Path) -> None: + """Save to a .npz file (compressed bool arrays + JSON-encoded metadata). + + :param path: Output file path (should end in .npz). + """ + arrays: dict[str, np.ndarray] = { + "train": self.train.mask, + "test": self.test.mask, + "val": self.val.mask, + } + if self.metadata: + arrays["_metadata"] = np.array(json.dumps(self.metadata)) + np.savez_compressed(Path(path), **arrays) + + @classmethod + def load(cls, path: str | Path) -> SplitMasks: + """Load from a .npz file. + + :param path: Path to a .npz file saved by ``save()``. + :returns: Reconstructed SplitMasks with metadata. + """ + data = np.load(Path(path), allow_pickle=False) + metadata = json.loads(str(data["_metadata"])) if "_metadata" in data else {} + return cls( + train=SplitMask(data["train"]), + test=SplitMask(data["test"]), + val=SplitMask(data["val"]), + metadata=metadata, + ) + + def __repr__(self) -> str: + """Formatted summary.""" + lines = [ + "SplitMasks", + f" Shape: {self.shape}", + f" Train: {len(self.train)} pairs", + f" Test: {len(self.test)} pairs", + f" Val: {len(self.val)} pairs", + ] + if self.metadata: + lines.append(" Metadata:") + for k, v in self.metadata.items(): + lines.append(f" {k}: {v}") + return "\n".join(lines) diff --git a/drevalpy/types/data/tensor_data.py b/drevalpy/types/data/tensor_data.py new file mode 100644 index 000000000..29f3d528f --- /dev/null +++ b/drevalpy/types/data/tensor_data.py @@ -0,0 +1,89 @@ +"""Shared DataLoader factory with lazy pair-level index lookup. + +``torch`` is imported inside the two entry points. Six predictor modules import +``make_pair_loader``, and all six are imported by ``drevalpy.registry`` when it +registers builtins, so a module-scope ``import torch`` would put ~0.35s on the +startup path of every CLI invocation. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import numpy as np + import torch + from torch.utils.data import DataLoader + + +class IndexedPairDataset: + """Looks up entity-level features by pair index on each access. + + Instead of materializing a full pair-level feature matrix upfront, this + dataset stores compact entity-level matrices and performs the lookup + per-sample in ``__getitem__``. + + Deliberately not a ``torch.utils.data.Dataset`` subclass: that base class + contributes only ``__add__``, which nothing here uses, and inheriting from it + would require ``torch`` at class-definition time. ``DataLoader`` accepts any + object with ``__getitem__`` and ``__len__`` as a map-style dataset. + """ + + def __init__( + self, + *feature_specs: tuple[np.ndarray, np.ndarray], + response: np.ndarray | None = None, + ) -> None: + """Initialize with entity matrices and corresponding pair indices. + + :param feature_specs: Each positional arg is a tuple of + ``(entity_matrix, pair_index_array)`` where entity_matrix has shape + ``[n_entities, d]`` and pair_index_array has shape ``[n_pairs]``. + :param response: Optional pair-level response array of shape ``[n_pairs]``. + """ + import torch + + self._matrices = [torch.as_tensor(m, dtype=torch.float32) for m, _ in feature_specs] + self._indices = [torch.as_tensor(i, dtype=torch.long) for _, i in feature_specs] + self._response = torch.as_tensor(response, dtype=torch.float32) if response is not None else None + self._n_pairs = int(self._indices[0].shape[0]) if self._indices else 0 + + def __len__(self) -> int: + """Return number of pairs. + + :returns: Dataset length. + """ + return self._n_pairs + + def __getitem__(self, idx: int) -> tuple[torch.Tensor, ...]: + """Look up features for pair *idx*. + + :param idx: Pair index. + :returns: Tuple of feature tensors, optionally followed by the response scalar. + """ + feats = tuple(m[i[idx]] for m, i in zip(self._matrices, self._indices, strict=True)) + if self._response is not None: + return (*feats, self._response[idx]) + return feats + + +def make_pair_loader( + *feature_specs: tuple[np.ndarray, np.ndarray], + response: np.ndarray | None = None, + batch_size: int, + shuffle: bool = True, + drop_last: bool = False, +) -> DataLoader: + """Create a DataLoader that lazily indexes entity features per mini-batch. + + :param feature_specs: Each positional arg is ``(entity_matrix, pair_indices)``. + :param response: Optional pair-level response vector. + :param batch_size: Mini-batch size. + :param shuffle: Whether to shuffle each epoch. + :param drop_last: Whether to drop the last incomplete batch. + :returns: A DataLoader yielding tuples of tensors. + """ + from torch.utils.data import DataLoader + + ds = IndexedPairDataset(*feature_specs, response=response) + return DataLoader(ds, batch_size=batch_size, shuffle=shuffle, drop_last=drop_last) diff --git a/drevalpy/types/enums/__init__.py b/drevalpy/types/enums/__init__.py new file mode 100644 index 000000000..141fe295a --- /dev/null +++ b/drevalpy/types/enums/__init__.py @@ -0,0 +1,7 @@ +"""Enum-like types and structured references.""" + +from .literature_reference import LiteratureReference +from .model_scope import ModelScope +from .prediction_mode import PredictionMode + +__all__ = ["LiteratureReference", "ModelScope", "PredictionMode"] diff --git a/drevalpy/types/enums/literature_reference.py b/drevalpy/types/enums/literature_reference.py new file mode 100644 index 000000000..b7c59118e --- /dev/null +++ b/drevalpy/types/enums/literature_reference.py @@ -0,0 +1,22 @@ +"""Structured literature citation metadata for registered components.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LiteratureReference: + """Repository, citation, and integration-deviation notes for a literature port.""" + + repo_url: str + citation_text: str = "" + citation_doi: str = "" + deviations: str = "" + + def __post_init__(self) -> None: + """Normalize surrounding whitespace in every field.""" + object.__setattr__(self, "repo_url", self.repo_url.strip()) + object.__setattr__(self, "citation_text", self.citation_text.strip()) + object.__setattr__(self, "citation_doi", self.citation_doi.strip()) + object.__setattr__(self, "deviations", self.deviations.strip()) diff --git a/drevalpy/types/enums/model_scope.py b/drevalpy/types/enums/model_scope.py new file mode 100644 index 000000000..e9cf650ce --- /dev/null +++ b/drevalpy/types/enums/model_scope.py @@ -0,0 +1,12 @@ +"""Training scope for global vs single-drug models.""" + +from __future__ import annotations + +from enum import StrEnum + + +class ModelScope(StrEnum): + """Whether one model is fitted globally or independently per drug.""" + + MULTI_DRUG = "multi_drug" + SINGLE_DRUG = "single_drug" diff --git a/drevalpy/types/enums/prediction_mode.py b/drevalpy/types/enums/prediction_mode.py new file mode 100644 index 000000000..7c6b412d3 --- /dev/null +++ b/drevalpy/types/enums/prediction_mode.py @@ -0,0 +1,12 @@ +"""Prediction task mode for composed models and predictors.""" + +from __future__ import annotations + +from enum import StrEnum + + +class PredictionMode(StrEnum): + """Whether the model predicts a continuous response or a discrete class.""" + + REGRESSION = "regression" + CLASSIFICATION = "classification" diff --git a/drevalpy/types/results/__init__.py b/drevalpy/types/results/__init__.py new file mode 100644 index 000000000..ab6fa4178 --- /dev/null +++ b/drevalpy/types/results/__init__.py @@ -0,0 +1,8 @@ +"""Result types: RunResult, TrialResult, ModelResult, ExperimentResult.""" + +from .experiment import ExperimentResult +from .model import ModelResult +from .run import RunResult +from .trial import TrialResult + +__all__ = ["ExperimentResult", "ModelResult", "RunResult", "TrialResult"] diff --git a/drevalpy/types/results/experiment.py b/drevalpy/types/results/experiment.py new file mode 100644 index 000000000..bb617e320 --- /dev/null +++ b/drevalpy/types/results/experiment.py @@ -0,0 +1,294 @@ +"""Experiment-level result: aggregates ModelResults across models.""" + +from __future__ import annotations + +import json +import sys +from collections import defaultdict +from typing import TYPE_CHECKING, Any + +import numpy as np +from upath import UPath as Path + +from drevalpy.evaluation import AVAILABLE_METRICS, _compute_metric_value +from drevalpy.log import get_logger +from drevalpy.types.results.model import ModelResult +from drevalpy.types.results.run import RunResult +from drevalpy.types.results.trial import TrialResult + +if TYPE_CHECKING: + from drevalpy.visualization.requirements import PlotRequirement + +logger = get_logger(__name__) + + +class ExperimentResult: + """Groups all model results for a complete experiment.""" + + def __init__(self, run_results: list[RunResult]) -> None: + """Create an ExperimentResult from a flat list of RunResults. + + :param run_results: Non-empty list of RunResult objects sharing the same dataset. + :raises ValueError: If the list is empty or contains inconsistent metadata. + """ + if not run_results: + raise ValueError("run_results must not be empty") + + dataset_names = {r.dataset_name for r in run_results} + if len(dataset_names) > 1: + raise ValueError(f"All RunResults must share the same dataset_name, got: {dataset_names}") + + split_modes = {r.split_mode for r in run_results if r.split_mode} + if len(split_modes) > 1: + raise ValueError(f"All RunResults must share the same split_mode, got: {split_modes}") + + self.dataset_name: str = dataset_names.pop() + self.split_mode: str = split_modes.pop() if split_modes else "" + self.normalized_by: str | None = None + + grouped: dict[str, list[RunResult]] = defaultdict(list) + for r in run_results: + grouped[r.model_name].append(r) + + self.models: list[ModelResult] = [ + ModelResult(model_name=name, dataset_name=self.dataset_name, runs=runs) for name, runs in grouped.items() + ] + + @property + def model_names(self) -> list[str]: + """Names of all models in this experiment.""" + return [m.model_name for m in self.models] + + @property + def has_randomization(self) -> bool: + """Whether any run has randomization data.""" + return any(r.randomization is not None for m in self.models for r in m.runs) + + @property + def has_robustness(self) -> bool: + """Whether any run has robustness trial metadata.""" + return any("robustness_trial" in r.fold_metadata for m in self.models for r in m.runs) + + @property + def n_models(self) -> int: + """Number of distinct models.""" + return len(self.models) + + @property + def max_folds(self) -> int: + """Maximum number of folds across models.""" + return max((m.n_folds for m in self.models), default=0) + + def satisfies(self, requirements: frozenset[PlotRequirement]) -> bool: + """Check if this experiment has data needed for a set of plot requirements. + + :param requirements: Set of requirements to check. + :returns: True if all requirements are satisfied. + """ + from drevalpy.visualization.requirements import PlotRequirement + + for req in requirements: + if req == PlotRequirement.MULTIPLE_MODELS and self.n_models < 2: + return False + if req == PlotRequirement.MULTIPLE_FOLDS and self.max_folds < 2: + return False + if req == PlotRequirement.RANDOMIZATION and not self.has_randomization: + return False + if req == PlotRequirement.ROBUSTNESS and not self.has_robustness: + return False + return True + + @property + def summary_table(self) -> dict[str, dict[str, float]]: + """Mean metric per model: {model_name: {metric: mean}}. + + :returns: Nested dict suitable for DataFrame construction. + """ + return {m.model_name: {k: v["mean"] for k, v in m.aggregate_metrics.items()} for m in self.models} + + def normalize(self, reference_model: str = "NaiveMeanEffectsPredictor") -> ExperimentResult: + """Return a new ExperimentResult with metrics normalized against a reference model. + + :param reference_model: Name of the model to normalize against. + :returns: A new ExperimentResult containing only non-reference runs with recomputed metrics. + :raises ValueError: If already normalized or reference model not found. + """ + if self.normalized_by is not None: + raise ValueError(f"Already normalized by {self.normalized_by!r}") + + ref_runs_by_fold: dict[str, RunResult] = {} + other_runs: list[RunResult] = [] + + for model in self.models: + if model.model_name == reference_model: + for run in model.runs: + ref_runs_by_fold[run.fold_id] = run + else: + other_runs.extend(model.runs) + + if not ref_runs_by_fold: + raise ValueError(f"Reference model {reference_model!r} not found. Available: {self.model_names}") + + normalized_runs: list[RunResult] = [] + for run in other_runs: + if run.fold_id not in ref_runs_by_fold: + raise ValueError(f"No reference run found for fold_id {run.fold_id!r}") + ref_run = ref_runs_by_fold[run.fold_id] + normalized_runs.append(_normalize_run(run, ref_run)) + + result = ExperimentResult(normalized_runs) + result.normalized_by = reference_model + return result + + def save(self, directory: str | Path) -> None: + """Save to a directory tree. + + :param directory: Root output directory. + """ + out = Path(directory) + out.mkdir(parents=True, exist_ok=True) + + meta: dict[str, Any] = { + "dataset_name": self.dataset_name, + "split_mode": self.split_mode, + "normalized_by": self.normalized_by, + "models": self.model_names, + } + (out / "metadata.json").write_text(json.dumps(meta, indent=2)) + + for model_result in self.models: + model_result.save(out / model_result.model_name) + + @classmethod + def load(cls, directory: str | Path, *, with_trials: bool = True) -> ExperimentResult: + """Load from a directory tree saved by ``save()``. + + :param directory: Root experiment directory. + :param with_trials: Forwarded to :meth:`ModelResult.load`; pass ``False`` to skip + reading the HPO trial predictions, which no visualization consumes. + :returns: Reconstructed ExperimentResult. + """ + path = Path(directory) + meta = json.loads((path / "metadata.json").read_text()) + + model_results = [ModelResult.load(path / name, with_trials=with_trials) for name in meta["models"]] + + all_runs: list[RunResult] = [] + for mr in model_results: + for r in mr.runs: + if not r.split_mode: + r.split_mode = meta.get("split_mode", "") + all_runs.append(r) + + experiment = cls(all_runs) + experiment.normalized_by = meta.get("normalized_by") + _log_load_summary(experiment, all_runs, with_trials=with_trials) + return experiment + + def __repr__(self) -> str: + """Formatted summary.""" + lines = [ + "ExperimentResult", + f" Dataset: {self.dataset_name}", + f" Split mode: {self.split_mode}", + f" Normalized by: {self.normalized_by}", + f" Models: {len(self.models)}", + ] + for m in self.models: + agg = m.aggregate_metrics + metric_str = ", ".join(f"{k}={v['mean']:.4f}" for k, v in agg.items()) if agg else "no metrics" + lines.append(f" {m.model_name} ({m.n_folds} folds): {metric_str}") + return "\n".join(lines) + + +def _run_array_bytes(run: RunResult) -> int: + """Approximate the heap cost of one run's arrays. + + Object-dtype id arrays only report their pointer table via ``nbytes``, so the shared + strings behind them are added once each - which is the whole point of interning them. + + :param run: Run to measure. + :returns: Approximate number of bytes retained by the run's arrays. + """ + total = run.predictions.nbytes + run.ground_truth.nbytes + for ids in (run.cell_line_ids, run.drug_ids): + total += ids.nbytes + if ids.dtype == object: + total += sum(sys.getsizeof(value) for value in set(ids.tolist())) + if run.trials: + total += sum(trial.predictions.nbytes for trial in run.trials) + return total + + +def _log_load_summary(experiment: ExperimentResult, runs: list[RunResult], *, with_trials: bool) -> None: + """Emit the single line that establishes the scale of a loaded experiment.""" + rows = sum(len(r.predictions) for r in runs) + total_bytes = sum(_run_array_bytes(r) for r in runs) + logger.info( + "Loaded ExperimentResult %r: %d models, %d runs, %d prediction rows, %.2f GB of arrays (trials %s)", + experiment.dataset_name, + experiment.n_models, + len(runs), + rows, + total_bytes / 1024**3, + "loaded" if with_trials else "skipped", + ) + + +def _normalize_run(run: RunResult, ref_run: RunResult) -> RunResult: + """Normalize a single RunResult against a reference RunResult.""" + import pandas as pd + + ref_index = pd.MultiIndex.from_arrays( + [np.asarray(ref_run.cell_line_ids, dtype=object), np.asarray(ref_run.drug_ids, dtype=object)] + ) + ref_predictions = np.asarray(ref_run.predictions) + if ref_index.has_duplicates: + # The dict lookup this replaced let the last occurrence of a pair win. + keep = ~ref_index.duplicated(keep="last") + ref_index = ref_index[keep] + ref_predictions = ref_predictions[keep] + positions = ref_index.get_indexer( + pd.MultiIndex.from_arrays([np.asarray(run.cell_line_ids, dtype=object), np.asarray(run.drug_ids, dtype=object)]) + ) + # get_indexer yields -1 for pairs the reference never predicted; those normalize + # against 0.0, matching the dict-lookup default this replaced. + ref_preds = np.where(positions >= 0, ref_predictions[positions.clip(min=0)], 0.0) + + norm_gt = run.ground_truth - ref_preds + norm_pred = run.predictions - ref_preds + + valid = ~np.isnan(norm_pred) & ~np.isnan(norm_gt) + metrics: dict[str, float] = {} + if valid.any(): + for metric_name in AVAILABLE_METRICS: + metrics[metric_name] = _compute_metric_value(metric_name, norm_pred[valid], norm_gt[valid]) + + normalized_trials = None + if run.trials: + normalized_trials = [ + TrialResult( + hyperparameters=trial.hyperparameters, + metrics=trial.metrics, + optimization_metric=trial.optimization_metric, + predictions=trial.predictions, + ) + for trial in run.trials + ] + + return RunResult( + model_name=run.model_name, + dataset_name=run.dataset_name, + split_mode=run.split_mode, + fold_index=run.fold_index, + fold_id=run.fold_id, + predictions=norm_pred, + ground_truth=norm_gt, + cell_line_ids=run.cell_line_ids, + drug_ids=run.drug_ids, + best_hyperparameters=run.best_hyperparameters, + metrics=metrics, + fold_metadata=run.fold_metadata, + trials=normalized_trials, + randomization=run.randomization, + ) diff --git a/drevalpy/types/results/model.py b/drevalpy/types/results/model.py new file mode 100644 index 000000000..bdce76709 --- /dev/null +++ b/drevalpy/types/results/model.py @@ -0,0 +1,97 @@ +"""Model-level result: aggregates RunResults across folds.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from upath import UPath as Path + +from drevalpy.types.results.run import RunResult + + +@dataclass +class ModelResult: + """Groups all fold results for a single model on a single dataset.""" + + model_name: str + dataset_name: str + runs: list[RunResult] = field(default_factory=list) + + @property + def n_folds(self) -> int: + """Number of folds (runs) in this result.""" + return len(self.runs) + + @property + def aggregate_metrics(self) -> dict[str, dict[str, float]]: + """Mean and std of each metric across folds. + + :returns: Mapping of metric_name -> {"mean": ..., "std": ...}. + """ + if not self.runs: + return {} + all_metrics: dict[str, list[float]] = {} + for run in self.runs: + for key, value in run.metrics.items(): + all_metrics.setdefault(key, []).append(value) + return { + key: {"mean": float(np.mean(values)), "std": float(np.std(values))} for key, values in all_metrics.items() + } + + def save(self, directory: str | Path) -> None: + """Save to a directory with metadata.json and one .npz per fold. + + :param directory: Output directory path. + """ + out = Path(directory) + out.mkdir(parents=True, exist_ok=True) + + meta: dict[str, Any] = { + "model_name": self.model_name, + "dataset_name": self.dataset_name, + "n_folds": self.n_folds, + "aggregate_metrics": self.aggregate_metrics, + } + (out / "metadata.json").write_text(json.dumps(meta, indent=2)) + + for i, run in enumerate(self.runs): + run.save(str(out / f"fold_{i}.npz")) + + @classmethod + def load(cls, directory: str | Path, *, with_trials: bool = True) -> ModelResult: + """Load from a directory saved by ``save()``. + + :param directory: Path to the model result directory. + :param with_trials: Forwarded to :meth:`RunResult.load`; pass ``False`` to skip + reading the HPO trial predictions. + :returns: Reconstructed ModelResult. + """ + path = Path(directory) + meta = json.loads((path / "metadata.json").read_text()) + + fold_files = sorted(path.glob("fold_*.npz")) + runs = [RunResult.load(str(f), with_trials=with_trials) for f in fold_files] + + return cls( + model_name=meta["model_name"], + dataset_name=meta["dataset_name"], + runs=runs, + ) + + def __repr__(self) -> str: + """Formatted summary.""" + lines = [ + "ModelResult", + f" Model: {self.model_name}", + f" Dataset: {self.dataset_name}", + f" Folds: {self.n_folds}", + ] + agg = self.aggregate_metrics + if agg: + lines.append(" Metrics (mean +/- std):") + for key, stats in agg.items(): + lines.append(f" {key}: {stats['mean']:.4f} +/- {stats['std']:.4f}") + return "\n".join(lines) diff --git a/drevalpy/types/results/run.py b/drevalpy/types/results/run.py new file mode 100644 index 000000000..0fe592ac9 --- /dev/null +++ b/drevalpy/types/results/run.py @@ -0,0 +1,193 @@ +"""Run result dataclass.""" + +from __future__ import annotations + +import json +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from upath import UPath as Path + +from drevalpy.log import get_logger + +from .trial import TrialResult + +logger = get_logger(__name__) + + +def _indented_items( + items: Mapping[str, Any], + format_value: Callable[[Any], str] = str, +) -> list[str]: + """Render *items* as the ``__repr__``'s inner-level ``key: value`` lines. + + :param items: Mapping to render, in iteration order. + :param format_value: Applied to each value; defaults to ``str``. + :returns: One line per entry, indented to the nested level. + """ + return [f" {key}: {format_value(value)}" for key, value in items.items()] + + +def _section( + heading: str, + items: Mapping[str, Any], + format_value: Callable[[Any], str] = str, +) -> list[str]: + """Render a headed block of ``key: value`` lines, or nothing when *items* is empty. + + :param heading: Section heading, already indented. + :param items: Mapping to render under the heading. + :param format_value: Applied to each value; defaults to ``str``. + :returns: The heading followed by its entries, or an empty list. + """ + if not items: + return [] + return [heading, *_indented_items(items, format_value)] + + +@dataclass +class RunResult: + """Output of a single Run.""" + + model_name: str + dataset_name: str + fold_index: int + predictions: np.ndarray + ground_truth: np.ndarray + cell_line_ids: np.ndarray + drug_ids: np.ndarray + split_mode: str = "" + fold_id: str = "" + best_hyperparameters: dict[str, Any] = field(default_factory=dict) + metrics: dict[str, float] = field(default_factory=dict) + fold_metadata: dict[str, Any] = field(default_factory=dict) + trials: list[TrialResult] | None = None + randomization: tuple[str, str] | None = None + + def __repr__(self) -> str: + """Formatted summary.""" + extra_fold_metadata = {k: v for k, v in self.fold_metadata.items() if k != "fold_index"} + lines = [ + "RunResult", + f" Model: {self.model_name}", + f" Dataset: {self.dataset_name}", + f" Randomization: {self._randomization_summary()}", + f" Fold: {self.fold_index}", + *_indented_items(extra_fold_metadata), + f" Predictions: {len(self.predictions)} pairs", + f" Ground truth: {int(np.sum(~np.isnan(self.ground_truth)))} non-NaN values", + *_section(" Hyperparameters:", self.best_hyperparameters), + *_section(" Metrics:", self.metrics, format_value="{:.4f}".format), + ] + if self.trials: + lines.append(f" HPO Trials: {len(self.trials)}") + return "\n".join(lines) + + def _randomization_summary(self) -> str: + """Describe the randomization this run was produced under. + + :returns: ``" ()"``, or ``"None"`` for an unrandomized run. + """ + if not self.randomization: + return "None" + return f"{self.randomization[0]} ({self.randomization[1]})" + + def save(self, path: str | Path) -> None: + """Save to a compressed .npz file. + + :param path: Output file path (should end in .npz). + """ + arrays: dict[str, np.ndarray] = { + "predictions": self.predictions, + "ground_truth": self.ground_truth, + "cell_line_ids": np.asarray(self.cell_line_ids, dtype=str), + "drug_ids": np.asarray(self.drug_ids, dtype=str), + } + trials_meta = None + if self.trials: + trials_meta = [] + for i, t in enumerate(self.trials): + arrays[f"trial_{i}_predictions"] = t.predictions + trials_meta.append( + { + "hyperparameters": t.hyperparameters, + "metrics": t.metrics, + "optimization_metric": t.optimization_metric, + } + ) + meta = { + "model_name": self.model_name, + "dataset_name": self.dataset_name, + "split_mode": self.split_mode, + "fold_index": self.fold_index, + "fold_id": self.fold_id, + "best_hyperparameters": self.best_hyperparameters, + "metrics": self.metrics, + "fold_metadata": self.fold_metadata, + "randomization": list(self.randomization) if self.randomization else None, + "trials": trials_meta, + } + arrays["_metadata"] = np.array(json.dumps(meta)) + np.savez_compressed(Path(path), **arrays) + + @classmethod + def load(cls, path: str | Path, *, with_trials: bool = True) -> RunResult: + """Load from a .npz file saved by ``save()``. + + :param path: Path to the .npz file. + :param with_trials: Whether to read the ``trial_*_predictions`` arrays. These are + typically an order of magnitude larger than the fold's own predictions and no + visualization reads them, so the report path opts out. ``np.load`` is lazy, so + skipping them means they are never read off disk. + :returns: Reconstructed RunResult. + """ + logger.debug("Loading run %s (with_trials=%s)", path, with_trials) + data = np.load(Path(path), allow_pickle=False) + meta = json.loads(str(data["_metadata"])) + trials = None + if with_trials and meta.get("trials"): + trials = [ + TrialResult( + hyperparameters=t["hyperparameters"], + metrics=t["metrics"], + optimization_metric=t["optimization_metric"], + predictions=np.asarray(data[f"trial_{i}_predictions"]), + ) + for i, t in enumerate(meta["trials"]) + ] + return cls( + model_name=meta["model_name"], + dataset_name=meta["dataset_name"], + split_mode=meta.get("split_mode", ""), + fold_index=meta["fold_index"], + fold_id=meta.get("fold_id", ""), + predictions=np.asarray(data["predictions"]), + ground_truth=np.asarray(data["ground_truth"]), + cell_line_ids=intern_ids(data["cell_line_ids"]), + drug_ids=intern_ids(data["drug_ids"]), + best_hyperparameters=meta.get("best_hyperparameters", {}), + metrics=meta.get("metrics", {}), + fold_metadata=meta.get("fold_metadata", {}), + trials=trials, + randomization=tuple(meta["randomization"]) if meta.get("randomization") else None, + ) + + +def intern_ids(ids: np.ndarray) -> np.ndarray: + """Re-express a fixed-width unicode id array as an object array of shared strings. + + NumPy stores `` float: + """The score for the optimization metric.""" + return self.metrics.get(self.optimization_metric, float("nan")) + + def __repr__(self) -> str: + """Formatted summary.""" + lines = ["TrialResult", " Hyperparameters:"] + for k, v in self.hyperparameters.items(): + lines.append(f" {k}: {v}") + lines.append(" Metrics:") + for k, v in self.metrics.items(): + marker = " *" if k == self.optimization_metric else "" + lines.append(f" {k}: {v:.4f}{marker}") + return "\n".join(lines) diff --git a/drevalpy/utils.py b/drevalpy/utils.py deleted file mode 100644 index ba3b20a14..000000000 --- a/drevalpy/utils.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Utility functions for the evaluation pipeline.""" - -from pathlib import Path - -from sklearn.base import TransformerMixin -from sklearn.preprocessing import MinMaxScaler, RobustScaler, StandardScaler - -from .datasets import AVAILABLE_DATASETS -from .datasets.dataset import DrugResponseDataset -from .datasets.loader import load_dataset -from .datasets.splits import validate_split_label -from .datasets.utils import ALLOWED_MEASURES -from .evaluation import AVAILABLE_METRICS -from .experiment import drug_response_experiment, pipeline_function -from .models import MODEL_FACTORY - - -def check_arguments(args) -> None: - """ - Check the validity of the arguments for the evaluation pipeline. - - :param args: arguments passed from the command line - :raises AssertionError: if any of the arguments is invalid - :raises ValueError: if the number of cross-validation splits or curve_curator_cores is less than 1 - :raises FileNotFoundError: if a custom dataset name was specified and the input file could not be found. - """ - if not args.models: - raise AssertionError("At least one model must be specified") - if not all(model in MODEL_FACTORY for model in args.models): - raise AssertionError( - f"Invalid model name. Available models are {list(MODEL_FACTORY.keys())}. If you want to " - f"use your own model, you need to implement a new model class and add it to the " - f"MODEL_FACTORY in the models init" - ) - if not all(test in ["LPO", "LCO", "LDO", "LTO"] for test in args.test_mode): - raise AssertionError("Invalid test mode. Available test modes are LPO, LCO, LDO, LTO") - - if args.baselines is not None: - if not all(baseline in MODEL_FACTORY for baseline in args.baselines): - raise AssertionError( - f"Invalid baseline name. Available baselines are {list(MODEL_FACTORY.keys())}. If you " - f"want to use your own baseline, you need to implement a new model class and add it to " - f"the MODEL_FACTORY in the models init" - ) - if args.dataset_name not in AVAILABLE_DATASETS: - if not args.no_refitting: - expected_custom_input = Path(args.path_data).absolute() / args.dataset_name / f"{args.dataset_name}_raw.csv" - if not expected_custom_input.is_file(): - raise FileNotFoundError( - "You specified the curve_curator option with a custom dataset name which requires raw " - f"viability data to be located at {expected_custom_input} but the file does not exist. " - "Please check the 'path_data' and 'dataset_name' arguments and ensure the raw viability " - "input file is located at //_raw.csv." - ) - else: - expected_custom_input = Path(args.path_data).absolute() / args.dataset_name / f"{args.dataset_name}.csv" - if not expected_custom_input.is_file(): - raise FileNotFoundError( - "You specified a custom dataset name which requires prefit curve data to be located at " - f"{expected_custom_input} but the file does not exist. Please check the 'path_data' and " - "'dataset_name' arguments and ensure the prefit curve data is located at input file is " - "located at //.csv." - ) - - if (not args.no_refitting) and args.curve_curator_cores < 1: - raise ValueError("Number of cores for CurveCurator must be greater than 0.") - - for dataset in args.cross_study_datasets: - if dataset not in AVAILABLE_DATASETS: - raise AssertionError( - f"Invalid dataset name in cross_study_datasets. Available datasets are " - f"{list(AVAILABLE_DATASETS.keys())} If you want to use your own dataset, you " - f"need to implement a new response dataset loader and add it to the " - f"AVAILABLE_DATASETS in the response_datasets init." - ) - - # if the path to args.path_data does not exist, create the directory - Path(args.path_data).mkdir(parents=True, exist_ok=True) - - if args.n_cv_splits <= 1 and not getattr(args, "custom_splitter_path", None): - raise ValueError("Number of cross-validation splits must be greater than 1.") - - custom_splitter_path = getattr(args, "custom_splitter_path", None) - if custom_splitter_path: - if not Path(custom_splitter_path).expanduser().is_file(): - raise FileNotFoundError(f"Custom split script not found: {custom_splitter_path}") - - custom_split_name = getattr(args, "custom_split_name", None) - if custom_split_name is not None: - validate_split_label(custom_split_name) - - # TODO Allow for custom randomization tests maybe via config file - if args.randomization_mode[0] != "None": - if not all(randomization in ["SVCC", "SVRC", "SVCD", "SVRD"] for randomization in args.randomization_mode): - raise AssertionError( - "At least one invalid randomization mode. Available randomization modes are SVCC, SVRC, SVCD, SVRD." - ) - - if args.randomization_type not in ["permutation", "invariant"]: - raise AssertionError("Invalid randomization type. Choose from 'permutation' or 'invariant'") - - if args.n_trials_robustness < 0: - raise ValueError("Number of trials for robustness test must be greater than or equal to 0") - - if args.measure not in ALLOWED_MEASURES: - raise ValueError( - "Only 'LN_IC50', 'EC50', 'IC50', 'pEC50', 'AUC', 'response' or their equivalents including " - "the '_curvecurator' suffix are allowed drug response measures." - ) - - if args.response_transformation not in ["None", "standard", "minmax", "robust"]: - raise AssertionError("Invalid response_transformation. Choose from None, standard, minmax, robust") - - if args.optim_metric not in AVAILABLE_METRICS: - raise AssertionError( - f"Invalid optim_metric for hyperparameter tuning. Choose from" f" {list(AVAILABLE_METRICS.keys())}" - ) - - -def main(args) -> None: - """ - Main function to run the drug response evaluation pipeline. - - :param args: passed from command line - """ - check_arguments(args) - response_data, cross_study_datasets = get_datasets( - dataset_name=args.dataset_name, - cross_study_datasets=args.cross_study_datasets, - path_data=args.path_data, - measure=args.measure, - curve_curator=(not args.no_refitting), - cores=args.curve_curator_cores, - normalize=getattr(args, "curve_curator_normalize", False), - ) - - models = [MODEL_FACTORY[model] for model in args.models] - - if args.baselines is not None: - baselines = [MODEL_FACTORY[baseline] for baseline in args.baselines] - else: - baselines = [] - - if args.randomization_mode[0] == "None": - args.randomization_mode = None - response_transformation = get_response_transformation(args.response_transformation) - - for test_mode in args.test_mode: - drug_response_experiment( - models=models, - baselines=baselines, - response_data=response_data, - response_transformation=response_transformation, - hpam_optimization_metric=args.optim_metric, - n_cv_splits=args.n_cv_splits, - multiprocessing=args.multiprocessing, - test_mode=test_mode, - randomization_mode=args.randomization_mode, - randomization_type=args.randomization_type, - n_trials_robustness=args.n_trials_robustness, - cross_study_datasets=cross_study_datasets, - path_out=args.path_out, - run_id=args.run_id, - overwrite=args.overwrite, - path_data=args.path_data, - model_checkpoint_dir=args.model_checkpoint_dir, - hyperparameter_tuning=not args.no_hyperparameter_tuning, - final_model_on_full_data=args.final_model_on_full_data, - wandb_project=args.wandb_project, - custom_splitter=getattr(args, "custom_splitter_path", None), - custom_split_name=getattr(args, "custom_split_name", None), - ) - - -def get_datasets( - dataset_name: str, - cross_study_datasets: list, - path_data: str = "data", - measure: str = "response", - curve_curator: bool = False, - cores: int = 1, - normalize: bool = False, -) -> tuple[DrugResponseDataset, list[DrugResponseDataset] | None]: - """ - Load the response data and cross-study datasets. - - :param dataset_name: The name of the dataset to load. Can be one of ('GDSC1', 'GDSC2', 'CCLE', CTRPv1', - 'CTRPv2', 'TOYv1', 'TOYv2') - to download provided datasets, or any other name to use a custom datasets. - :param cross_study_datasets: list of cross-study datasets. CurveCurator is not applicable to these. If you wish - to provide custom cross_study_datasets, you have to invoke curve fitting manually using - drevalpy.datasets.curvecurator.fit_curves - :param path_data: The parent path in which custom or downloaded datasets should be located, or in which raw - viability data is to be found for fitting with CurveCurator (see param curve_curator for details). - The location of the datasets are resolved by //.csv. - :param measure: The name of the column containing the measure to predict, default = "response". - If curve_curator is True, this measure is appended with "_curvecurator", e.g. "response_curvecurator" to - distinguish between measures provided by the original source of a dataset, or the measures fit by - CurveCurator. - :param curve_curator: If True, the measure is appended with "_curvecurator". - If a custom dataset_name was provided, this will invoke the fitting procedure of raw viability data, - which is expected to exist at //_raw.csv. The fitted dataset will - be stored in the same folder, in a file called .csv - :param cores: Number of cores to use for CurveCurator fitting. Only used when curve_curator is True, default = 1 - :param normalize: Whether to normalize the response values to [0, 1] for curvecurator. Default = False. - Only used for custom datasets when curve_curator is True. - :returns: response data and, potentially, cross-study datasets - """ - response_data = load_dataset( - dataset_name=dataset_name, - path_data=path_data, - measure=measure, - curve_curator=curve_curator, - cores=cores, - normalize=normalize, - ) - - cross_study_datasets = [ - load_dataset(dataset_name=dn, path_data=path_data, measure=measure) for dn in cross_study_datasets - ] - return response_data, cross_study_datasets - - -@pipeline_function -def get_response_transformation(response_transformation: str | None) -> TransformerMixin | None: - """ - Get the skelarn response transformation object of choice. - - Users can choose from "None", "standard", "minmax", "robust". - - :param response_transformation: response transformation to apply - :returns: response transformation object - :raises ValueError: if the response transformation is not recognized - """ - if (response_transformation == "None") or (response_transformation is None): - return None - if response_transformation == "standard": - return StandardScaler() - if response_transformation == "minmax": - return MinMaxScaler() - if response_transformation == "robust": - return RobustScaler() - raise ValueError( - f"Unknown response transformation {response_transformation}. Choose from 'None', " - f"'standard', 'minmax', 'robust'" - ) diff --git a/drevalpy/utils/__init__.py b/drevalpy/utils/__init__.py new file mode 100644 index 000000000..dbf025d5f --- /dev/null +++ b/drevalpy/utils/__init__.py @@ -0,0 +1,10 @@ +"""Utility helpers: response transforms and decorators.""" + +from __future__ import annotations + +from .response_transform import fit_response_transformation, get_response_transformation + +__all__ = [ + "fit_response_transformation", + "get_response_transformation", +] diff --git a/drevalpy/utils/response_transform.py b/drevalpy/utils/response_transform.py new file mode 100644 index 000000000..c5b226d4d --- /dev/null +++ b/drevalpy/utils/response_transform.py @@ -0,0 +1,81 @@ +"""Sklearn response-value transformations for the evaluation pipeline. + +``sklearn`` is imported inside the two functions rather than at module scope: +``drevalpy.models.drp_model`` reaches this module on the registration path of +``import drevalpy``, and importing ``sklearn.base`` alone costs ~0.35s because it +pulls in ``scipy.stats``. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from sklearn.base import TransformerMixin + + from drevalpy.types.data.mudatalike import MuDataLike + from drevalpy.types.data.split_mask import SplitMask + + +def get_response_transformation( + response_transformation: str | None, +) -> TransformerMixin | None: + """Return the sklearn response transformer for a pipeline option. + + :param response_transformation: One of ``"None"``, ``"standard"``, ``"minmax"``, or ``"robust"``. + + :returns: Fitted-ready sklearn transformer, or ``None`` for no transformation. + + :raises ValueError: If *response_transformation* is not recognized. + + :param response_transformation: response transformation. + :returns: Result of the operation. + """ + from sklearn.preprocessing import MinMaxScaler, RobustScaler, StandardScaler + + if (response_transformation == "None") or (response_transformation is None): + return None + if response_transformation == "standard": + return StandardScaler() + if response_transformation == "minmax": + return MinMaxScaler() + if response_transformation == "robust": + return RobustScaler() + raise ValueError( + f"Unknown response transformation {response_transformation}. Choose from 'None', 'standard', 'minmax', 'robust'" + ) + + +def fit_response_transformation( + prototype: TransformerMixin | None, + mudataset: MuDataLike, + scope: SplitMask, +) -> TransformerMixin | None: + """Fit a clone of *prototype* on the raw responses inside *scope*. + + This is the only place a response transformer is fitted. Every consumer + downstream receives the already-fitted instance (or ``None``) and only calls + ``transform`` / ``inverse_transform``. Restricting the fit to a single scope - + normally the training scope - is what keeps held-out responses out of the + scaler's statistics, and cloning leaves the caller's prototype unfitted so it + can be reused across folds. + + :param prototype: Unfitted transformer to clone, or ``None`` for no transformation. + :param mudataset: Source of the raw response matrix. + :param scope: Split mask selecting the pairs to fit on. + + :returns: A transformer fitted on the scope's non-NaN responses, or ``None`` when + *prototype* is ``None``. + """ + if prototype is None: + return None + + from sklearn.base import clone + + fitted = clone(prototype) + pairs = scope.pairs + responses = mudataset.response_matrix[pairs[:, 0], pairs[:, 1]] + fitted.fit(responses[~np.isnan(responses)].reshape(-1, 1)) + return fitted diff --git a/drevalpy/utils/seed.py b/drevalpy/utils/seed.py new file mode 100644 index 000000000..607803fe5 --- /dev/null +++ b/drevalpy/utils/seed.py @@ -0,0 +1,23 @@ +"""Random seed setup for experiment runs.""" + +import os +import random + +import numpy as np + + +def seed_everything(seed: int = 42) -> None: + """Seed python ``random``, numpy, torch (CPU + CUDA), and ``PYTHONHASHSEED``. + + :param seed: Random seed applied to all supported RNG backends. + + Call once at the top of a run. + """ + import torch + + os.environ["PYTHONHASHSEED"] = str(seed) + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) diff --git a/drevalpy/utils/torch_io.py b/drevalpy/utils/torch_io.py new file mode 100644 index 000000000..85c90d773 --- /dev/null +++ b/drevalpy/utils/torch_io.py @@ -0,0 +1,131 @@ +"""Trusted PyTorch serialization boundary for drevalpy. + +``torch`` is imported inside the two functions that call it. Predictor modules +reach this module while ``drevalpy.registry`` registers builtins, so a +module-scope ``import torch`` would put ~0.33s on the startup path of every CLI +invocation. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +import io +from typing import Any, BinaryIO + +from upath import UPath as Path + +TorchSource = bytes | bytearray | Path | str | BinaryIO + + +def _coerce_source(source: TorchSource) -> Path | BinaryIO: + if isinstance(source, (bytes, bytearray)): + return io.BytesIO(source) + if isinstance(source, str): + return Path(source) + return source + + +def load_torch_payload( + source: TorchSource, + *, + map_location: Any | None = None, + weights_only: bool = True, +) -> Any: + """Load a PyTorch-serialized payload from a trusted local source. + + :param source: Bytes, path, or open binary stream containing a torch checkpoint. + :param map_location: Optional device remapping passed through to ``torch.load``. + :param weights_only: When ``True``, restrict deserialization to tensor payloads. + :returns: Deserialized checkpoint object. + """ + import torch + + coerced = _coerce_source(source) + kwargs: dict[str, Any] = {"weights_only": weights_only} + if map_location is not None: + kwargs["map_location"] = map_location + return torch.load(coerced, **kwargs) # noqa: S614 + + +def save_torch_payload(payload: Any, destination: Path | str | BinaryIO) -> None: + """Serialize ``payload`` with ``torch.save`` to a path or buffer. + + :param payload: Object to serialize. + :param destination: Output path or binary stream. + """ + import torch + + torch.save(payload, destination) + + +def load_state_dict( + source: TorchSource, + *, + map_location: Any | None = None, +) -> dict[str, Any]: + """Load a PyTorch state dict mapping from a trusted local source. + + :param source: Bytes, path, or open binary stream containing a state dict. + :param map_location: Optional device remapping passed through to ``torch.load``. + :returns: Mapping of parameter names to tensors. + :raises TypeError: If the deserialized payload is not a mapping. + """ + data = load_torch_payload(source, map_location=map_location, weights_only=True) + if not isinstance(data, dict): + msg = "torch payload must be a state dict mapping" + raise TypeError(msg) + return data + + +def load_trusted_payload( + source: TorchSource, + *, + map_location: Any | None = None, +) -> Any: + """Load a trusted checkpoint that may contain arbitrary pickled Python objects. + + :param source: Bytes, path, or open binary stream containing a trusted checkpoint. + :param map_location: Optional device remapping passed through to ``torch.load``. + :returns: Deserialized checkpoint object. + """ + return load_torch_payload(source, map_location=map_location, weights_only=False) + + +def load_trusted_mapping( + source: TorchSource, + *, + map_location: Any | None = None, +) -> dict[str, Any]: + """Load a trusted mapping previously written with ``save_trusted_mapping``. + + :param source: Bytes, path, or open binary stream containing a mapping checkpoint. + :param map_location: Optional device remapping passed through to ``torch.load``. + :returns: Deserialized mapping object. + :raises TypeError: If the deserialized payload is not a mapping. + """ + data = load_trusted_payload(source, map_location=map_location) + if not isinstance(data, dict): + msg = "torch payload must be a mapping" + raise TypeError(msg) + return data + + +def save_state_dict(state_dict: dict[str, Any]) -> bytes: + """Serialize a PyTorch state dict to bytes. + + :param state_dict: Mapping of parameter names to tensors. + :returns: Serialized checkpoint bytes. + """ + buffer = io.BytesIO() + save_torch_payload(state_dict, buffer) + return buffer.getvalue() + + +def save_trusted_mapping(payload: dict[str, Any]) -> bytes: + """Serialize an arbitrary mapping with ``torch.save``. + + :param payload: Mapping to serialize. + :returns: Serialized checkpoint bytes. + """ + buffer = io.BytesIO() + save_torch_payload(payload, buffer) + return buffer.getvalue() diff --git a/drevalpy/visualization/__init__.py b/drevalpy/visualization/__init__.py index 47776029d..13142fc51 100644 --- a/drevalpy/visualization/__init__.py +++ b/drevalpy/visualization/__init__.py @@ -1,19 +1,17 @@ -"""Module containing the drevalpy plotly visualizations.""" +"""Visualization registry, base classes, and plot implementations.""" __all__ = [ - "ComparisonScatter", - "CriticalDifferencePlot", - "Heatmap", - "RegressionSliderPlot", - "VioHeat", - "Violin", - "CrossStudyTables", + "ImageVisualization", + "PlotRequirement", + "Section", + "Visualization", + "create_report", + "save_all_png", + "visualization_registry", ] -from .comp_scatter import ComparisonScatter -from .critical_difference_plot import CriticalDifferencePlot -from .cross_study_tables import CrossStudyTables -from .heatmap import Heatmap -from .regression_slider_plot import RegressionSliderPlot -from .vioheat import VioHeat -from .violin import Violin +from drevalpy.registry.visualization import visualization_registry + +from .base import ImageVisualization, Section, Visualization +from .report import create_report, save_all_png +from .requirements import PlotRequirement diff --git a/drevalpy/visualization/_metric_names.py b/drevalpy/visualization/_metric_names.py new file mode 100644 index 000000000..d7d443be5 --- /dev/null +++ b/drevalpy/visualization/_metric_names.py @@ -0,0 +1,66 @@ +"""Metric-name resolution shared by the visualizations. + +:meth:`drevalpy.types.results.experiment.ExperimentResult.normalize` recomputes +every metric in :data:`drevalpy.evaluation.AVAILABLE_METRICS` on the residuals +against the reference model and stores them under their **plain** names; the +container records the reference model in ``normalized_by``. That is the contract +the plots are written against. + +Results serialized by older versions of drevalpy instead merged an +un-normalized and a normalized metric table into one row, suffixing the +normalized copy with ``": normalized"``. A plot must therefore ask for a metric +by its plain name and let this module decide which key is actually present, +rather than hard-coding either spelling - hard-coding the suffixed one produced +an all-NaN column on the normalized path, which is what crashed the leaderboard. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable + + from drevalpy.types.results import ExperimentResult, ModelResult + +#: Suffix older drevalpy releases appended to the normalized copy of a metric. +NORMALIZED_SUFFIX = ": normalized" + + +def metric_keys(result: ExperimentResult | ModelResult) -> set[str]: + """Collect every metric name any run of *result* reports. + + :param result: Experiment or model result to inspect. + :returns: Union of the ``metrics`` keys across all runs. + """ + models = getattr(result, "models", None) + runs = [run for model in models for run in model.runs] if models is not None else list(result.runs) + return {name for run in runs for name in run.metrics} + + +def resolve_metric_key(available: Iterable[str], base: str) -> str | None: + """Find the key holding *base* among the metric names actually present. + + The plain name wins when both spellings exist: on a normalized experiment it + already holds the normalized values, and on an un-normalized one it is the + only honest choice. + + :param available: Metric names present in the result. + :param base: Plain metric name, for example ``"Pearson"``. + :returns: The matching key, or ``None`` when the metric is absent. + """ + names = set(available) + if base in names: + return base + suffixed = f"{base}{NORMALIZED_SUFFIX}" + return suffixed if suffixed in names else None + + +def holds_normalized_values(result: ExperimentResult | ModelResult, key: str) -> bool: + """Whether *key* of *result* holds values normalized against a reference model. + + :param result: Result the metric was read from. + :param key: Metric key as returned by :func:`resolve_metric_key`. + :returns: True if the values are normalized. + """ + return key.endswith(NORMALIZED_SUFFIX) or getattr(result, "normalized_by", None) is not None diff --git a/drevalpy/visualization/_progress.py b/drevalpy/visualization/_progress.py new file mode 100644 index 000000000..9eeae45ac --- /dev/null +++ b/drevalpy/visualization/_progress.py @@ -0,0 +1,169 @@ +"""Stdlib-only memory probes and a one-line stage logger. + +Report generation has historically died with a bare ``exit 137`` and no output, +so these helpers exist to make the last line before a SIGKILL name both the +stage and the memory headroom that was left. They deliberately avoid ``psutil``: +it is only a transitive ``wandb`` dependency and is not guaranteed to be present +in a report-only install. + +Every filesystem path is a parameter so the readers can be pointed at fixtures +in tests rather than at the host's real ``/proc`` and cgroup trees. +""" + +from __future__ import annotations + +import logging +import resource +import sys + +from upath import UPath + +PROC_STATUS = "/proc/self/status" +CGROUP_V2_LIMIT = "/sys/fs/cgroup/memory.max" +CGROUP_V1_LIMIT = "/sys/fs/cgroup/memory/memory.limit_in_bytes" + +_BYTES_PER_GB = 1024**3 +_KIB_PER_GB = 1024**2 + +#: Above this the cgroup value is a sentinel for "unlimited" rather than a cap. +#: Unbounded cgroups report a value near 2**63, and some report ``PAGE_COUNTER_MAX``. +_UNLIMITED_GB = 1024.0 * 1024.0 + + +def _rusage_max_rss_gb() -> float: + """Peak RSS from :func:`resource.getrusage`, normalised across platforms. + + ``ru_maxrss`` is KiB on Linux but bytes on macOS. + + :returns: Peak resident set size of this process in GB. + """ + raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + divisor = _BYTES_PER_GB if sys.platform == "darwin" else _KIB_PER_GB + return raw / divisor + + +def _read_status_kib(field: str, status_path: str) -> float | None: + """Read one ``VmXxx`` field, in kB, out of a ``/proc//status`` file. + + :param field: Field name without the colon, e.g. ``"VmRSS"``. + :param status_path: Path to the status file to parse. + :returns: The value in kB, or ``None`` if the file or field is unavailable. + """ + prefix = f"{field}:" + try: + text = UPath(status_path).read_text() + except OSError: + return None + for line in text.splitlines(): + if line.startswith(prefix): + parts = line.split() + if len(parts) >= 2: + try: + return float(parts[1]) + except ValueError: + return None + return None + + +def rss_gb(status_path: str = PROC_STATUS) -> float: + """Current resident set size of this process. + + :param status_path: Path to a ``/proc//status``-formatted file. + :returns: Current RSS in GB, falling back to peak RSS where ``/proc`` is absent. + """ + kib = _read_status_kib("VmRSS", status_path) + if kib is None: + return _rusage_max_rss_gb() + return kib / _KIB_PER_GB + + +def peak_rss_gb(status_path: str = PROC_STATUS) -> float: + """High-water mark of this process's resident set size. + + :param status_path: Path to a ``/proc//status``-formatted file. + :returns: Peak RSS in GB. + """ + kib = _read_status_kib("VmHWM", status_path) + if kib is None: + return _rusage_max_rss_gb() + return kib / _KIB_PER_GB + + +def memory_limit_gb( + v2_path: str = CGROUP_V2_LIMIT, + v1_path: str = CGROUP_V1_LIMIT, +) -> float | None: + """Container memory cap as seen from inside a cgroup. + + Inside an AWS Batch container this reports the real allocation, which is what + makes the headroom figure in :func:`log_stage` meaningful. Outside one it is + expected to be unavailable. + + :param v2_path: cgroup v2 ``memory.max`` path. + :param v1_path: cgroup v1 ``memory.limit_in_bytes`` path, tried second. + :returns: The cap in GB, or ``None`` when unreadable, unparseable or unlimited. + """ + for path in (v2_path, v1_path): + try: + raw = UPath(path).read_text().strip() + except OSError: + continue + if not raw or raw == "max": + continue + try: + limit = int(raw) / _BYTES_PER_GB + except ValueError: + continue + if limit >= _UNLIMITED_GB: + continue + return limit + return None + + +def format_stage( + stage: str, + *, + status_path: str = PROC_STATUS, + v2_path: str = CGROUP_V2_LIMIT, + v1_path: str = CGROUP_V1_LIMIT, +) -> str: + """Build the one-line memory summary for a stage. + + :param stage: Human-readable name of the stage being entered or left. + :param status_path: Path to a ``/proc//status``-formatted file. + :param v2_path: cgroup v2 ``memory.max`` path. + :param v1_path: cgroup v1 ``memory.limit_in_bytes`` path. + :returns: A line such as ``"load | rss=1.42 GB peak=1.51 GB limit=36.00 GB (4%)"``. + """ + rss = rss_gb(status_path) + peak = peak_rss_gb(status_path) + limit = memory_limit_gb(v2_path, v1_path) + line = f"{stage} | rss={rss:.2f} GB peak={peak:.2f} GB" + if limit is not None and limit > 0: + line += f" limit={limit:.2f} GB ({peak / limit:.0%})" + return line + + +def log_stage( + logger: logging.Logger, + stage: str, + *, + level: int = logging.INFO, + status_path: str = PROC_STATUS, + v2_path: str = CGROUP_V2_LIMIT, + v1_path: str = CGROUP_V1_LIMIT, +) -> None: + """Emit :func:`format_stage` through ``logger``. + + :param logger: Logger to emit through. + :param stage: Human-readable name of the stage being entered or left. + :param level: Logging level for the emitted record. + :param status_path: Path to a ``/proc//status``-formatted file. + :param v2_path: cgroup v2 ``memory.max`` path. + :param v1_path: cgroup v1 ``memory.limit_in_bytes`` path. + """ + logger.log( + level, + "%s", + format_stage(stage, status_path=status_path, v2_path=v2_path, v1_path=v1_path), + ) diff --git a/drevalpy/visualization/base.py b/drevalpy/visualization/base.py new file mode 100644 index 000000000..f6dad36be --- /dev/null +++ b/drevalpy/visualization/base.py @@ -0,0 +1,152 @@ +"""Visualization base class and Section dataclass.""" + +from __future__ import annotations + +import base64 +from abc import ABC, abstractmethod +from dataclasses import dataclass +from io import BytesIO +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + import matplotlib.figure + import plotly.graph_objects as go + from upath import UPath + + from drevalpy.types.data.dataset import Dataset + from drevalpy.types.results import ExperimentResult, ModelResult + + +@dataclass +class Section: + """A single report section to be added to the MultiQC report.""" + + name: str + anchor: str + description: str = "" + plot: Any = None + content: str | None = None + + +def embedded_png_html(figure: matplotlib.figure.Figure) -> str: + """Render *figure* as a self-contained ```` tag for a report Section. + + :param figure: Figure to rasterize. + :returns: An ```` element carrying the PNG as a base64 data URI. + """ + buffer = BytesIO() + figure.savefig(buffer, format="png", dpi=150, bbox_inches="tight") + payload = base64.b64encode(buffer.getvalue()).decode() + return f'' + + +def require_figure(figure: Any, caller: str) -> Any: + """Return *figure*, rejecting the not-yet-computed state. + + :param figure: The visualization's figure, ``None`` before ``compute()``. + :param caller: Name of the method being guarded, for the error message. + :returns: The figure. + :raises RuntimeError: If *figure* is ``None``. + """ + if figure is None: + msg = f"Call compute() before {caller}()" + raise RuntimeError(msg) + return figure + + +class Visualization(ABC): + """Base class for all visualizations producing MultiQC report sections.""" + + registry_name: str = "" + + @abstractmethod + def compute(self, result: ExperimentResult | ModelResult, dataset: Dataset | None = None) -> None: + """Compute the visualization data from the result (store internally). + + :param result: An ExperimentResult or ModelResult to visualize. + :param dataset: Optional dataset for looking up drug/cell-line metadata. + """ + ... + + @abstractmethod + def to_png(self, path: str | UPath) -> None: + """Render to a static PNG file. + + :param path: File path for the output PNG. + """ + ... + + @abstractmethod + def to_multiqc(self) -> list[Section]: + """Return MultiQC Section objects for report integration. + + Implementations may use native MultiQC plot objects (Path A) + or embed a base64-encoded image via Section.content (Path B). + """ + ... + + @abstractmethod + def show(self) -> None: + """Display interactively in a Jupyter notebook.""" + ... + + +class ImageVisualization(Visualization): + """Base for plots that render as a static image (no native MultiQC type). + + Subclasses implement only compute() and _create_figure(). The base class + handles to_png(), to_multiqc(), and show() automatically. + """ + + _fig: matplotlib.figure.Figure | None = None + + @abstractmethod + def _create_figure(self) -> matplotlib.figure.Figure: + """Create and return the matplotlib Figure.""" + ... + + def to_png(self, path: str | UPath) -> None: + """Save the figure to a PNG file. + + :param path: Output file path. + """ + require_figure(self._fig, "to_png").savefig(str(path), dpi=150, bbox_inches="tight") + + def to_multiqc(self) -> list[Section]: + """Embed figure as a base64-encoded PNG in a report Section.""" + figure = require_figure(self._fig, "to_multiqc") + return [ + Section( + name=self.registry_name, + anchor=self.registry_name, + content=embedded_png_html(figure), + ) + ] + + def show(self) -> None: + """Display the figure in a Jupyter notebook.""" + figure = require_figure(self._fig, "show") + from IPython.display import display + + display(figure) + + +class PlotlyVisualization(Visualization): + """Base for plots whose ``compute()`` leaves a Plotly figure in ``_fig``. + + Subclasses implement compute() and to_multiqc(); rendering the figure to a + PNG or into a notebook is the same call for every one of them. + """ + + _fig: go.Figure | None = None + + def to_png(self, path: str | UPath) -> None: + """Render the figure to a static PNG. + + :param path: Output file path. + """ + require_figure(self._fig, "to_png").write_image(str(path)) + + def show(self) -> None: + """Display the figure in a Jupyter notebook.""" + require_figure(self._fig, "show").show() diff --git a/drevalpy/visualization/comp_scatter.py b/drevalpy/visualization/comp_scatter.py deleted file mode 100644 index 33118c1fa..000000000 --- a/drevalpy/visualization/comp_scatter.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Contains the code needed to draw the correlation comparison scatter plot.""" - -from io import TextIOWrapper - -import pandas as pd -import plotly.graph_objects as go - -from ..models import SINGLE_DRUG_MODEL_FACTORY -from .outplot import OutPlot - - -class ComparisonScatter(OutPlot): - """ - Class to draw scatter plots for comparison of correlation metrics between models. - - Produces two types of plots: an overall comparison plot and a dropdown plot for comparison between all models. - If one model is consistently better than the other, the points deviate from the identity line (higher if the - model is on the y-axis, lower if it is on the x-axis. - The dropdown plot allows to select two models for comparison of their per-drug/per-cell-line pearson correlation. - The overall plot facets all models and visualizes the density of the points. - """ - - def __init__( - self, - df: pd.DataFrame, - color_by: str, - test_mode: str, - metric: str = "R^2", - algorithm: str = "all", - ): - """ - Initialize the ComparisonScatter object. - - :param df: evaluation results per group, either drug or cell line - :param color_by: group variable, i.e., drug or cell line - :param test_mode: evaluation test_mode, e.g., LCO (leave-cell-line-out) - :param metric: correlation metric to be compared. Default is Pearson. - :param algorithm: used to distinguish between per-algorithm plots and per-test_mode plots (all models then). - """ - exclude_models = ( - {"NaiveDrugMeanPredictor"}.union({model for model in SINGLE_DRUG_MODEL_FACTORY.keys()}) - if color_by == "drug" - else {"NaiveCellLineMeanPredictor"} - ) - exclude_models.add("NaivePredictor") - exclude_models.add("NaiveMeanEffectsPredictor") - - self.df = df.sort_values("model") - self.name: str | None = None - if algorithm == "all": - # draw plots for comparison between all models - self.df = self.df[ - (self.df["test_mode"] == test_mode) - & (self.df["rand_setting"] == "predictions") - & (~self.df["algorithm"].isin(exclude_models)) - # and exclude all lines for which algorithm starts with any element from - # exclude_models - & (~self.df["algorithm"].str.startswith(tuple(exclude_models))) - ] - self.name = f"{color_by}_{test_mode}" - elif algorithm not in exclude_models: - # draw plots for comparison between all test settings of one model - self.df = self.df[(self.df["test_mode"] == test_mode) & (self.df["algorithm"] == algorithm)] - self.name = f"{color_by} {algorithm} {test_mode}" - if self.df.empty: - print(f"No data found for {self.name}. Skipping ...") - return - self.color_by = color_by - self.metric = metric - - self.df["test_mode"] = self.df["model"].str.split("_").str[0:3].str.join("_") - self.models = self.df["test_mode"].unique() - - self.dropdown_fig = go.Figure() - self.dropdown_buttons_x: list[dict] = list() - self.dropdown_buttons_y: list[dict] = list() - - def draw_and_save(self, out_prefix: str, out_suffix: str) -> None: - """ - Draws and saves the scatter plots. - - :param out_prefix: e.g., results/my_run/comp_scatter/ - :param out_suffix: should be self.name - :raises AssertionError: if out_suffix does not match self.name - """ - if self.df.empty: - return - self._draw() - if self.name != out_suffix: - raise AssertionError(f"Name mismatch: {self.name} != {out_suffix}") - path_out = f"{out_prefix}comp_scatter_{out_suffix}.html" - self.dropdown_fig.write_html(path_out) - - def _draw(self) -> None: - """Draws the scatter plots.""" - print("Drawing scatterplots ...") - self._generate_comp_scatterplots() - - self.dropdown_fig.update_layout( - title=f'{str(self.color_by).replace("_", " ").capitalize()}-wise scatter plot of {self.metric} ' - f"for each model", - showlegend=False, - ) - # Set dropdown menu - self.dropdown_fig.update_layout( - updatemenus=[ - { - "buttons": self.dropdown_buttons_x, - "direction": "down", - "showactive": True, - "x": 0.0, - "xanchor": "left", - "y": 1.5, - "yanchor": "top", - }, - { - "buttons": self.dropdown_buttons_y, - "direction": "down", - "showactive": True, - "x": 0.5, - "xanchor": "left", - "y": 1.5, - "yanchor": "top", - }, - ] - ) - self.dropdown_fig.update_xaxes(range=[-1, 1]) - self.dropdown_fig.update_yaxes(range=[-1, 1]) - - @staticmethod - def write_to_html(test_mode: str, f: TextIOWrapper, *args, **kwargs) -> TextIOWrapper: - """ - Inserts the generated files into the result HTML file. - - :param test_mode: test_mode, e.g., LCO - :param f: file to write to - :param args: unused - :param kwargs: used to get all files generated by create_report / the pipeline - :returns: the file f - """ - files: list[str] = kwargs.get("files", []) - f.write('

Comparison of normalized R^2 values

\n') - f.write( - "R^2 values can be compared here between models, either per cell line or per drug. " - "This can either show if a model has consistently higher or lower R^2 values than another model or " - "identify cell lines/drugs for which models agree or disagree.\n" - "The x-axis is the first dropdown menu, the y-axis is the second dropdown menu.\n" - ) - for group_by in ["drug_name", "cell_line_name"]: - plot_list = [f for f in files if f.startswith("comp_scatter") and f.endswith(f"{test_mode}.html")] - if f"comp_scatter_{group_by}_{test_mode}.html" in plot_list: - f.write(f'

{group_by.capitalize()}-wise comparison

\n') - f.write( - f'\n' - ) - f.write("

Comparisons per model

\n") - f.write("
    \n") - listed_files = [ - elem - for elem in plot_list - if ( - elem != f"comp_scatter_{group_by}_{test_mode}.html" - and elem != f"comp_scatter_overall_{group_by}_{test_mode}.html" - ) - ] - listed_files.sort() - for group_comparison in listed_files: - f.write( - f'
  • ' - f"{group_comparison}
  • \n" - ) - f.write("
\n") - return f - - def _generate_comp_scatterplots(self) -> None: - """Generates the scatter plots.""" - # render first scatterplot that is shown in the dropdown plot - first_df = self._subset_df(run_id=self.models[0]) - if self.color_by == "drug_name": - hover_variables = ["drug_name", "pubchem_id"] - else: - hover_variables = ["cell_line_name", "cellosaurus_id"] - scatterplot = go.Scatter( - x=first_df[self.metric], - y=first_df[self.metric], - mode="markers", - marker=dict(size=6, showscale=False), - customdata=first_df[hover_variables], - hovertemplate="
".join( - [ - f"{self.color_by.capitalize()}: %{{customdata[0]}}", - f"{hover_variables[1]}: %{{customdata[1]}}", - "x: %{{x:.2f}}", - "y: %{{y:.2f}}", - ] - ), - showlegend=True, - visible=True, - ) - self.dropdown_fig.add_trace(scatterplot) - - for run_idx in range(len(self.models)): - run = self.models[run_idx] - x_df = self._subset_df(run_id=run) - self.dropdown_buttons_x.append( - dict( - label=run, - method="update", - args=[ - {"x": [x_df[self.metric]]}, - {"xaxis": {"title": run, "range": [-1, 1]}}, - ], - ) - ) - for run2_idx in range(len(self.models)): - run2 = self.models[run2_idx] - y_df = self._subset_df(run_id=run2) - - # create dropdown buttons for y axis only in the first iteration - if run_idx == 0: - self.dropdown_buttons_y.append( - dict( - label=run2, - method="update", - args=[ - {"y": [y_df[self.metric]]}, - {"yaxis": {"title": run2, "range": [-1, 1]}}, - ], - ) - ) - - def _subset_df(self, run_id: str) -> pd.DataFrame: - """ - Subsets the dataframe for a given run_id to the relevant columns and sets the index to the color_by variable. - - :param run_id: user-defined ID of the whole run - :returns: subsetted dataframe - """ - subset_cols = [self.metric, self.color_by, "model"] - if self.color_by == "drug_name": - subset_cols.append("pubchem_id") - else: - subset_cols.append("cellosaurus_id") - s_df = self.df[self.df["test_mode"] == run_id][subset_cols] - # sort by color_by variable - s_df = s_df.sort_values(self.color_by) - s_df[self.metric] = s_df[self.metric].fillna(0) - return s_df diff --git a/drevalpy/visualization/create_leaderboard.py b/drevalpy/visualization/create_leaderboard.py deleted file mode 100644 index 4a0de6dac..000000000 --- a/drevalpy/visualization/create_leaderboard.py +++ /dev/null @@ -1,478 +0,0 @@ -#!/usr/bin/env python3 -""" -DrEvalPy Leaderboard Visualization. - -This script generates a leaderboard visualization (normalized PCC and RMSE) from -the evaluation results CSV file produced by the DrEvalPy evaluation pipeline. -Usage: -python create_leaderboard.py --results_path /path/to/results.csv -""" - -import argparse -from pathlib import Path -from typing import Optional - -import matplotlib.patches as mpatches -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -from matplotlib.patches import FancyBboxPatch - -# --- Theme Definitions --- -DARK_THEME = { - "background": "#0d1117", - "surface": "#2d2d2d", - "text": "#ece7e4", - "text_secondary": "#a0a0a0", - "grid": "#30363d", -} - -LIGHT_THEME = { - "background": "#ffffff", - "surface": "#f6f8fa", - "text": "#1f2328", - "text_secondary": "#57606a", - "grid": "#d0d7de", -} - -COLORS = DARK_THEME - -COMPETITOR_COLOR = "#6A5ACD" - - -def configure_matplotlib(font_adder: int = 0): - """ - Configure global matplotlib parameters for the current theme. - - :param font_adder: Increment to add to the base font size. - """ - plt.rcParams.update( - { - "figure.facecolor": COLORS["background"], - "axes.facecolor": COLORS["background"], - "axes.edgecolor": COLORS["grid"], - "axes.labelcolor": COLORS["text"], - "text.color": COLORS["text"], - "xtick.color": COLORS["text"], - "ytick.color": COLORS["text"], - "grid.color": COLORS["grid"], - "font.family": "sans-serif", - "font.size": 11 + font_adder, - "axes.spines.top": False, - "axes.spines.right": False, - } - ) - - -def load_results(results_path: str, test_mode: str = "LCO") -> pd.DataFrame: - """ - Load and aggregate results from the evaluation CSV. - - :param results_path: Path to evaluation_results.csv. - :param test_mode: Filtering mode (e.g., LCO). - :raises FileNotFoundError: If path does not exist. - :raises ValueError: If no data matches criteria. - :return: Processed DataFrame. - """ - path = Path(results_path) - if not path.exists(): - raise FileNotFoundError(f"Results file not found: {results_path}") - - df = pd.read_csv(path, index_col=0) - df = df[(df["rand_setting"] == "predictions") & (df["test_mode"] == test_mode)] - - if df.empty: - raise ValueError(f"No results found for rand_setting='predictions' and test_mode='{test_mode}'") - - df_agg = ( - df.groupby("algorithm") - .agg( - { - "Pearson: normalized": ["mean", "std"], - "RMSE": ["mean", "std"], - } - ) - .reset_index() - ) - - df_agg.columns = ["algorithm", "PCC", "PCC_std", "RMSE", "RMSE_std"] - df_agg["PCC_std"] = df_agg["PCC_std"].fillna(0) - df_agg["RMSE_std"] = df_agg["RMSE_std"].fillna(0) - df_agg["is_baseline"] = df_agg["algorithm"].str.startswith("Naive") - - return df_agg.sort_values("PCC", ascending=False).reset_index(drop=True) - - -def get_bar_color(rank: int, is_baseline: bool) -> dict: - """ - Assign colors based on model rank and type. - - :param rank: Model index in sorted list. - :param is_baseline: Boolean if model is a baseline. - :return: Styling dictionary. - """ - if is_baseline: - return {"color": "#5a5a5a", "alpha": 1.0} - - medal_gold = "#F4D03F" - medal_silver = "#BDC3C7" - medal_bronze = "#E67E22" - - if rank == 0: - return {"color": medal_gold, "alpha": 1.0} - elif rank == 1: - return {"color": medal_silver, "alpha": 1.0} - elif rank == 2: - return {"color": medal_bronze, "alpha": 1.0} - - return {"color": COMPETITOR_COLOR, "alpha": 0.85} - - -def draw_bar(ax, x: float, y: float, width: float, height: float, color: str, alpha: float = 1.0): - """ - Draw a custom rounded rectangle bar. - - :param ax: Matplotlib axis. - :param x: Origin X. - :param y: Origin Y. - :param width: Bar width. - :param height: Bar height. - :param color: Hex color. - :param alpha: Transparency. - :return: Patch artist. - """ - bar = FancyBboxPatch( - (x, y - height / 2), - width, - height, - boxstyle="round,pad=0.01,rounding_size=0.015", - facecolor=color, - alpha=alpha, - edgecolor="none", - zorder=3, - ) - ax.add_patch(bar) - return bar - - -def create_leaderboard( - df: pd.DataFrame, - output_path: str, - test_mode: str = "LCO", - dataset: str = "CTRPv2", - measure: str = "LN_IC50_curvecurator", - figsize: tuple = (16, 12), - show_top_n: Optional[int] = None, - font_adder: int = 6, -) -> tuple: - """ - Generate the dual-panel leaderboard figure. - - :param df: Input results data. - :param output_path: File path for save. - :param test_mode: Evaluation mode name. - :param dataset: Dataset name. - :param measure: Performance measure. - :param figsize: Figure dimensions. - :param show_top_n: Limit displayed models. - :param font_adder: Scale for text. - :return: Figure and axes tuple. - """ - configure_matplotlib(font_adder=font_adder) - - if show_top_n: - df = df.head(show_top_n) - - n_models = len(df) - y_positions = np.arange(n_models - 1, -1, -1) - bar_height = 0.65 - - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize, facecolor=COLORS["background"]) - fig.subplots_adjust(wspace=0.4) - - ax1.set_facecolor(COLORS["background"]) - df_pcc = df.sort_values("PCC", ascending=False).reset_index(drop=True) - max_pcc = (df_pcc["PCC"] + df_pcc["PCC_std"]).max() * 1.18 - - for i, (_, row) in enumerate(df_pcc.iterrows()): - style = get_bar_color(i, row["is_baseline"]) - draw_bar(ax1, 0, y_positions[i], row["PCC"], bar_height, style["color"], style["alpha"]) - - label_color = style["color"] if not row["is_baseline"] else COLORS["text_secondary"] - label_x = row["PCC"] + max_pcc * 0.02 - ax1.text( - label_x, - y_positions[i], - f"{row['PCC']:.3f}", - va="center", - ha="left", - fontsize=9 + font_adder, - fontweight="bold", - color=label_color, - zorder=5, - ) - - if i < 3 and not row["is_baseline"]: - medals = ["①", "②", "③"] - ax1.text( - -max_pcc * 0.03, - y_positions[i], - medals[i], - va="center", - ha="center", - fontsize=14 + font_adder, - fontweight="bold", - color=style["color"], - zorder=5, - ) - - ax1.set_xlim(-max_pcc * 0.06, max_pcc) - ax1.set_ylim(-0.8, n_models - 0.2) - ax1.set_yticks(y_positions) - ax1.set_yticklabels(df_pcc["algorithm"].values, fontsize=10 + font_adder) - - for i, label in enumerate(ax1.get_yticklabels()): - if i < 3 and not df_pcc.iloc[i]["is_baseline"]: - label.set_fontweight("bold") - label.set_color(get_bar_color(i, False)["color"]) - elif df_pcc.iloc[i]["is_baseline"]: - label.set_style("italic") - label.set_color(COLORS["text_secondary"]) - else: - label.set_color(COLORS["text"]) - - ax1.set_xlabel("Normalized PCC", fontsize=12 + font_adder, fontweight="bold", labelpad=10) - ax1.xaxis.grid(True, linestyle="--", alpha=0.3, color=COLORS["grid"]) - ax1.set_axisbelow(True) - ax1.tick_params(axis="x", colors=COLORS["text_secondary"]) - ax1.set_title( - "Normalized Pearson ↑ higher is better", - fontsize=14 + font_adder, - fontweight="bold", - color="#29ABCA", - pad=15, - ) - - ax2.set_facecolor(COLORS["background"]) - df_rmse = df.sort_values("RMSE", ascending=True).reset_index(drop=True) - max_rmse = (df_rmse["RMSE"] + df_rmse["RMSE_std"]).max() * 1.18 - - for i, (_, row) in enumerate(df_rmse.iterrows()): - style = get_bar_color(i, row["is_baseline"]) - draw_bar(ax2, 0, y_positions[i], row["RMSE"], bar_height, style["color"], style["alpha"]) - - label_color = style["color"] if not row["is_baseline"] else COLORS["text_secondary"] - label_x = row["RMSE"] + max_rmse * 0.02 - ax2.text( - label_x, - y_positions[i], - f"{row['RMSE']:.3f}", - va="center", - ha="left", - fontsize=9 + font_adder, - fontweight="bold", - color=label_color, - zorder=5, - ) - - if i < 3 and not row["is_baseline"]: - medals = ["①", "②", "③"] - ax2.text( - -max_rmse * 0.03, - y_positions[i], - medals[i], - va="center", - ha="center", - fontsize=14 + font_adder, - fontweight="bold", - color=style["color"], - zorder=5, - ) - - ax2.set_xlim(-max_rmse * 0.06, max_rmse) - ax2.set_ylim(-0.8, n_models - 0.2) - ax2.set_yticks(y_positions) - ax2.set_yticklabels(df_rmse["algorithm"].values, fontsize=10 + font_adder) - ax2.set_xlabel("Root Mean Square Error", fontsize=12 + font_adder, fontweight="bold", labelpad=10) - - for i, label in enumerate(ax2.get_yticklabels()): - if i < 3 and not df_rmse.iloc[i]["is_baseline"]: - label.set_fontweight("bold") - label.set_color(get_bar_color(i, False)["color"]) - elif df_rmse.iloc[i]["is_baseline"]: - label.set_style("italic") - label.set_color(COLORS["text_secondary"]) - else: - label.set_color(COLORS["text"]) - - ax2.xaxis.grid(True, linestyle="--", alpha=0.3, color=COLORS["grid"]) - ax2.set_axisbelow(True) - ax2.tick_params(axis="x", colors=COLORS["text_secondary"]) - ax2.set_title("RMSE ↓ lower is better", fontsize=14 + font_adder, fontweight="bold", color="#FF6B9D", pad=15) - - title_text = "DrEval Challenge Leaderboard" - n_chars = len(title_text) - gradient_colors = [] - for j in range(n_chars): - t = j / max(n_chars - 1, 1) - if t < 0.5: - t2 = t * 2 - r = int(0x14 + (0x29 - 0x14) * t2) - g = int(0xB8 + (0xAB - 0xB8) * t2) - b = int(0xA6 + (0xCA - 0xA6) * t2) - else: - t2 = (t - 0.5) * 2 - r = int(0x29 + (0x9D - 0x29) * t2) - g = int(0xAB + (0x4E - 0xAB) * t2) - b = int(0xCA + (0xDD - 0xCA) * t2) - gradient_colors.append(f"#{r:02x}{g:02x}{b:02x}") - - title_x_start = 0.5 - len(title_text) * 0.012 - for j, char in enumerate(title_text): - fig.text( - title_x_start + j * 0.024, - 0.97, - char, - fontsize=24 + font_adder, - fontweight="bold", - color=gradient_colors[j], - ha="center", - ) - fig.text( - 0.5, - 0.92, - f"{dataset} Dataset • {measure} • {_get_test_mode_name(test_mode)}", - ha="center", - fontsize=12 + font_adder, - color=COLORS["text_secondary"], - ) - - logo_path = Path("docs/_static/img/DrugResponseEvalLogo.svg") - if logo_path.exists(): - try: - from io import BytesIO - - import cairosvg - from PIL import Image - - png_data = cairosvg.svg2png(url=str(logo_path)) - logo_img = Image.open(BytesIO(png_data)) - logo_ax = fig.add_axes((0.8, 0.94, 0.15, 0.06)) - logo_ax.imshow(logo_img) - logo_ax.axis("off") - except Exception as e: - print(e) - pass - - legend_elements = [ - mpatches.Patch(facecolor="#F4D03F", label="#1 Champion", edgecolor="none"), - mpatches.Patch(facecolor="#BDC3C7", label="#2 Runner-up", edgecolor="none"), - mpatches.Patch(facecolor="#E67E22", label="#3 Third Place", edgecolor="none"), - mpatches.Patch(facecolor=COMPETITOR_COLOR, alpha=0.85, label="Competitor", edgecolor="none"), - mpatches.Patch(facecolor="#5a5a5a", alpha=1, label="Baseline", edgecolor="none"), - ] - - legend = fig.legend( - handles=legend_elements, - loc="lower center", - ncol=5, - frameon=True, - facecolor=COLORS["surface"], - edgecolor=COLORS["grid"], - fontsize=10 + font_adder, - bbox_to_anchor=(0.5, 0.02), - ) - legend.get_frame().set_alpha(0.9) - for text in legend.get_texts(): - text.set_color(COLORS["text"]) - - footer_text = ( - "Submit your model → https://drevalpy.readthedocs.io/en/latest/. " - "Send us your results.\n\n" - "If you significantly outperform the RandomForest, we send you chocolate!" - ) - - fig.text( - 0.5, - -0.01, - footer_text, - ha="center", - va="top", - fontsize=14 + font_adder, - color=COLORS["text_secondary"], - style="italic", - linespacing=1.0, - ) - - plt.tight_layout(rect=(0, 0.06, 1, 0.90)) - fig.savefig(output_path, dpi=150, bbox_inches="tight", facecolor=COLORS["background"], transparent=False) - plt.close(fig) - print(f"Saved leaderboard to: {output_path}") - - return fig, (ax1, ax2) - - -def _get_test_mode_name(test_mode: str) -> str: - """ - Map shorthand mode codes to full descriptive names. - - :param test_mode: Suffix code (LCO, etc). - :return: Full string name. - """ - names = { - "LCO": "10-Fold Leave-Cell-Out Cross Validation", - "LDO": "10-Fold Leave-Drug-Out Cross Validation", - "LPO": "10-Fold Leave-Pair-Out Cross Validation", - "LTO": "10-Fold Leave-Tissue-Out Cross Validation", - } - return names.get(test_mode, test_mode) - - -def main(): - """Execute dual-theme leaderboard generation.""" - parser = argparse.ArgumentParser( - description="Generate DrEvalPy leaderboard visualization (Dark & Light modes)", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument("--results_path", "-r", type=str, required=True, help="Path to evaluation_results.csv") - parser.add_argument("--output_dir", "-o", type=str, default="docs/_static/img", help="Directory to save images") - parser.add_argument("--test_mode", "-t", type=str, default="LCO", choices=["LCO", "LDO", "LPO", "LTO"]) - parser.add_argument("--dataset", "-d", type=str, default="CTRPv2", help="Dataset name") - parser.add_argument("--measure", "-m", type=str, default="LN_IC50_curvecurator", help="Response measure") - parser.add_argument("--top_n", "-n", type=int, default=None, help="Top N models") - parser.add_argument("--font_adder", type=int, default=6, help="Font size increment") - - args = parser.parse_args() - - df = load_results(args.results_path, test_mode=args.test_mode) - - out_dir = Path(args.output_dir) - out_dir.mkdir(parents=True, exist_ok=True) - - global COLORS - - COLORS = DARK_THEME - create_leaderboard( - df=df, - output_path=str(out_dir / "leaderboard_dark.png"), - test_mode=args.test_mode, - dataset=args.dataset, - measure=args.measure, - show_top_n=args.top_n, - font_adder=args.font_adder, - ) - - COLORS = LIGHT_THEME - create_leaderboard( - df=df, - output_path=str(out_dir / "leaderboard_light.png"), - test_mode=args.test_mode, - dataset=args.dataset, - measure=args.measure, - show_top_n=args.top_n, - font_adder=args.font_adder, - ) - - -if __name__ == "__main__": - main() diff --git a/drevalpy/visualization/create_report.py b/drevalpy/visualization/create_report.py deleted file mode 100644 index eabb973ca..000000000 --- a/drevalpy/visualization/create_report.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Generate evaluation reports after running a drug response experiment.""" - -import os -import pathlib -from collections.abc import Iterable -from typing import Union - -import numpy as np -import pandas as pd - -from drevalpy.visualization.utils import ( - create_html, - create_index_html, - create_output_directories, - draw_algorithm_plots, - draw_test_mode_plots, - parse_results, - prep_results, - write_results, -) - - -def generate_reports_for_test_mode( - test_mode: str, - evaluation_results: pd.DataFrame, - evaluation_results_per_drug: pd.DataFrame, - evaluation_results_per_cell_line: pd.DataFrame, - true_vs_pred: pd.DataFrame, - run_id: str, - path_data: Union[str, pathlib.Path], - result_path: Union[str, pathlib.Path], -) -> None: - """ - Generate reports (plots and HTML) for a single test mode. - - :param test_mode: The test mode to generate reports for. - :param evaluation_results: Aggregated evaluation results. - :param evaluation_results_per_drug: Evaluation results per drug. - :param evaluation_results_per_cell_line: Evaluation results per cell line. - :param true_vs_pred: True vs predicted values. - :param run_id: Unique run identifier. - :param path_data: Path to the dataset directory. - :param result_path: Path to the results directory. - """ - path_data = pathlib.Path(path_data) - result_path = pathlib.Path(result_path) - - print(f"Generating report for {test_mode} ...") - unique_algos_ndarray = draw_test_mode_plots( - test_mode=test_mode, - ev_res=evaluation_results, - ev_res_per_drug=evaluation_results_per_drug, - ev_res_per_cell_line=evaluation_results_per_cell_line, - custom_id=run_id, - path_data=path_data, - result_path=result_path, - ) - unique_algos: Iterable[str] = ( - list(unique_algos_ndarray) if isinstance(unique_algos_ndarray, (np.ndarray, tuple)) else unique_algos_ndarray - ) - - unique_algos_set = set(unique_algos) - { - "NaiveMeanEffectsPredictor", - "NaivePredictor", - "NaiveCellLineMeansPredictor", - "NaiveTissueMeansPredictor", - "NaiveDrugMeanPredictor", - } - for algorithm in unique_algos_set: - draw_algorithm_plots( - model=algorithm, - ev_res=evaluation_results, - ev_res_per_drug=evaluation_results_per_drug, - ev_res_per_cell_line=evaluation_results_per_cell_line, - t_vs_p=true_vs_pred, - test_mode=test_mode, - custom_id=run_id, - result_path=result_path, - ) - - all_files = [] - for _, _, files in os.walk(f"{result_path}/{run_id}"): - for file in files: - if file.endswith("json") or ( - file.endswith(".html") and file not in ["index.html", "LPO.html", "LCO.html", "LDO.html"] - ): - all_files.append(file) - - create_html( - run_id=run_id, - test_mode=test_mode, - files=all_files, - prefix_results=f"{result_path}/{run_id}", - ) - - -def generate_reports_for_all_test_modes( - test_modes: list[str], - evaluation_results: pd.DataFrame, - evaluation_results_per_drug: pd.DataFrame, - evaluation_results_per_cell_line: pd.DataFrame, - true_vs_pred: pd.DataFrame, - run_id: str, - path_data: Union[str, pathlib.Path], - result_path: Union[str, pathlib.Path], -) -> None: - """ - Generate reports for all test modes. - - :param test_modes: list of test modes to process. - :param evaluation_results: Aggregated evaluation results. - :param evaluation_results_per_drug: Evaluation results per drug. - :param evaluation_results_per_cell_line: Evaluation results per cell line. - :param true_vs_pred: True vs predicted values. - :param run_id: Unique run identifier. - :param path_data: Path to the dataset directory. - :param result_path: Path to the results directory. - """ - for test_mode in test_modes: - generate_reports_for_test_mode( - test_mode=test_mode, - evaluation_results=evaluation_results, - evaluation_results_per_drug=evaluation_results_per_drug, - evaluation_results_per_cell_line=evaluation_results_per_cell_line, - true_vs_pred=true_vs_pred, - run_id=run_id, - path_data=path_data, - result_path=result_path, - ) - - -def create_report( - run_id: str, - dataset: str, - path_data: Union[str, pathlib.Path] = "data", - result_path: Union[str, pathlib.Path] = "results", -) -> None: - """ - Render a full evaluation report pipeline. - - :param run_id: Unique run identifier for locating results. - :param dataset: Dataset name to filter results. - :param path_data: Path to the dataset directory. Defaults to "data". - :param result_path: Path to the results directory. Defaults to "results". - - :raises AssertionError: If the folder with the run_id does not exist under result_path. - """ - path_data = pathlib.Path(path_data).resolve() - result_path = pathlib.Path(result_path).resolve() - - if not os.path.exists(f"{result_path}/{run_id}"): - raise AssertionError(f"Folder {result_path}/{run_id} does not exist. The pipeline has to be run first.") - - ( - evaluation_results, - evaluation_results_per_drug, - evaluation_results_per_cell_line, - true_vs_pred, - ) = parse_results(path_to_results=f"{result_path}/{run_id}", dataset=dataset) - - ( - evaluation_results, - evaluation_results_per_drug, - evaluation_results_per_cell_line, - true_vs_pred, - ) = prep_results( - evaluation_results, evaluation_results_per_drug, evaluation_results_per_cell_line, true_vs_pred, path_data - ) - - write_results( - path_out=f"{result_path}/{run_id}/", - eval_results=evaluation_results, - eval_results_per_drug=evaluation_results_per_drug, - eval_results_per_cl=evaluation_results_per_cell_line, - t_vs_p=true_vs_pred, - ) - - create_output_directories(result_path, run_id) - test_modes = list(evaluation_results["test_mode"].unique()) - - generate_reports_for_all_test_modes( - test_modes=test_modes, - evaluation_results=evaluation_results, - evaluation_results_per_drug=evaluation_results_per_drug, - evaluation_results_per_cell_line=evaluation_results_per_cell_line, - true_vs_pred=true_vs_pred, - run_id=run_id, - path_data=path_data, - result_path=result_path, - ) - - create_index_html( - custom_id=run_id, - test_modes=test_modes, - prefix_results=f"{result_path}/{run_id}", - ) - - -def run_report( - *, - run_id: str, - dataset: str, - path_data: str = "data", - result_path: str = "results", -) -> None: - """Generate HTML report from a standalone experiment run.""" - create_report(run_id, dataset, path_data, result_path) - - -def run_pipeline_report( - *, - test_modes: list[str], - eval_results: str, - eval_results_per_drug: str, - eval_results_per_cl: str, - true_vs_predicted: str, - path_data: str, -) -> None: - """Generate HTML report from pipeline evaluation CSVs.""" - result_path = pathlib.Path(".") - outdir_name = "report" - create_output_directories(result_path=result_path, custom_id=outdir_name) - - ev_res = pd.read_csv(eval_results, index_col=0) - if eval_results_per_drug == "NO_FILE": - ev_res_per_drug = None - else: - ev_res_per_drug = pd.read_csv(eval_results_per_drug, index_col=0) - if eval_results_per_cl == "NO_FILE": - ev_res_per_cl = None - else: - ev_res_per_cl = pd.read_csv(eval_results_per_cl, index_col=0) - t_vs_p = pd.read_csv(true_vs_predicted, index_col=0) - - generate_reports_for_all_test_modes( - test_modes=test_modes, - evaluation_results=ev_res, - evaluation_results_per_drug=ev_res_per_drug, - evaluation_results_per_cell_line=ev_res_per_cl, - true_vs_pred=t_vs_p, - run_id=outdir_name, - path_data=path_data, - result_path=result_path, - ) - create_index_html( - custom_id=outdir_name, - test_modes=test_modes, - prefix_results=f"{result_path}/{outdir_name}", - ) diff --git a/drevalpy/visualization/critical_difference_plot.py b/drevalpy/visualization/critical_difference_plot.py deleted file mode 100644 index a7f3f7d07..000000000 --- a/drevalpy/visualization/critical_difference_plot.py +++ /dev/null @@ -1,419 +0,0 @@ -"""Draws the critical difference plot. - -This method performs the following steps: - -1. **Friedman Test**: First, it performs the Friedman test, which is a non-parametric statistical test used to detect - differences in treatments across multiple test attempts. It compares the ranks of multiple groups and is - suitable when there are repeated measurements for each group (as is the case here with cross-validation splits). - The p-value of this test is used to assess whether there are any significant differences in the performance of the - models. We use Benjamini/Hochberg correction for multiple testing. - -2. **Post-hoc Conover Test**: If the Friedman test returns a significant result (p-value < 0.05), the post-hoc Conover - test can be used to identify pairs of algorithms that perform significantly different. This test is necessary - because the Friedman test only tells if there is a difference somewhere among the models, but not which ones are - different. The `scikit_posthocs` library is used for this step. - -3. **Rank Calculation**: Next, the average ranks of each classifier across all cross-validation splits are computed. - The models are ranked based on their performance (lower ranks indicate better performance) and the average rank - across all splits is calculated for each model. - -4. **Critical Difference Diagram**: Finally, the method draws the critical difference diagram. This diagram visually - displays the significant differences between the algorithms. A horizontal line groups a set of models that are - not significantly different. The critical difference is determined based on the post-hoc test results. -""" - -import pathlib -import warnings -from io import TextIOWrapper -from typing import Optional, Union - -import matplotlib -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -import plotly.colors as pc -import scikit_posthocs as sp -from matplotlib import pyplot -from matplotlib.axes import Axes -from pandas import DataFrame, Series -from scikit_posthocs import sign_array -from scipy import stats - -from ..evaluation import MINIMIZATION_METRICS -from .outplot import OutPlot - -matplotlib.use("agg") -matplotlib.rcParams["font.family"] = "sans-serif" -matplotlib.rcParams["font.sans-serif"] = "Helvetica Neue" -warnings.filterwarnings("ignore", category=FutureWarning, message=".*swapaxes.*") - - -class CriticalDifferencePlot(OutPlot): - """ - Draws the critical difference diagram. - - The critical difference diagram is used to compare the performance of multiple classifiers and show whether a - model is significantly better than another model. This is calculated over the average ranks of the classifiers - which is why there need to be at least 3 classifiers to draw the diagram. Because the ranks are calculated over - the cross-validation splits and the significance threshold is set to 0.05, e.g., 10 CV folds are advisable. - """ - - def __init__(self, eval_results_preds: pd.DataFrame, metric="MSE"): - """ - Initializes the critical difference plot. - - :param eval_results_preds: evaluation results subsetted to predictions only (no randomizations etc) - :param metric: to be used to assess the critical difference - :raises ValueError: if eval_results_preds is empty or does not contain the metric - """ - eval_results_preds = eval_results_preds[["algorithm", "CV_split", metric]] - if eval_results_preds.empty: - raise ValueError( - "Critical Difference Plot: The DataFrame is empty. Please provide a valid DataFrame with predictions." - ) - if metric in MINIMIZATION_METRICS: - eval_results_preds.loc[:, metric] = -eval_results_preds.loc[:, metric] - - self.eval_results_preds = eval_results_preds - self.metric = metric - self.fig: Optional[plt.Figure] = None - self.test_results: Optional[pd.DataFrame] = None - - def draw_and_save(self, out_prefix: str, out_suffix: str) -> None: - """ - Draws the critical difference plot and saves it to a file. - - :param out_prefix: e.g., results/my_run/critical_difference_plots/ - :param out_suffix: e.g., LPO - :raises ValueError: if the figure is None or the test results are None - """ - try: - self._draw() - path_out = f"{out_prefix}critical_difference_algorithms_{out_suffix}.svg" - if self.fig is None or self.test_results is None: - raise ValueError("Figure is None. Cannot save the plot.") - else: - self.fig.savefig(path_out, bbox_inches="tight") - plt.clf() - self.test_results = self.test_results.round(4) - self.test_results.to_html(f"{out_prefix}critical_difference_algorithms_{out_suffix}.html") - except Exception as e: - print(f"Error in drawing critical difference plot: {e}") - - def _draw(self) -> None: - """Draws the critical difference plot.""" - input_friedman = self.eval_results_preds.groupby("algorithm")[self.metric].apply(list) - # check that all algorithms have the same number of CV splits, if not filter them out - # table lengths of arrays: - table_lengths = input_friedman.apply(len) - # get the most common length - most_common_length = table_lengths.mode().values[0] - # filter out algorithms that do not have the most common length - input_friedman = input_friedman[table_lengths == most_common_length] - algorithms_included = set(input_friedman.index) - friedman_p_value = stats.friedmanchisquare(*input_friedman).pvalue - self.eval_results_preds = self.eval_results_preds[ - self.eval_results_preds["algorithm"].isin(algorithms_included) - ] - # transform: rows = CV_split, columns = algorithms, values = metric - input_conover_friedman = self.eval_results_preds.pivot_table( - index="CV_split", columns="algorithm", values=self.metric - ) - self.test_results = pd.DataFrame(sp.posthoc_conover_friedman(input_conover_friedman, p_adjust="fdr_bh")) - average_ranks = input_conover_friedman.rank(ascending=False, axis=1).mean(axis=0) - plt.title( - f"Critical Difference Diagram: Metric: {self.metric}.\n" - f"Overall Friedman-Chi2 p-value: {friedman_p_value:.2e}", - fontsize=20, - ) - color_palette = dict() - generated_colors = _generate_discrete_palette(len(input_conover_friedman.columns)) - for alg in input_conover_friedman.columns: - color_palette[alg] = generated_colors.pop() - - _critical_difference_diagram(ranks=average_ranks, sig_matrix=self.test_results, color_palette=color_palette) - - self.fig = plt.gcf() - - @staticmethod - def write_to_html(test_mode: str, f: TextIOWrapper, *args, **kwargs) -> TextIOWrapper: - """ - Inserts the critical difference plot into the HTML report file. - - :param test_mode: test_mode, e.g., LPO - :param f: HTML report file - :param args: not needed - :param kwargs: not needed - :returns: HTML report file - """ - path_out_cd = f"critical_difference_plots/critical_difference_algorithms_{test_mode}.svg" - f.write(f" ") - f.write( - "

" - "This diagram displays the mean rank of each model over all cross-validation splits: Within each CV " - "split, the models are ranked according to their MSE. We calculate whether a model is significantly " - "better than another one using the Friedman test and the post-hoc Conover test. " - "The Friedman test shows whether there are overall differences between the models. After a significant" - "Friedman test, the pairwise Conover test is performed to identify which models are significantly " - "outperforming others. One line indicates which models are not significantly different from each " - "other. The p-values are shown below. This can only be rendered if at least 3 models were run." - ) - f.write("

") - f.write("

Results of the pairwise Post-Hoc Conover Test

") - f.write("

All p-values are adjusted with Benjamini-Hochberg correction.

") - f.write("
") - path_to_table = pathlib.Path( - pathlib.Path(f.name).parent, f"critical_difference_plots/critical_difference_algorithms_{test_mode}.html" - ) - if not path_to_table.exists(): - return f - with open(path_to_table) as conover_results_f: - conover_results = conover_results_f.readlines() - conover_results[0] = conover_results[0].replace( - '', - '
', - ) - for line in conover_results: - f.write(line) - return f - - -def _critical_difference_diagram( - ranks: Union[dict, Series], - sig_matrix: DataFrame, - *, - color_palette: dict, - ax: Optional[Axes] = None, - label_fmt_left: str = "{label} ({rank:.2g})", - label_fmt_right: str = "({rank:.2g}) {label}", - label_props: Optional[dict] = None, - marker_props: Optional[dict] = None, - elbow_props: Optional[dict] = None, - crossbar_props: Optional[dict] = None, - text_h_margin: float = 0.01, - left_only: bool = False, -) -> dict[str, list]: - """ - Plot a Critical Difference diagram from ranks and post-hoc results. - - :param ranks : dict or Series - Indicates the rank value for each sample or estimator (as keys or index). - - :param sig_matrix : DataFrame - The corresponding p-value matrix outputted by post-hoc tests, with - indices matching the labels in the ranks argument. - - :param ax : matplotlib.SubplotBase, optional - The object in which the plot will be built. Gets the current Axes - by default (if None is passed). - - :param label_fmt_left : str, optional - The format string to apply to the labels on the left side. The keywords - label and rank can be used to specify the sample/estimator name and - rank value, respectively, by default '{label} ({rank:.2g})'. - - :param label_fmt_right : str, optional - The same, but for the labels on the right side of the plot. - By default '({rank:.2g}) {label}'. - - :param label_props : dict, optional - Parameters to be passed to pyplot.text() when creating the labels, - by default None. - - :param marker_props : dict, optional - Parameters to be passed to pyplot.scatter() when plotting the rank - markers on the axis, by default None. - - :param elbow_props : dict, optional - Parameters to be passed to pyplot.plot() when creating the elbow lines, - by default None. - - :param crossbar_props : dict, optional - Parameters to be passed to pyplot.plot() when creating the crossbars - that indicate lack of statistically significant difference. By default - None. - - :param color_palette: dict - Parameters to be passed when you need specific colors for each category - - :param text_h_margin : float, optional - Space between the text labels and the nearest vertical line of an - elbow, by default 0.01. - - :param left_only: boolean, optional - Set all labels in a single left-sided block instead of splitting them - into two block, one for the left and one for the right. - :raises ValueError: If the color_palette keys are not consistent with the ranks. - :returns: dict - """ - # check color_palette consistency - if isinstance(color_palette, dict) and ((len(set(ranks.keys()) & set(color_palette.keys()))) == len(ranks)): - pass - elif isinstance(color_palette, list) and (len(ranks) <= len(color_palette)): - pass - else: - raise ValueError("color_palette keys are not consistent, or list size too small") - - elbow_props = elbow_props or {} - marker_props = {"zorder": 3, **(marker_props or {})} - label_props = {"va": "center", "fontsize": 16, "weight": "heavy", **(label_props or {})} - crossbar_props = { - "color": "k", - "zorder": 3, - "linewidth": 4, - **(crossbar_props or {}), - } - - ax = ax or pyplot.gca() - ax.yaxis.set_visible(False) - ax.spines["right"].set_visible(False) - ax.spines["left"].set_visible(False) - ax.spines["bottom"].set_visible(False) - ax.xaxis.set_ticks_position("top") - ax.spines["top"].set_position("zero") - - # lists of artists to be returned - markers = [] - elbows = [] - labels = [] - crossbars = [] - - # True if pairwise comparison is NOT significant - adj_matrix = DataFrame( - 1 - sign_array(sig_matrix), - index=sig_matrix.index, - columns=sig_matrix.columns, - dtype=bool, - ) - - ranks = Series(ranks).sort_values() # Standardize if ranks is dict - if left_only: - points_left = ranks - else: - left_points = len(ranks) // 2 - points_left, points_right = ( - ranks.iloc[:left_points], - ranks.iloc[left_points:], - ) - - # for each algorithm: get the set of algorithms that are not significantly different - crossbar_sets = dict() - for alg, row in adj_matrix.iterrows(): - not_different = adj_matrix.columns[row].tolist() - crossbar_sets[alg] = set(not_different).union({alg}) - - # Create stacking of crossbars: make a crossbar of the fitting color for each algorithm - crossbar_levels: list[list[set]] = [] - ypos = -0.5 - for alg in ranks.index: - bar = crossbar_sets[alg] - not_different = crossbar_sets[alg] - if len(not_different) == 1: - continue - crossbar_levels.append([bar]) - - crossbar_props["color"] = color_palette[alg] - crossbars.append( - ax.plot( - # Adding a separate line between each pair enables showing a - # marker over each elbow with crossbar_props={'marker': 'o'}. - [ranks[i] for i in bar], - [ypos] * len(bar), - **crossbar_props, - ) - ) - ypos -= 0.5 - - lowest_crossbar_ypos = ypos - - def plot_items(points, xpos, label_fmt, color_palette, label_props): - """ - Plot each marker + elbow + label. - - :param points: the points to plot - :param xpos: the x position of the points - :param label_fmt: the format of the label - :param color_palette: the color palette to use - :param label_props: the label properties - """ - ypos = lowest_crossbar_ypos - 0.5 - for idx, (label, rank) in enumerate(points.items()): - if not color_palette or len(color_palette) == 0: - elbow, *_ = ax.plot( - [xpos, rank, rank], - [ypos, ypos, 0], - **elbow_props, - ) - else: - elbow, *_ = ax.plot( - [xpos, rank, rank], - [ypos, ypos, 0], - c=color_palette[label] if isinstance(color_palette, dict) else color_palette[idx], - **elbow_props, - ) - - elbows.append(elbow) - curr_color = elbow.get_color() - markers.append(ax.scatter(rank, 0, **{"color": curr_color, **marker_props})) - labels.append( - ax.text( - xpos, - ypos, - label_fmt.format(label=label, rank=rank), - color=curr_color, - **label_props, - ) - ) - ypos -= 0.5 - - plot_items( - points_left, - xpos=points_left.iloc[0] - text_h_margin, - label_fmt=label_fmt_left, - color_palette=color_palette, - label_props={ - "ha": "right", - **label_props, - }, - ) - - if not left_only: - plot_items( - points_right[::-1], - xpos=points_right.iloc[-1] + text_h_margin, - label_fmt=label_fmt_right, - color_palette=color_palette, - label_props={"ha": "left", **label_props}, - ) - - return { - "markers": markers, - "elbows": elbows, - "labels": labels, - "crossbars": crossbars, - } - - -def _generate_discrete_palette(n_colors): - # Get the base D3 categorical palette - base_palette = pc.qualitative.D3 - base_n = len(base_palette) # Number of available discrete colors - - if n_colors <= base_n: - return base_palette[:n_colors] # Use available colors directly - - # Convert HEX to RGB (0-1 range) - base_rgb = np.array([matplotlib.colors.to_rgb(c) for c in base_palette]) - - # Generate target indices in the interpolated space - target_indices = np.linspace(0, base_n - 1, n_colors) - - # Interpolate in RGB space - interpolated_rgb = np.array( - [np.interp(target_indices, np.arange(base_n), base_rgb[:, i]) for i in range(3)] - ).T # Transpose to get (n_colors, 3) - - # Convert back to HEX - interpolated_hex = [matplotlib.colors.to_hex(c) for c in interpolated_rgb] - - return interpolated_hex diff --git a/drevalpy/visualization/cross_study_tables.py b/drevalpy/visualization/cross_study_tables.py deleted file mode 100644 index c5ce52abd..000000000 --- a/drevalpy/visualization/cross_study_tables.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Module for generating evaluation tables for cross-study drug response prediction.""" - -import os -import pathlib -from io import TextIOWrapper - -import pandas as pd -import plotly.graph_objects as go - - -class CrossStudyTables: - """Generate evaluation tables for cross-study drug response prediction.""" - - def __init__(self, evaluation_metrics: pd.DataFrame, path_data: pathlib.Path): - """ - Initialize the CrossStudyTables object. - - :param evaluation_metrics: eval metrics dataframe. - :param path_data: Path to data directory (used for context or extensions). - """ - self.evaluation_metrics = evaluation_metrics - self.path_data = path_data - - self.figures: dict[str, go.Figure] = {} - cross_study_settings = evaluation_metrics[ - evaluation_metrics.rand_setting.str.contains("cross-study-") - ].rand_setting.unique() - self.cross_study_datasets = [setting.split("cross-study-")[1] for setting in cross_study_settings] - - evaluation_metrics = evaluation_metrics[evaluation_metrics.rand_setting.isin(cross_study_settings)] - - self.mean_metrics = [] - self.std_metrics = [] - for dataset in self.cross_study_datasets: - evaluation_metrics_dataset = evaluation_metrics[ - evaluation_metrics.rand_setting.str.contains(f"cross-study-{dataset}") - ] - evaluation_metrics_group = [s.split("_split_")[0] for s in evaluation_metrics_dataset.index] - metrics = [ - "MSE", - "RMSE", - "MAE", - "R^2", - "Pearson", - "Spearman", - "Kendall", - "Pearson: normalized", - "Spearman: normalized", - "Kendall: normalized", - "R^2: normalized", - ] - grouped = evaluation_metrics_dataset[metrics].groupby(evaluation_metrics_group) - mean = grouped.mean() - std = grouped.std() - # sort by lowest MSE - mean = mean.sort_values(by="MSE") - std = std.loc[mean.index] - - mean.index = [s.split("_cross-study")[0] for s in mean.index] - std.index = mean.index - self.mean_metrics.append(mean) - self.std_metrics.append(std) - - def draw(self): - """Create and store Plotly table figures sorted by MSE.""" - for dataset_name, mean_df, std_df in zip(self.cross_study_datasets, self.mean_metrics, self.std_metrics): - - formatted_data = mean_df.map(lambda x: f"{x:.3f}") + " ± " + std_df.map(lambda x: f"{x:.3f}") - - fig = go.Figure( - data=[ - go.Table( - header=dict( - values=["Model"] + list(formatted_data.columns), fill_color="lightgrey", align="left" - ), - cells=dict( - values=[formatted_data.index] - + [formatted_data[col].values for col in formatted_data.columns], - fill_color="white", - align="left", - ), - ) - ] - ) - fig.update_layout(title_text=f"Evaluation Metrics for Cross-Study Predictions to {dataset_name}") - self.figures[dataset_name] = fig - - def draw_and_save(self, out_prefix: str, out_suffix: str): - """ - Generate and save HTML tables for each cross-study dataset. - - :param out_prefix: Directory to save output files. - :param out_suffix: Suffix to append to each output filename. - """ - os.makedirs(out_prefix, exist_ok=True) - self.draw() - for dataset_name, fig in self.figures.items(): - filename = f"{out_prefix}/table_cross_study_{dataset_name}_{out_suffix}.html" - fig.write_html(filename, include_plotlyjs="embed", full_html=True) - - @staticmethod - def write_to_html(test_mode: str, f: TextIOWrapper, files: list[str], prefix: str) -> TextIOWrapper: - """ - Embed HTML table files into an open HTML file handle. - - :param test_mode: Substring to match filenames (e.g., 'lpo', 'lco'). - :param f: Open writable file handle to insert HTML blocks. - :param files: List of filenames in the target directory. - :param prefix: Path prefix to locate HTML table files. - - :return: Updated file handle with HTML blocks written in. - """ - if prefix: - prefix = os.path.join(prefix, "html_tables") - os.makedirs(prefix, exist_ok=True) - - for file in files: - if file.startswith("table_cross_study_") and file.endswith(".html") and test_mode in file: - f.write(f'\n') - return f diff --git a/drevalpy/visualization/heatmap.py b/drevalpy/visualization/heatmap.py deleted file mode 100644 index 816731833..000000000 --- a/drevalpy/visualization/heatmap.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Plots a heatmap of the evaluation metrics.""" - -import numpy as np -import pandas as pd -import plotly.graph_objects as go -from plotly.subplots import make_subplots - -from .vioheat import VioHeat - - -class Heatmap(VioHeat): - """Plots a heatmap of the evaluation metrics.""" - - def __init__(self, df: pd.DataFrame, normalized_metrics=False, whole_name=False): - """ - Initialize the Heatmap class. - - :param df: either containing all predictions for all algorithms or all tests for one algorithm (including - robustness, randomization, … tests then) - :param normalized_metrics: whether the metrics are normalized - :param whole_name: whether the whole name should be displayed - :raises ValueError: If the DataFrame is empty or does not contain the required metrics. - """ - super().__init__(df, normalized_metrics, whole_name) - if normalized_metrics and not any(["normalized" in col for col in self.df.columns]): - raise ValueError( - "The DataFrame does not contain normalized metrics. Please provide a DataFrame with normalized metrics." - ) - if self.df.empty: - raise ValueError("The DataFrame is empty. Please provide a valid DataFrame with metrics.") - - self.df = self.df[[col for col in self.df.columns if col in self.all_metrics]] - if self.df.empty: - - raise ValueError("The DataFrame does not contain any valid metrics. Please check the columns.") - self.n_models = len(self.df.index) - - if self.normalized_metrics: - titles = [ - "Mean R^2: normalized", - "Mean Correlations: normalized", - ] - nr_subplots = 3 - self.plot_settings = ["r2", "correlations"] - else: - titles = [ - "Mean R^2", - "Mean Correlations", - "Mean Errors", - "Strictly Standardized Mean Difference for R^2", - "Strictly Standardized Mean Difference for MSE", - ] - self.plot_settings = [ - "r2", - "correlations", - "errors", - "ssmd_R^2", - "ssmd_MSE", - ] - nr_subplots = len(self.plot_settings) - - self.fig = make_subplots( - rows=nr_subplots, - cols=1, - subplot_titles=tuple(titles), - vertical_spacing=0.1, - ) - - def draw_and_save(self, out_prefix: str, out_suffix: str) -> None: - """ - Draw the heatmap and save it to a file. - - :param out_prefix: e.g., results/my_run/heatmaps/ - :param out_suffix: e.g., algorithms_normalized - """ - self._draw() - path_out = f"{out_prefix}heatmap_{out_suffix}.html" - self.fig.write_html(path_out) - - def _draw(self) -> None: - """Draw the heatmap.""" - print("Drawing heatmaps ...") - for plot_setting in self.plot_settings: - - self._draw_subplots(plot_setting) - - # Dynamically adjust figure height based on number of models - num_models = self.n_models - height_per_model = 35 # Increase spacing for each model - max_height = 5000 # Increase max height if needed - new_height = min(500 + num_models * height_per_model, max_height) - self.fig.update_layout( - height=new_height, - width=1300, - title_text="Heatmap of the evaluation metrics", - ) - self.fig.update_traces(showscale=False) - - def _draw_subplots(self, plot_setting: str) -> None: - """ - Draw the subplots of the heatmap. - - :param plot_setting: Either "r2", "correlations", "errors", or "ssmd" - :raises ValueError: If an unknown plot setting is given - """ - idx_split = self.df.index.to_series().str.split("_") - setting = idx_split.str[0:3].str.join("_") - if plot_setting.startswith("ssmd_"): - metric_name = plot_setting.split("_")[1] # Extract metric name (e.g., "ssmd_r2" → "r2") - dt = self._compute_ssmd(metric_name) - dt["sort_key"] = dt.max(axis=1) - dt = dt.sort_values(by="sort_key", ascending=True).drop(columns=["sort_key"]) - dt = dt[dt.index] # Ensure columns match sorted rows - - if dt.empty: - print(f"Warning: SSMD heatmap for {metric_name} is empty. Skipping.") - return - row_idx = self.plot_settings.index(plot_setting) + 1 - - colorscale = "RdBu" - text_labels = dt.round(3).astype(str) - else: - dt_std_errs = self.df.groupby(setting).apply(lambda x: self._calc_summary_metric(x, std_error=True)) - - if plot_setting == "r2": - r2_columns = [col for col in self.df.columns if "R^2" in col] - - dt = self.df[r2_columns].groupby(setting).apply(lambda x: self._calc_summary_metric(x)) - dt = dt.sort_values(by=r2_columns[0], ascending=True) - dt_std_errs = dt_std_errs[r2_columns] - dt_std_errs = dt_std_errs.loc[dt.index] - - row_idx = 1 - colorscale = "Blues" - elif plot_setting == "correlations": - corr_columns = [ - col for col in self.df.columns if "Pearson" in col or "Spearman" in col or "Kendall" in col - ] - dt = self.df[corr_columns].groupby(setting).apply(lambda x: self._calc_summary_metric(x)) - dt = dt.sort_values(by=corr_columns[0], ascending=True) - dt_std_errs = dt_std_errs[corr_columns] - dt_std_errs = dt_std_errs.loc[dt.index] - - row_idx = 2 - colorscale = "Viridis" - elif plot_setting == "errors": - error_columns = [col for col in self.df.columns if col in ["MSE", "RMSE", "MAE"]] - if not error_columns: - print("Warning: No error metric columns found. Skipping error heatmap.") - return - dt = self.df[error_columns].groupby(setting).apply(lambda x: self._calc_summary_metric(x)) - dt = dt.sort_values(by=error_columns[0], ascending=False) - dt_std_errs = dt_std_errs[error_columns] - dt_std_errs = dt_std_errs.loc[dt.index] - - row_idx = 3 - colorscale = "hot" - else: - raise ValueError(f"Unknown plot setting: {plot_setting}") - text_labels = dt.round(3).astype(str) + " ± " + dt_std_errs.round(3).astype(str) - - labels = [i.replace("_", " ") if self.whole_name else i.split("_")[0] for i in dt.index] - self.fig.add_trace( - go.Heatmap( - z=dt.values, - x=dt.columns, - y=labels, - colorscale=colorscale, - texttemplate="%{text}", - text=text_labels, - textfont={"size": 16}, # size of labels of pixels of the heatmap - ), - row=row_idx, - col=1, - ) - - # Force all y-ticks to be displayed - self.fig.update_yaxes( - row=row_idx, - col=1, - tickmode="array", - tickvals=list(range(len(dt.index))), # Force showing all ticks - ticktext=labels, - automargin=True, # Prevent cutoff - tickfont=dict(size=15), # Adjust text size - ) - - def _compute_ssmd(self, metric: str) -> pd.DataFrame: - """ - Compute Strictly Standardized Mean Difference (SSMD) for a given metric across splits. - - :param metric: The evaluation metric to compute SSMD for (e.g., "R^2", "RMSE", "MAE", "Pearson"). - :return: SSMD heatmap matrix (models × models) as a DataFrame. - """ - if metric not in self.df.columns: - print(f"Warning: '{metric}' metric not found in DataFrame. Skipping SSMD heatmap.") - return pd.DataFrame() - - # Extract only the base model name (remove _predictions_testmode_split_X) - self.df["model_name"] = self.df.index.to_series().apply(lambda x: x.split("_predictions")[0]) - - models = self.df["model_name"].unique() - ssmd_matrix = pd.DataFrame(index=models, columns=models) - - for m1 in models: - for m2 in models: - if m1 == m2: - ssmd_matrix.loc[m1, m2] = 0 # No self-comparison - continue - - # Get metric values across splits for both models - values_m1 = self.df[self.df["model_name"] == m1][metric] - values_m2 = self.df[self.df["model_name"] == m2][metric] - - # Compute SSMD - mu1, mu2 = values_m1.mean(), values_m2.mean() - sigma1_sq, sigma2_sq = values_m1.var(ddof=1), values_m2.var(ddof=1) - ssmd = (mu1 - mu2) / np.sqrt(sigma1_sq + sigma2_sq) if sigma1_sq + sigma2_sq > 0 else np.nan - - ssmd_matrix.loc[m1, m2] = ssmd - - return ssmd_matrix.astype(float) - - @staticmethod - def _calc_summary_metric(x: pd.DataFrame, std_error: bool = False): - """ - Calculate the mean or standard error of the metrics. - - :param x: DataFrame containing the metrics - :param std_error: whether to calculate the standard error or the mean - :returns: Series containing the mean or standard error of the metrics - """ - results = pd.Series(index=x.columns) - for col in x.columns: - if np.count_nonzero(np.isnan(x[col])) == len(x[col]): - results[col] = np.nan - elif std_error: - results[col] = np.nanstd(x[col]) / np.sqrt(x.shape[0]) - else: - results[col] = np.nanmean(x[col]) - return results diff --git a/drevalpy/visualization/outplot.py b/drevalpy/visualization/outplot.py deleted file mode 100644 index cd96d32c1..000000000 --- a/drevalpy/visualization/outplot.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Abstract wrapper class for all visualizations.""" - -from abc import ABC, abstractmethod -from io import TextIOWrapper - - -class OutPlot(ABC): - """Abstract wrapper class for all visualizations.""" - - @abstractmethod - def draw_and_save(self, out_prefix: str, out_suffix: str) -> None: - """ - Draw and save the plot. - - :param out_prefix: path to output directory for python package - :param out_suffix: custom suffix for output file - """ - pass - - @abstractmethod - def _draw(self) -> None: - """Draw the plot.""" - pass - - @staticmethod - @abstractmethod - def write_to_html(test_mode: str, f: TextIOWrapper, *args, **kwargs) -> TextIOWrapper: - """ - Write the plot to the final report file. - - :param test_mode: LPO, LCO, LDO - :param f: the file to write to - :param args: additional arguments - :param kwargs: additional keyword arguments - :return: the file to write to - """ - pass diff --git a/drevalpy/visualization/plots/__init__.py b/drevalpy/visualization/plots/__init__.py new file mode 100644 index 000000000..693505300 --- /dev/null +++ b/drevalpy/visualization/plots/__init__.py @@ -0,0 +1,21 @@ +"""Visualization plot implementations. Importing this module triggers registration.""" + +from drevalpy.visualization.plots import ( + comparison_scatter, + critical_difference, + cross_study_table, + heatmap, + leaderboard, + regression_scatter, + violin, +) + +__all__ = [ + "comparison_scatter", + "critical_difference", + "cross_study_table", + "heatmap", + "leaderboard", + "regression_scatter", + "violin", +] diff --git a/drevalpy/visualization/plots/_group_metrics.py b/drevalpy/visualization/plots/_group_metrics.py new file mode 100644 index 000000000..7e451b6e0 --- /dev/null +++ b/drevalpy/visualization/plots/_group_metrics.py @@ -0,0 +1,276 @@ +"""Bounded, vectorised per-group correlation metrics for the comparison plots. + +The comparison scatter needs one number per (model, group) pair - the Pearson +correlation of a model's predictions against ground truth restricted to a single +drug or cell line - not the underlying point cloud. Computing it with +``DataFrame.groupby(...).apply(pearsonr)`` costs a Python call per group and a +row-wise DataFrame to group over; at 96 models x 10 folds x 23k rows that +dominates both runtime and peak memory. + +Everything here works on ``np.bincount`` sums instead, so the cost is a handful +of vectorised passes per fold and the retained result is a ``models x groups`` +float32 matrix - 0.2 MB at the scale above. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, Literal + +import numpy as np + +if TYPE_CHECKING: + from drevalpy.types.results import ExperimentResult, ModelResult, RunResult + +#: Groupings the comparison plots support, in report order. +GROUPINGS: Final[tuple[str, ...]] = ("drug", "cell_line") + +#: Grouping name -> the ``RunResult`` attribute holding that grouping's labels. +_ID_ATTRIBUTE: Final[dict[str, str]] = {"drug": "drug_ids", "cell_line": "cell_line_ids"} + +#: Human-readable axis/section labels per grouping. +GROUPING_LABELS: Final[dict[str, str]] = {"drug": "drug", "cell_line": "cell line"} + +#: Correlation is undefined below this many observations in a group. +MIN_GROUP_SIZE: Final[int] = 2 + +Grouping = Literal["drug", "cell_line"] + + +def group_labels(run: RunResult, grouping: str) -> np.ndarray: + """Return the grouping labels of a run. + + :param run: Run whose identifier arrays to read. + :param grouping: One of :data:`GROUPINGS`. + :returns: The run's ``drug_ids`` or ``cell_line_ids`` array. + :raises ValueError: If ``grouping`` is not one of :data:`GROUPINGS`. + """ + try: + attribute = _ID_ATTRIBUTE[grouping] + except KeyError: + raise ValueError(f"Unknown grouping {grouping!r}; expected one of {GROUPINGS}") from None + return np.asarray(getattr(run, attribute)) + + +def _scoring_runs(model: ModelResult) -> list[RunResult]: + """Return the non-randomized runs of a model.""" + return [run for run in model.runs if run.randomization is None] + + +class _PearsonSums: + """Streaming sufficient statistics for a grouped Pearson correlation. + + Holds the six ``bincount`` accumulators (count, sum x, sum y, sum x^2, + sum y^2, sum xy) for a fixed group axis, so folds can be folded in one at a + time and never coexist in memory. + """ + + __slots__ = ("_count", "_sx", "_sxx", "_sxy", "_sy", "_syy", "n_groups") + + def __init__(self, n_groups: int) -> None: + self.n_groups = n_groups + self._count = np.zeros(n_groups, dtype=np.int64) + self._sx = np.zeros(n_groups, dtype=np.float64) + self._sy = np.zeros(n_groups, dtype=np.float64) + self._sxx = np.zeros(n_groups, dtype=np.float64) + self._syy = np.zeros(n_groups, dtype=np.float64) + self._sxy = np.zeros(n_groups, dtype=np.float64) + + def add(self, codes: np.ndarray, x: np.ndarray, y: np.ndarray) -> None: + """Accumulate one batch of observations. + + Rows with a negative code (no matching group) or a NaN on either axis + are dropped. + + :param codes: Group index per observation. + :param x: First variable, aligned with ``codes``. + :param y: Second variable, aligned with ``codes``. + """ + codes = np.asarray(codes) + x = np.asarray(x, dtype=np.float64) + y = np.asarray(y, dtype=np.float64) + keep = (codes >= 0) & np.isfinite(x) & np.isfinite(y) + if not keep.all(): + codes, x, y = codes[keep], x[keep], y[keep] + if codes.size == 0: + return + n = self.n_groups + self._count += np.bincount(codes, minlength=n) + self._sx += np.bincount(codes, weights=x, minlength=n) + self._sy += np.bincount(codes, weights=y, minlength=n) + self._sxx += np.bincount(codes, weights=x * x, minlength=n) + self._syy += np.bincount(codes, weights=y * y, minlength=n) + self._sxy += np.bincount(codes, weights=x * y, minlength=n) + + def correlations(self, *, min_count: int = MIN_GROUP_SIZE) -> np.ndarray: + """Reduce the accumulated sums to one correlation per group. + + Groups with fewer than ``min_count`` observations, or with no variance + on either axis, yield NaN - mirroring + :func:`drevalpy.evaluation.pearson`, which returns NaN for a constant + target. + + :param min_count: Minimum observations required for a finite value. + :returns: ``float64`` array of length ``n_groups``. + """ + counts = self._count.astype(np.float64) + enough = self._count >= max(min_count, MIN_GROUP_SIZE) + safe = np.where(enough, counts, 1.0) + + cov = self._sxy - self._sx * self._sy / safe + var_x = self._sxx - self._sx * self._sx / safe + var_y = self._syy - self._sy * self._sy / safe + + # Rounding can drive a genuinely zero variance slightly negative. + denominator = np.sqrt(np.clip(var_x, 0.0, None) * np.clip(var_y, 0.0, None)) + valid = enough & (denominator > 0.0) + + out = np.full(self.n_groups, np.nan, dtype=np.float64) + np.divide(cov, denominator, out=out, where=valid) + return np.clip(out, -1.0, 1.0, out=out) + + +def grouped_pearson( + codes: np.ndarray, + n_groups: int, + x: np.ndarray, + y: np.ndarray, + *, + min_count: int = MIN_GROUP_SIZE, +) -> np.ndarray: + """Compute the Pearson correlation of ``x`` against ``y`` within each group. + + Vectorised over groups: no Python-level loop and no intermediate object per + observation. + + :param codes: Group index per observation; negative entries are dropped. + :param n_groups: Size of the group axis, so empty groups keep their slot. + :param x: First variable, aligned with ``codes``. + :param y: Second variable, aligned with ``codes``. + :param min_count: Minimum observations required for a finite value. + :returns: ``float64`` array of length ``n_groups``, NaN where undefined. + """ + sums = _PearsonSums(n_groups) + sums.add(codes, x, y) + return sums.correlations(min_count=min_count) + + +@dataclass(frozen=True) +class GroupCorrelationMatrix: + """Per-group correlations for every model, on a shared group axis. + + The shared axis is what makes the comparison plot cheap: two models are + compared by reading two rows of :attr:`values`, with column ``j`` of both + referring to ``group_names[j]``. + """ + + grouping: str + model_names: tuple[str, ...] + group_names: tuple[str, ...] + values: np.ndarray + + @property + def n_models(self) -> int: + """Number of models on the row axis.""" + return len(self.model_names) + + @property + def n_groups(self) -> int: + """Number of drugs or cell lines on the column axis.""" + return len(self.group_names) + + @property + def is_empty(self) -> bool: + """Whether the matrix has no models or no groups.""" + return self.n_models == 0 or self.n_groups == 0 + + def for_model(self, model_name: str) -> np.ndarray: + """Return one model's correlation vector. + + :param model_name: Name of a model in :attr:`model_names`. + :returns: A read-only view of the matching row of :attr:`values`. + :raises KeyError: If ``model_name`` is not in :attr:`model_names`. + """ + try: + index = self.model_names.index(model_name) + except ValueError: + raise KeyError(model_name) from None + return self.values[index] + + def drop_all_nan_models(self) -> GroupCorrelationMatrix: + """Return a copy without models whose correlations are all NaN. + + A model that predicts a constant within every group - the per-drug and + per-cell-line naive baselines do exactly that - has no defined + correlation anywhere and would only contribute an empty dropdown entry. + + :returns: ``self`` when nothing is dropped, otherwise a filtered copy. + """ + if self.is_empty: + return self + keep = np.isfinite(self.values).any(axis=1) + if keep.all(): + return self + return GroupCorrelationMatrix( + grouping=self.grouping, + model_names=tuple(np.asarray(self.model_names)[keep].tolist()), + group_names=self.group_names, + values=self.values[keep], + ) + + +def _shared_group_axis(result: ExperimentResult, grouping: str) -> np.ndarray: + """Return the sorted union of group labels over every scoring run.""" + seen: list[np.ndarray] = [] + for model in result.models: + for run in _scoring_runs(model): + seen.append(np.unique(group_labels(run, grouping))) + if not seen: + return np.empty(0, dtype=object) + return np.unique(np.concatenate(seen)) + + +def model_group_correlations( + result: ExperimentResult, + grouping: str, + *, + min_count: int = MIN_GROUP_SIZE, +) -> GroupCorrelationMatrix: + """Correlate predictions against ground truth per model and per group. + + Folds are pooled per model and consumed one at a time, so the retained + memory is the ``n_models x n_groups`` result rather than anything + proportional to the number of predictions. + + :param result: Experiment whose models to summarise. Randomized runs are + skipped. + :param grouping: One of :data:`GROUPINGS`. + :param min_count: Minimum observations a group needs for a finite value. + :returns: A :class:`GroupCorrelationMatrix` over every model in ``result``. + """ + groups = _shared_group_axis(result, grouping) + import pandas as pd + + group_index = pd.Index(groups) + n_groups = len(groups) + + model_names: list[str] = [] + rows: list[np.ndarray] = [] + for model in result.models: + runs = _scoring_runs(model) + if not runs: + continue + sums = _PearsonSums(n_groups) + for run in runs: + codes = group_index.get_indexer(group_labels(run, grouping)) + sums.add(codes, run.predictions, run.ground_truth) + model_names.append(model.model_name) + rows.append(sums.correlations(min_count=min_count).astype(np.float32)) + + values = np.stack(rows) if rows else np.empty((0, n_groups), dtype=np.float32) + return GroupCorrelationMatrix( + grouping=grouping, + model_names=tuple(model_names), + group_names=tuple(str(name) for name in groups), + values=values, + ) diff --git a/drevalpy/visualization/plots/_utils.py b/drevalpy/visualization/plots/_utils.py new file mode 100644 index 000000000..29abf5bb3 --- /dev/null +++ b/drevalpy/visualization/plots/_utils.py @@ -0,0 +1,89 @@ +"""Shared utilities for visualization plot implementations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from numpy.typing import ArrayLike + +if TYPE_CHECKING: + import pandas as pd + + from drevalpy.types.results import ExperimentResult + +MODEL_COLORS: list[str] = [ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", +] + + +def model_color_palette(model_names: list[str]) -> dict[str, str]: + """Assign a distinct color to each model name. + + Colors cycle through MODEL_COLORS if there are more models than colors. + + :param model_names: List of model names. + :returns: Mapping from model name to hex color string. + """ + return {name: MODEL_COLORS[i % len(MODEL_COLORS)] for i, name in enumerate(model_names)} + + +def compute_ssmd(values_a: ArrayLike, values_b: ArrayLike) -> float: + """Compute the Strictly Standardized Mean Difference between two arrays. + + SSMD = (mean_a - mean_b) / sqrt(var_a + var_b) + + :param values_a: Metric values for model A (across CV folds). + :param values_b: Metric values for model B (across CV folds). + :returns: SSMD value, or NaN if the denominator is zero. + """ + a = np.asarray(values_a, dtype=float) + b = np.asarray(values_b, dtype=float) + mu_a, mu_b = a.mean(), b.mean() + var_a, var_b = a.var(ddof=1), b.var(ddof=1) + denom = var_a + var_b + if denom <= 0: + return float("nan") + return float((mu_a - mu_b) / np.sqrt(denom)) + + +def runs_frame(result: ExperimentResult, *, indexed: bool = False) -> pd.DataFrame: + """Flatten every run of *result* into one row per model, setting and fold. + + Columns are ``algorithm``, ``rand_setting``, ``test_mode``, ``CV_split`` plus + one per metric the run reports. + + :param result: Experiment result to flatten. + :param indexed: Label each row ``___split_``. The + heatmap and the cross-study table group on that label; the violin plot + wants the default positional index. + :returns: The per-run frame. + """ + import pandas as pd + + rows: list[dict] = [] + labels: list[str] = [] + for model in result.models: + for run in model.runs: + setting = f"{run.randomization[0]}_{run.randomization[1]}" if run.randomization else "predictions" + row: dict = { + "algorithm": run.model_name, + "rand_setting": setting, + "test_mode": result.split_mode, + "CV_split": run.fold_index, + } + row.update(run.metrics) + rows.append(row) + labels.append(f"{run.model_name}_{setting}_{result.split_mode}_split_{run.fold_index}") + if indexed: + return pd.DataFrame(rows, index=labels) + return pd.DataFrame(rows) diff --git a/drevalpy/visualization/plots/comparison_scatter.py b/drevalpy/visualization/plots/comparison_scatter.py new file mode 100644 index 000000000..d27f213b2 --- /dev/null +++ b/drevalpy/visualization/plots/comparison_scatter.py @@ -0,0 +1,286 @@ +"""Comparison scatter plot: per-group correlation of one model against another. + +One point per drug (or per cell line), not per prediction. Both axes carry the +same quantity for two different models, so the identity line reads directly as +"these two models are equally good on this group"; systematic deviation means one +model dominates. Model selection is a pair of Plotly dropdowns, so the payload is +one correlation vector per model rather than one point cloud per model pair. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.registry.visualization import register +from drevalpy.visualization.base import PlotlyVisualization, Section +from drevalpy.visualization.plots._group_metrics import ( + GROUPING_LABELS, + GROUPINGS, + GroupCorrelationMatrix, + model_group_correlations, +) +from drevalpy.visualization.requirements import PlotRequirement + +if TYPE_CHECKING: + import plotly.graph_objects as go + + from drevalpy.types.results import ExperimentResult + +#: Correlations live in [-1, 1]; fixing the range keeps the identity line at 45 +#: degrees when a dropdown swaps in a model with a narrower spread. +_AXIS_RANGE = (-1.05, 1.05) + +#: Index of the trace the dropdowns restyle. Trace 1 is the reference line and +#: must be left alone. +_POINTS_TRACE = 0 + + +def _axis_title(grouping: str, model_name: str) -> str: + return f"{model_name} (per-{GROUPING_LABELS[grouping]} Pearson)" + + +def _axis_layout(grouping: str, model_name: str) -> dict: + """Return an axis layout dict. + + Plotly 3 silently discards a bare string under ``title`` in a relayout + payload, leaving the axis unlabelled, so the nested form is used throughout. + + :param grouping: One of the supported groupings. + :param model_name: Model shown on this axis. + :returns: A layout fragment carrying the title and the fixed range. + """ + return {"title": {"text": _axis_title(grouping, model_name)}, "range": list(_AXIS_RANGE)} + + +def _dropdown_buttons(matrix: GroupCorrelationMatrix, axis: str) -> list[dict]: + """Build the update buttons for one axis of one grouping. + + Each button carries a single model's correlation vector, so the whole figure + holds ``n_models x n_groups`` numbers twice over rather than anything + quadratic in the number of models. + + The trace index is passed explicitly: without it Plotly applies the restyle + cyclically across every trace and the dashed reference line is overwritten + with the model's data. + + :param matrix: Correlations to build buttons from. + :param axis: ``"x"`` or ``"y"``. + :returns: One Plotly ``updatemenu`` button per model. + """ + key = "xaxis" if axis == "x" else "yaxis" + buttons = [] + for index, model_name in enumerate(matrix.model_names): + values = np.nan_to_num(matrix.values[index], nan=0.0).astype(float).tolist() + buttons.append( + { + "label": model_name, + "method": "update", + "args": [ + {axis: [values]}, + {key: _axis_layout(matrix.grouping, model_name)}, + [_POINTS_TRACE], + ], + } + ) + return buttons + + +def _build_figure(matrix: GroupCorrelationMatrix) -> go.Figure: + """Build the two-dropdown comparison figure for one grouping. + + :param matrix: Correlations to plot. May be empty, in which case an empty + figure is returned. + :returns: A Plotly figure with one scatter trace and two ``updatemenus``. + """ + import plotly.graph_objects as go + + fig = go.Figure() + if matrix.is_empty: + return fig + + first = np.nan_to_num(matrix.values[0], nan=0.0).astype(float) + fig.add_trace( + go.Scatter( + x=first, + y=first, + mode="markers", + marker={"size": 6, "opacity": 0.7}, + customdata=list(matrix.group_names), + hovertemplate=( + f"{GROUPING_LABELS[matrix.grouping].capitalize()}: %{{customdata}}
" + "x: %{x:.3f}
y: %{y:.3f}" + ), + showlegend=False, + ) + ) + fig.add_trace( + go.Scatter( + x=list(_AXIS_RANGE), + y=list(_AXIS_RANGE), + mode="lines", + line={"dash": "dash", "width": 1, "color": "#888888"}, + hoverinfo="skip", + showlegend=False, + ) + ) + + label = GROUPING_LABELS[matrix.grouping] + first_model = matrix.model_names[0] + fig.update_layout( + title={ + "text": f"Per-{label} Pearson correlation, model against model", + "y": 0.99, + "yanchor": "top", + }, + showlegend=False, + xaxis=_axis_layout(matrix.grouping, first_model), + yaxis=_axis_layout(matrix.grouping, first_model), + annotations=[ + { + "text": "x-axis model:", + "showarrow": False, + "x": 0, + "xref": "paper", + "xanchor": "left", + "y": 1.12, + "yref": "paper", + "yanchor": "bottom", + }, + { + "text": "y-axis model:", + "showarrow": False, + "x": 0.5, + "xref": "paper", + "xanchor": "left", + "y": 1.12, + "yref": "paper", + "yanchor": "bottom", + }, + ], + updatemenus=[ + { + "buttons": _dropdown_buttons(matrix, "x"), + "direction": "down", + "showactive": True, + "x": 0.0, + "xanchor": "left", + "y": 1.12, + "yanchor": "top", + }, + { + "buttons": _dropdown_buttons(matrix, "y"), + "direction": "down", + "showactive": True, + "x": 0.5, + "xanchor": "left", + "y": 1.12, + "yanchor": "top", + }, + ], + margin={"t": 130}, + plot_bgcolor="#e5ecf6", + xaxis_gridcolor="white", + yaxis_gridcolor="white", + ) + return fig + + +def _inline_plotly_html(fig: go.Figure, div_id: str) -> str: + """Render a figure as HTML against MultiQC's already-bundled Plotly. + + MultiQC's default template includes ``plotly-3.1.2.custom.min.js`` in the + document head and that bundle assigns ``window.Plotly``, so the report needs + no second copy of the library - only the data and a ``newPlot`` call. The + Plotly default styling template is stripped from the layout for the same + reason: it is ~18 kB of defaults the browser already has. + + :param fig: Figure to render. + :param div_id: DOM id for the container div; must be unique in the report. + :returns: A self-contained HTML fragment. + """ + from plotly.utils import PlotlyJSONEncoder + + spec = fig.to_plotly_json() + layout = {key: value for key, value in spec["layout"].items() if key != "template"} + payload = json.dumps({"data": spec["data"], "layout": layout}, cls=PlotlyJSONEncoder) + return ( + f'
\n' + "" + ) + + +@register( + "comparison_scatter", + "Per-drug and per-cell-line correlation compared between two selectable models", + requirements=frozenset({PlotRequirement.MULTIPLE_MODELS}), +) +class ComparisonScatterVisualization(PlotlyVisualization): + """Model-against-model comparison of per-group correlation (Plotly).""" + + def __init__(self) -> None: + """Initialize with empty state.""" + self._fig: go.Figure | None = None + self._matrices: dict[str, GroupCorrelationMatrix] = {} + + def compute(self, result: ExperimentResult, dataset=None) -> None: + """Compute per-group correlations for every model and build the figure. + + Retains one ``float32`` matrix of shape ``n_models x n_groups`` per + grouping, independent of the number of predictions. + + :param result: Experiment result with at least two models. + :param dataset: Unused; accepted for interface compatibility. + """ + import plotly.graph_objects as go + + self._matrices = {} + for grouping in GROUPINGS: + matrix = model_group_correlations(result, grouping).drop_all_nan_models() + if matrix.n_models >= 2 and matrix.n_groups > 0: + self._matrices[grouping] = matrix + self._fig = _build_figure(self._primary_matrix()) if self._matrices else go.Figure() + + def _primary_matrix(self) -> GroupCorrelationMatrix: + """Return the matrix backing ``_fig``, i.e. the first available grouping.""" + return self._matrices[next(iter(self._matrices))] + + def to_multiqc(self) -> list[Section]: + """Return one Section per grouping, each holding a two-dropdown figure. + + :returns: Sections for the drug and cell-line groupings, in that order. + Empty when fewer than two models have defined correlations. + :raises RuntimeError: If called before ``compute()``. + """ + if self._fig is None: + raise RuntimeError("Call compute() before to_multiqc()") + + sections: list[Section] = [] + for grouping, matrix in self._matrices.items(): + label = GROUPING_LABELS[grouping] + anchor = f"dreval_comp_scatter_{grouping}" + figure = self._fig if matrix is self._primary_matrix() else _build_figure(matrix) + sections.append( + Section( + name=f"{label.capitalize()}-wise comparison", + anchor=anchor, + description=( + f"Pearson correlation of predictions against ground truth within each {label}, " + f"for {matrix.n_models} models over {matrix.n_groups} {label}s. " + "Pick the model on each axis with the dropdowns; points above the diagonal " + "favour the model on the y-axis." + ), + content=_inline_plotly_html(figure, f"{anchor}_div"), + ) + ) + return sections diff --git a/drevalpy/visualization/plots/critical_difference.py b/drevalpy/visualization/plots/critical_difference.py new file mode 100644 index 000000000..429e76f0b --- /dev/null +++ b/drevalpy/visualization/plots/critical_difference.py @@ -0,0 +1,287 @@ +"""Critical difference diagram visualization (Matplotlib via ImageVisualization). + +This module deliberately does **not** call :func:`matplotlib.use`. It used to +select the ``agg`` backend at import time, but ``drevalpy.registry`` imports +every builtin visualization on ``import drevalpy``, so that one line switched the +backend for the whole process and silently disabled inline plotting in any +notebook that merely imported the library. Matplotlib already falls back to +``agg`` when no display is available, so headless rendering needs no help, and +:meth:`ImageVisualization.to_png` writes through ``Figure.savefig``, which is +backend-independent. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.evaluation import MINIMIZATION_METRICS +from drevalpy.log import get_logger +from drevalpy.registry.visualization import register +from drevalpy.visualization._metric_names import metric_keys, resolve_metric_key +from drevalpy.visualization.base import ImageVisualization +from drevalpy.visualization.requirements import PlotRequirement + +if TYPE_CHECKING: + import pandas as pd + from matplotlib.axes import Axes + from matplotlib.figure import Figure + + from drevalpy.types.results import ExperimentResult + +logger = get_logger(__name__) + +warnings.filterwarnings("ignore", category=FutureWarning, message=".*swapaxes.*") + + +def _build_cd_df(result: ExperimentResult, metric: str) -> pd.DataFrame: + """Build DataFrame for CD plot: columns algorithm, CV_split, . + + The metric is looked up through :func:`resolve_metric_key`, so a result that + stores its normalized metrics under the legacy ``": normalized"`` spelling + still yields values instead of a silently all-NaN column. + + :param result: Experiment to rank. + :param metric: Plain metric name to rank by. + :returns: One row per non-randomized run, with NaN rows dropped. + """ + import pandas as pd + + key = resolve_metric_key(metric_keys(result), metric) + rows: list[dict] = [] + for model in result.models: + for run in model.runs: + if run.randomization is not None: + continue + rows.append( + { + "algorithm": run.model_name, + "CV_split": run.fold_index, + metric: run.metrics.get(key, float("nan")) if key else float("nan"), + } + ) + return pd.DataFrame(rows, columns=["algorithm", "CV_split", metric]).dropna(subset=[metric]) + + +def _generate_discrete_palette(n_colors: int) -> list[str]: + import matplotlib + import plotly.colors as pc + + base_palette = pc.qualitative.D3 + base_n = len(base_palette) + if n_colors <= base_n: + return list(base_palette[:n_colors]) + base_rgb = np.array([matplotlib.colors.to_rgb(c) for c in base_palette]) + target_indices = np.linspace(0, base_n - 1, n_colors) + interpolated_rgb = np.array([np.interp(target_indices, np.arange(base_n), base_rgb[:, i]) for i in range(3)]).T + return [matplotlib.colors.to_hex(c) for c in interpolated_rgb] + + +# --- CD layout logic --- + + +def _nonsignificant_adjacency(sig_matrix: pd.DataFrame) -> pd.DataFrame: + import pandas as pd + from scikit_posthocs import sign_array + + return pd.DataFrame( + 1 - sign_array(sig_matrix), + index=sig_matrix.index, + columns=sig_matrix.columns, + dtype=bool, + ) + + +def _crossbar_sets_from_adjacency(adj_matrix: pd.DataFrame) -> dict[str, set[str]]: + crossbar_sets: dict[str, set[str]] = {} + for alg, row in adj_matrix.iterrows(): + not_different = adj_matrix.columns[row].tolist() + crossbar_sets[alg] = set(not_different).union({alg}) + return crossbar_sets + + +def _draw_crossbars( + ax: Axes, + ranks: pd.Series, + crossbar_sets: dict[str, set[str]], + color_palette: dict, + crossbar_props: dict, +) -> float: + ypos = -0.5 + for alg in ranks.index: + bar = crossbar_sets[alg] + if len(bar) == 1: + continue + props = {**crossbar_props, "color": color_palette[alg]} + ax.plot([ranks[i] for i in bar], [ypos] * len(bar), **props) + ypos -= 0.5 + return ypos + + +def _plot_rank_items( + ax: Axes, + points: pd.Series, + *, + xpos: float, + label_fmt: str, + color_palette: dict, + label_props: dict, + elbow_props: dict, + marker_props: dict, + ypos_start: float, +) -> None: + ypos = ypos_start + for label, rank in points.items(): + color = color_palette[label] + plot_kwargs = {**elbow_props, "c": color} + ax.plot([xpos, rank, rank], [ypos, ypos, 0], **plot_kwargs) + ax.scatter(rank, 0, color=color, **marker_props) + ax.text(xpos, ypos, label_fmt.format(label=label, rank=rank), color=color, **label_props) + ypos -= 0.5 + + +def _critical_difference_diagram( + ranks: pd.Series, + sig_matrix: pd.DataFrame, + color_palette: dict, + ax: Axes | None = None, +) -> None: + """Draw the critical difference diagram on the given axes.""" + from matplotlib.figure import Figure + + elbow_props: dict = {} + marker_props = {"zorder": 3} + label_props = {"va": "center", "fontsize": 16, "weight": "heavy"} + crossbar_props = {"color": "k", "zorder": 3, "linewidth": 4} + text_h_margin = 0.01 + + ax = ax if ax is not None else Figure().add_subplot() + ax.yaxis.set_visible(False) + for spine in ("right", "left", "bottom"): + ax.spines[spine].set_visible(False) + ax.xaxis.set_ticks_position("top") + ax.spines["top"].set_position("zero") + + adj_matrix = _nonsignificant_adjacency(sig_matrix) + ranks_sorted = ranks.sort_values() + crossbar_sets = _crossbar_sets_from_adjacency(adj_matrix) + lowest_y = _draw_crossbars(ax, ranks_sorted, crossbar_sets, color_palette, crossbar_props) + + left_points_n = len(ranks_sorted) // 2 + points_left = ranks_sorted.iloc[:left_points_n] + points_right = ranks_sorted.iloc[left_points_n:] + + _plot_rank_items( + ax, + points_left, + xpos=points_left.iloc[0] - text_h_margin, + label_fmt="{label} ({rank:.2g})", + color_palette=color_palette, + label_props={"ha": "right", **label_props}, + elbow_props=elbow_props, + marker_props=marker_props, + ypos_start=lowest_y - 0.5, + ) + + if len(points_right) > 0: + _plot_rank_items( + ax, + points_right[::-1], + xpos=points_right.iloc[-1] + text_h_margin, + label_fmt="({rank:.2g}) {label}", + color_palette=color_palette, + label_props={"ha": "left", **label_props}, + elbow_props=elbow_props, + marker_props=marker_props, + ypos_start=lowest_y - 0.5, + ) + + +@register( + "critical_difference", + "Critical difference diagram with Friedman test and model rankings", + requirements=frozenset({PlotRequirement.MULTIPLE_MODELS, PlotRequirement.MULTIPLE_FOLDS}), +) +class CriticalDifferenceVisualization(ImageVisualization): + """Critical difference rank diagram using Matplotlib.""" + + def __init__(self) -> None: + """Initialize with empty state.""" + self._result: ExperimentResult | None = None + self._metric: str = "MSE" + self._fig: Figure | None = None + + def compute(self, result: ExperimentResult, dataset=None, metric: str = "MSE") -> None: + """Compute rankings and Friedman test, then create the CD figure. + + :param result: Experiment result with multiple models and folds. + :param metric: Metric to rank models by. + """ + self._result = result + self._metric = metric + self._fig = self._create_figure() + + def _create_figure(self) -> Figure: + """Create the critical difference diagram figure.""" + import pandas as pd + import scikit_posthocs as sp + from matplotlib.figure import Figure + from scipy import stats + + result = self._result + metric = self._metric + + eval_df = _build_cd_df(result, metric) + if eval_df.empty: + logger.warning( + "critical_difference: no finite %s values across folds; emitting a placeholder panel", metric + ) + fig = Figure(figsize=(10, 4)) + ax = fig.add_subplot() + ax.text(0.5, 0.5, "No data available", ha="center", va="center") + return fig + + if metric in MINIMIZATION_METRICS: + eval_df[metric] = -eval_df[metric] + + input_friedman = eval_df.groupby("algorithm")[metric].apply(list) + table_lengths = input_friedman.apply(len) + most_common_length = table_lengths.mode().values[0] + input_friedman = input_friedman[table_lengths == most_common_length] + algorithms_included = set(input_friedman.index) + + # scipy's Friedman test needs at least three comparable groups. + if len(algorithms_included) < 3: + logger.warning( + "critical_difference: only %d models share %d folds of %s; skipping the Friedman test", + len(algorithms_included), + most_common_length, + metric, + ) + fig = Figure(figsize=(10, 4)) + ax = fig.add_subplot() + ax.text(0.5, 0.5, "Not enough comparable models", ha="center", va="center") + return fig + + friedman_p_value = stats.friedmanchisquare(*input_friedman).pvalue + + eval_df = eval_df[eval_df["algorithm"].isin(algorithms_included)] + input_conover = eval_df.pivot_table(index="CV_split", columns="algorithm", values=metric) + test_results = pd.DataFrame(sp.posthoc_conover_friedman(input_conover, p_adjust="fdr_bh")) + average_ranks = input_conover.rank(ascending=False, axis=1).mean(axis=0) + + fig = Figure(figsize=(12, max(4, len(algorithms_included) * 0.8))) + ax = fig.add_subplot() + ax.set_title( + f"Critical Difference Diagram: Metric: {metric}.\nOverall Friedman-Chi2 p-value: {friedman_p_value:.2e}", + fontsize=20, + ) + + generated_colors = _generate_discrete_palette(len(input_conover.columns)) + color_palette = {alg: generated_colors[i] for i, alg in enumerate(input_conover.columns)} + + _critical_difference_diagram(ranks=average_ranks, sig_matrix=test_results, color_palette=color_palette, ax=ax) + + return fig diff --git a/drevalpy/visualization/plots/cross_study_table.py b/drevalpy/visualization/plots/cross_study_table.py new file mode 100644 index 000000000..8321cacf8 --- /dev/null +++ b/drevalpy/visualization/plots/cross_study_table.py @@ -0,0 +1,187 @@ +"""Cross-study table visualization (Plotly table + MultiQC table).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from drevalpy.registry.visualization import register +from drevalpy.visualization.base import PlotlyVisualization, Section +from drevalpy.visualization.plots._utils import runs_frame + +if TYPE_CHECKING: + import pandas as pd + import plotly.graph_objects as go + + from drevalpy.types.results import ExperimentResult + +_METRICS = [ + "MSE", + "RMSE", + "MAE", + "R^2", + "Pearson", + "Spearman", + "Kendall", + "Pearson: normalized", + "Spearman: normalized", + "Kendall: normalized", + "R^2: normalized", +] + + +@register( + "cross_study_table", + "Summary table of model metrics for cross-study evaluation", +) +class CrossStudyTableVisualization(PlotlyVisualization): + """Tabular summary of model performance for cross-study predictions (Plotly table).""" + + def __init__(self) -> None: + """Initialize with empty state.""" + self._fig: go.Figure | None = None + self._result: ExperimentResult | None = None + self._figures: dict[str, go.Figure] = {} + self._mean_metrics: list[pd.DataFrame] = [] + self._std_metrics: list[pd.DataFrame] = [] + self._cross_study_datasets: list[str] = [] + + def compute(self, result: ExperimentResult, dataset=None) -> None: + """Build data for cross-study evaluation tables. + + :param result: Experiment result to summarize. + """ + import plotly.graph_objects as go + + self._result = result + df = runs_frame(result, indexed=True) + + cross_study_settings = df[df["rand_setting"].str.contains("cross-study-")]["rand_setting"].unique() + self._cross_study_datasets = [s.split("cross-study-")[1] for s in cross_study_settings] + + filtered = df[df["rand_setting"].isin(cross_study_settings)] + + self._mean_metrics = [] + self._std_metrics = [] + for dataset in self._cross_study_datasets: + ds_df = filtered[filtered["rand_setting"].str.contains(f"cross-study-{dataset}")] + groups = [s.split("_split_")[0] for s in ds_df.index] + available_metrics = [m for m in _METRICS if m in ds_df.columns] + grouped = ds_df[available_metrics].groupby(groups) + mean = grouped.mean() + std = grouped.std() + if "MSE" in mean.columns: + mean = mean.sort_values(by="MSE") + std = std.loc[mean.index] + mean.index = [s.split("_cross-study")[0] for s in mean.index] + std.index = mean.index + self._mean_metrics.append(mean) + self._std_metrics.append(std) + + self._figures = {} + for dataset_name, mean_df, std_df in zip( + self._cross_study_datasets, self._mean_metrics, self._std_metrics, strict=False + ): + formatted = mean_df.map(lambda x: f"{x:.3f}") + " ± " + std_df.map(lambda x: f"{x:.3f}") + fig = go.Figure( + data=[ + go.Table( + header={ + "values": ["Model"] + list(formatted.columns), + "fill_color": "lightgrey", + "align": "left", + }, + cells={ + "values": [formatted.index] + [formatted[col].values for col in formatted.columns], + "fill_color": "white", + "align": "left", + }, + ) + ] + ) + fig.update_layout(title_text=f"Evaluation Metrics for Cross-Study Predictions to {dataset_name}") + self._figures[dataset_name] = fig + + if self._figures: + self._fig = next(iter(self._figures.values())) + else: + self._fig = _build_simple_table(result) + + def to_multiqc(self) -> list[Section]: + """Return MultiQC table Sections.""" + if self._result is None: + raise RuntimeError("Call compute() before to_multiqc()") + try: + from multiqc.plots import table as mqc_table + except ImportError as e: + raise ImportError("multiqc is required for to_multiqc(). Install with: pip install drevalpy[report]") from e + + metric_names = sorted({m for model in self._result.models for m in model.aggregate_metrics}) + + table_data: dict[str, dict[str, float]] = {} + for model in self._result.models: + row: dict[str, float] = {} + for metric in metric_names: + agg = model.aggregate_metrics.get(metric) + if agg: + row[f"{metric}_mean"] = agg["mean"] + row[f"{metric}_std"] = agg["std"] + table_data[model.model_name] = row + + headers: dict[str, dict[str, str]] = {} + for metric in metric_names: + headers[f"{metric}_mean"] = { + "title": f"{metric} (mean)", + "description": f"Mean {metric} across folds", + "format": "{:,.4f}", + } + headers[f"{metric}_std"] = { + "title": f"{metric} (std)", + "description": f"Std of {metric} across folds", + "format": "{:,.4f}", + } + + plot = mqc_table.plot( + table_data, + headers, + pconfig={"id": "dreval_summary_table", "title": "Model Summary"}, + ) + + return [ + Section( + name="Model Summary Table", + anchor="dreval_summary_table", + description="Aggregate performance metrics (mean ± std) per model.", + plot=plot, + ) + ] + + +def _build_simple_table(result: ExperimentResult) -> go.Figure: + """Build a simple Plotly table from aggregate metrics when no cross-study data is present.""" + import plotly.graph_objects as go + + metric_names = sorted({m for model in result.models for m in model.aggregate_metrics}) + model_names = [m.model_name for m in result.models] + + header_vals = ["Model"] + metric_names + cell_values: list[list] = [model_names] + for metric in metric_names: + col: list[str] = [] + for model in result.models: + agg = model.aggregate_metrics.get(metric) + if agg: + col.append(f"{agg['mean']:.3f} ± {agg['std']:.3f}") + else: + col.append("N/A") + cell_values.append(col) + + fig = go.Figure( + data=[ + go.Table( + header={"values": header_vals, "fill_color": "lightgrey", "align": "left"}, + cells={"values": cell_values, "fill_color": "white", "align": "left"}, + ) + ] + ) + fig.update_layout(title_text="Model Performance Summary") + return fig diff --git a/drevalpy/visualization/plots/heatmap.py b/drevalpy/visualization/plots/heatmap.py new file mode 100644 index 000000000..6be7bbfc4 --- /dev/null +++ b/drevalpy/visualization/plots/heatmap.py @@ -0,0 +1,259 @@ +"""Heatmap visualization (Plotly + MultiQC heatmap).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.log import get_logger +from drevalpy.registry.visualization import register +from drevalpy.visualization._metric_names import resolve_metric_key +from drevalpy.visualization.base import PlotlyVisualization, Section +from drevalpy.visualization.plots._utils import runs_frame +from drevalpy.visualization.requirements import PlotRequirement + +if TYPE_CHECKING: + import pandas as pd + import plotly.graph_objects as go + + from drevalpy.types.results import ExperimentResult + +logger = get_logger(__name__) + +_ALL_METRICS = [ + "R^2", + "Pearson", + "Spearman", + "Kendall", + "MSE", + "RMSE", + "MAE", +] + + +def _setting_groups(df: pd.DataFrame) -> pd.Series: + idx_split = df.index.to_series().str.split("_") + return idx_split.str[0:3].str.join("_") + + +def _calc_summary_metric(x: pd.DataFrame, std_error: bool = False) -> pd.Series: + import pandas as pd + + results = pd.Series(index=x.columns, dtype=float) + for col in x.columns: + if np.count_nonzero(np.isnan(x[col].values.astype(float))) == len(x[col]): + results[col] = np.nan + elif std_error: + results[col] = np.nanstd(x[col].values.astype(float)) / np.sqrt(x.shape[0]) + else: + results[col] = np.nanmean(x[col].values.astype(float)) + return results + + +def _compute_ssmd(df: pd.DataFrame, metric: str) -> pd.DataFrame: + import pandas as pd + + if metric not in df.columns: + return pd.DataFrame() + + df = df.copy() + df["model_name"] = df.index.to_series().apply(lambda x: x.split("_predictions")[0]) + models = df["model_name"].unique() + ssmd_matrix = pd.DataFrame(index=models, columns=models, dtype=float) + + for m1 in models: + for m2 in models: + if m1 == m2: + ssmd_matrix.loc[m1, m2] = 0.0 + continue + values_m1 = df[df["model_name"] == m1][metric].astype(float) + values_m2 = df[df["model_name"] == m2][metric].astype(float) + mu1, mu2 = values_m1.mean(), values_m2.mean() + sigma1_sq, sigma2_sq = values_m1.var(ddof=1), values_m2.var(ddof=1) + denom = sigma1_sq + sigma2_sq + ssmd_matrix.loc[m1, m2] = (mu1 - mu2) / np.sqrt(denom) if denom > 0 else np.nan + + return ssmd_matrix.astype(float) + + +def _resolve_metric_columns(result: ExperimentResult, df: pd.DataFrame) -> tuple[pd.DataFrame, list[str]]: + """Select one column per base metric, accepting the legacy suffixed spelling. + + ``normalize()`` emits plain metric names, but results serialized by older + releases carry the normalized copy under ``": normalized"``. Both are + surfaced here under the plain name so the panels are never silently blank. + + :param result: Experiment the frame was built from. + :param df: Flat per-run frame including the metric columns. + :returns: ``(frame, columns)`` where the frame has base-named metric columns. + """ + available = set(df.columns) + renames: dict[str, str] = {} + columns: list[str] = [] + for base in _ALL_METRICS: + key = resolve_metric_key(available, base) + if key is None: + continue + if key != base: + renames[key] = base + columns.append(base) + if renames: + df = df.rename(columns=renames) + return df, columns + + +@register( + "heatmap", + "Heatmap of mean metrics per model", + requirements=frozenset({PlotRequirement.MULTIPLE_FOLDS}), +) +class HeatmapVisualization(PlotlyVisualization): + """Heatmap showing mean metric values (rows=models, cols=metrics) with SSMD subplots.""" + + def __init__(self) -> None: + """Initialize with empty state.""" + self._fig: go.Figure | None = None + self._result: ExperimentResult | None = None + + def compute(self, result: ExperimentResult, dataset=None) -> None: + """Build the Plotly heatmap figure with mean metrics and SSMD panels. + + :param result: Experiment result with multiple folds. + """ + import plotly.graph_objects as go + from plotly.subplots import make_subplots + + self._result = result + df = runs_frame(result, indexed=True) + df, metric_cols = _resolve_metric_columns(result, df) + if not metric_cols: + logger.warning("heatmap: none of the expected metrics are present; the panels will be empty") + df_metrics = df[metric_cols] + + setting = _setting_groups(df) + + plot_settings = ["r2", "correlations", "errors", "ssmd_R^2", "ssmd_MSE"] + titles = [ + "Mean R^2", + "Mean Correlations", + "Mean Errors", + "SSMD for R^2", + "SSMD for MSE", + ] + + self._fig = make_subplots( + rows=len(plot_settings), + cols=1, + subplot_titles=tuple(titles), + vertical_spacing=0.1, + ) + + for idx, ps in enumerate(plot_settings, start=1): + if ps.startswith("ssmd_"): + metric_name = ps.split("_", 1)[1] + dt = _compute_ssmd(df, metric_name) + if dt.empty: + continue + dt["sort_key"] = dt.max(axis=1) + dt = dt.sort_values(by="sort_key", ascending=True).drop(columns=["sort_key"]) + dt = dt[dt.index] + text_labels = dt.round(3).astype(str) + labels = list(dt.index) + self._fig.add_trace( + go.Heatmap( + z=dt.values, + x=list(dt.columns), + y=labels, + colorscale="RdBu", + texttemplate="%{text}", + text=text_labels.values, + textfont={"size": 12}, + showscale=False, + ), + row=idx, + col=1, + ) + else: + columns = _columns_for_setting(ps, metric_cols) + if not columns: + continue + colorscale = {"r2": "Blues", "correlations": "Viridis", "errors": "hot"}[ps] + ascending = ps != "errors" + + dt = df_metrics[columns].groupby(setting).apply(lambda x: _calc_summary_metric(x)) + dt = dt.sort_values(by=columns[0], ascending=ascending) + dt_std = df_metrics[columns].groupby(setting).apply(lambda x: _calc_summary_metric(x, std_error=True)) + dt_std = dt_std.loc[dt.index] + text_labels = dt.round(3).astype(str) + " ± " + dt_std.round(3).astype(str) + labels = [i.split("_")[0] for i in dt.index] + + self._fig.add_trace( + go.Heatmap( + z=dt.values, + x=list(dt.columns), + y=labels, + colorscale=colorscale, + texttemplate="%{text}", + text=text_labels.values, + textfont={"size": 12}, + showscale=False, + ), + row=idx, + col=1, + ) + + n_models = len(result.models) + height_per_model = 35 + new_height = min(500 + n_models * height_per_model, 5000) + self._fig.update_layout( + height=new_height, + width=1300, + title_text="Heatmap of the evaluation metrics", + ) + + def to_multiqc(self) -> list[Section]: + """Return a MultiQC heatmap Section using native heatmap plot API.""" + if self._result is None: + raise RuntimeError("Call compute() before to_multiqc()") + try: + from multiqc.plots import heatmap as mqc_heatmap + except ImportError as e: + raise ImportError("multiqc is required for to_multiqc(). Install with: pip install drevalpy[report]") from e + + metric_names = sorted({m for model in self._result.models for m in model.aggregate_metrics}) + model_names = [m.model_name for m in self._result.models] + + data: list[list[float | None]] = [] + for model in self._result.models: + row: list[float | None] = [] + for metric in metric_names: + agg = model.aggregate_metrics.get(metric) + row.append(agg["mean"] if agg else None) + data.append(row) + + plot = mqc_heatmap.plot( + data, + xcats=metric_names, + ycats=model_names, + pconfig={"id": "dreval_heatmap", "title": "Model Performance Heatmap", "square": False}, + ) + + return [ + Section( + name="Performance Heatmap", + anchor="dreval_heatmap", + description="Mean metric values per model across cross-validation folds.", + plot=plot, + ) + ] + + +def _columns_for_setting(ps: str, metric_cols: list[str]) -> list[str]: + if ps == "r2": + return [c for c in metric_cols if "R^2" in c] + if ps == "correlations": + return [c for c in metric_cols if "Pearson" in c or "Spearman" in c or "Kendall" in c] + if ps == "errors": + return [c for c in metric_cols if c in ("MSE", "RMSE", "MAE")] + return [] diff --git a/drevalpy/visualization/plots/leaderboard.py b/drevalpy/visualization/plots/leaderboard.py new file mode 100644 index 000000000..9b00e658d --- /dev/null +++ b/drevalpy/visualization/plots/leaderboard.py @@ -0,0 +1,426 @@ +"""Leaderboard visualization (Matplotlib via ImageVisualization). + +``matplotlib`` is imported inside the drawing helpers rather than at module +scope: ``drevalpy.registry`` imports every builtin visualization on +``import drevalpy``, so a module-scope import would put the whole pyplot stack on +the startup path of every CLI invocation. See ``tests/test_import_cost_policy.py``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +from drevalpy.log import get_logger +from drevalpy.registry.visualization import register +from drevalpy.visualization._metric_names import holds_normalized_values, metric_keys, resolve_metric_key +from drevalpy.visualization.base import ImageVisualization +from drevalpy.visualization.requirements import PlotRequirement + +if TYPE_CHECKING: + import pandas as pd + from matplotlib.figure import Figure + + from drevalpy.types.results import ExperimentResult + +logger = get_logger(__name__) + +# --- Theme --- +DARK_THEME = { + "background": "#0d1117", + "surface": "#2d2d2d", + "text": "#ece7e4", + "text_secondary": "#a0a0a0", + "grid": "#30363d", +} + +COMPETITOR_COLOR = "#6A5ACD" + + +def _get_bar_color(rank: int, is_baseline: bool) -> dict[str, Any]: + if is_baseline: + return {"color": "#5a5a5a", "alpha": 1.0} + medal_colors = ["#F4D03F", "#BDC3C7", "#E67E22"] + if rank < len(medal_colors): + return {"color": medal_colors[rank], "alpha": 1.0} + return {"color": COMPETITOR_COLOR, "alpha": 0.85} + + +def _draw_bar(ax, x: float, y: float, width: float, height: float, color: str, alpha: float = 1.0): + from matplotlib.patches import FancyBboxPatch + + bar = FancyBboxPatch( + (x, y - height / 2), + width, + height, + boxstyle="round,pad=0.01,rounding_size=0.015", + facecolor=color, + alpha=alpha, + edgecolor="none", + zorder=3, + ) + ax.add_patch(bar) + return bar + + +def _axis_bounds(values: np.ndarray, stds: np.ndarray) -> tuple[float, float]: + """Compute padded x-axis bounds that stay finite for any input. + + Metrics can be NaN (a fold whose metric could not be computed) or negative + (a normalized correlation below the reference model), so the bounds are + derived from the finite values only and fall back to a unit axis when there + are none. Without this an all-NaN column made ``set_xlim`` raise + ``ValueError: Axis limits cannot be NaN or Inf`` and took the whole report + down with it. + + :param values: Metric values, possibly containing NaN. + :param stds: Matching standard deviations, possibly containing NaN. + :returns: ``(left, right)`` limits, always finite with ``left < right``. + """ + stds = np.nan_to_num(stds, nan=0.0) + with np.errstate(invalid="ignore"): + finite_high = values + stds + finite_low = values - stds + if not np.isfinite(finite_high).any(): + return -0.06, 1.0 + high = float(np.nanmax(finite_high)) + low = min(0.0, float(np.nanmin(finite_low))) + span = high - low + if not np.isfinite(span) or span <= 0: + span = max(abs(high), 1.0) + return low - span * 0.06, high + span * 0.18 + + +def _draw_ranked_metric_axis( + ax, + df: pd.DataFrame, + *, + metric_col: str, + std_col: str, + bar_height: float, + font_adder: int, + colors: dict[str, str], + ascending: bool, + xlabel: str, + title: str, + title_color: str, +) -> None: + ax.set_facecolor(colors["background"]) + df_metric = df.dropna(subset=[metric_col]).sort_values(metric_col, ascending=ascending).reset_index(drop=True) + y_positions = np.arange(len(df_metric) - 1, -1, -1) + left, right = _axis_bounds(df_metric[metric_col].to_numpy(dtype=float), df_metric[std_col].to_numpy(dtype=float)) + span = right - left + + for i, (_, row) in enumerate(df_metric.iterrows()): + style = _get_bar_color(i, row["is_baseline"]) + _draw_bar(ax, 0, y_positions[i], row[metric_col], bar_height, style["color"], style["alpha"]) + label_color = style["color"] if not row["is_baseline"] else colors["text_secondary"] + ax.text( + row[metric_col] + span * 0.02, + y_positions[i], + f"{row[metric_col]:.3f}", + va="center", + ha="left", + fontsize=9 + font_adder, + fontweight="bold", + color=label_color, + zorder=5, + ) + if i < 3 and not row["is_baseline"]: + medals = ["\u2460", "\u2461", "\u2462"] + ax.text( + left + span * 0.02, + y_positions[i], + medals[i], + va="center", + ha="center", + fontsize=14 + font_adder, + fontweight="bold", + color=style["color"], + zorder=5, + ) + + ax.set_xlim(left, right) + ax.set_ylim(-0.8, len(df_metric) - 0.2) + ax.set_yticks(y_positions) + ax.set_yticklabels(df_metric["algorithm"].values, fontsize=10 + font_adder) + + for i, label in enumerate(ax.get_yticklabels()): + row = df_metric.iloc[i] + if i < 3 and not row["is_baseline"]: + label.set_fontweight("bold") + label.set_color(_get_bar_color(i, False)["color"]) + elif row["is_baseline"]: + label.set_style("italic") + label.set_color(colors["text_secondary"]) + else: + label.set_color(colors["text"]) + + ax.set_xlabel(xlabel, fontsize=12 + font_adder, fontweight="bold", labelpad=10) + ax.xaxis.grid(True, linestyle="--", alpha=0.3, color=colors["grid"]) + ax.set_axisbelow(True) + ax.tick_params(axis="x", colors=colors["text_secondary"]) + ax.set_title(title, fontsize=14 + font_adder, fontweight="bold", color=title_color, pad=15) + + +def _gradient_char_colors(title_text: str) -> list[str]: + n_chars = len(title_text) + colors_list = [] + for j in range(n_chars): + t = j / max(n_chars - 1, 1) + if t < 0.5: + t2 = t * 2 + r = int(0x14 + (0x29 - 0x14) * t2) + g = int(0xB8 + (0xAB - 0xB8) * t2) + b = int(0xA6 + (0xCA - 0xA6) * t2) + else: + t2 = (t - 0.5) * 2 + r = int(0x29 + (0x9D - 0x29) * t2) + g = int(0xAB + (0x4E - 0xAB) * t2) + b = int(0xCA + (0xDD - 0xCA) * t2) + colors_list.append(f"#{r:02x}{g:02x}{b:02x}") + return colors_list + + +def _draw_gradient_title(fig, title_text: str, font_adder: int, y: float = 0.97) -> None: + title_x_start = 0.5 - len(title_text) * 0.012 + char_colors = _gradient_char_colors(title_text) + for j, char in enumerate(title_text): + fig.text( + title_x_start + j * 0.024, + y, + char, + fontsize=24 + font_adder, + fontweight="bold", + color=char_colors[j], + ha="center", + ) + + +def _draw_subtitle( + fig, dataset: str, measure: str, test_mode_label: str, font_adder: int, colors: dict, y: float = 0.92 +) -> None: + fig.text( + 0.5, + y, + f"{dataset} Dataset \u2022 {measure} \u2022 {test_mode_label}", + ha="center", + fontsize=12 + font_adder, + color=colors["text_secondary"], + ) + + +def _draw_legend(fig, font_adder: int, colors: dict, y: float = 0.02) -> None: + import matplotlib.patches as mpatches + + legend_elements = [ + mpatches.Patch(facecolor="#F4D03F", label="#1 Champion", edgecolor="none"), + mpatches.Patch(facecolor="#BDC3C7", label="#2 Runner-up", edgecolor="none"), + mpatches.Patch(facecolor="#E67E22", label="#3 Third Place", edgecolor="none"), + mpatches.Patch(facecolor=COMPETITOR_COLOR, alpha=0.85, label="Competitor", edgecolor="none"), + mpatches.Patch(facecolor="#5a5a5a", alpha=1, label="Baseline", edgecolor="none"), + ] + legend = fig.legend( + handles=legend_elements, + loc="lower center", + ncol=5, + frameon=True, + facecolor=colors["surface"], + edgecolor=colors["grid"], + fontsize=10 + font_adder, + bbox_to_anchor=(0.5, y), + ) + legend.get_frame().set_alpha(0.9) + for text in legend.get_texts(): + text.set_color(colors["text"]) + + +def _get_test_mode_name(test_mode: str) -> str: + names = { + "LCO": "10-Fold Leave-Cell-Out Cross Validation", + "LDO": "10-Fold Leave-Drug-Out Cross Validation", + "LPO": "10-Fold Leave-Pair-Out Cross Validation", + "LTO": "10-Fold Leave-Tissue-Out Cross Validation", + } + return names.get(test_mode, test_mode) + + +def _build_leaderboard_df(result: ExperimentResult) -> pd.DataFrame: + """Build aggregated leaderboard DataFrame from an ExperimentResult. + + ``"Pearson"`` is resolved through :func:`resolve_metric_key`, so the panel + shows the normalized correlation on a normalized experiment and the raw one + otherwise, instead of an all-NaN column when the suffixed key is absent. + + :param result: Experiment to rank. + :returns: One row per model with ``PCC``/``RMSE`` means and standard deviations. + """ + import pandas as pd + + available = metric_keys(result) + pcc_key = resolve_metric_key(available, "Pearson") + rmse_key = resolve_metric_key(available, "RMSE") + rows: list[dict] = [] + for model in result.models: + for run in model.runs: + if run.randomization is not None: + continue + rows.append( + { + "algorithm": run.model_name, + "PCC": run.metrics.get(pcc_key, float("nan")) if pcc_key else float("nan"), + "RMSE": run.metrics.get(rmse_key, float("nan")) if rmse_key else float("nan"), + } + ) + df = pd.DataFrame(rows) + if df.empty: + return pd.DataFrame(columns=["algorithm", "PCC", "PCC_std", "RMSE", "RMSE_std", "is_baseline"]) + + df_agg = df.groupby("algorithm").agg({"PCC": ["mean", "std"], "RMSE": ["mean", "std"]}).reset_index() + df_agg.columns = ["algorithm", "PCC", "PCC_std", "RMSE", "RMSE_std"] + df_agg["PCC_std"] = df_agg["PCC_std"].fillna(0) + df_agg["RMSE_std"] = df_agg["RMSE_std"].fillna(0) + df_agg["is_baseline"] = df_agg["algorithm"].str.startswith("Naive") + return df_agg.sort_values("PCC", ascending=False).reset_index(drop=True) + + +def _figure_geometry(n_models: int) -> tuple[float, int, float]: + """Size the figure so every model keeps a legible tick label. + + The panels carry one tick label per model, so a fixed 12-inch canvas turns + into an unreadable smear past roughly 20 models - the 96-model production + report is the case that matters. Height grows linearly with the model count + and the font shrinks back towards its base size as the list gets long. + + :param n_models: Number of models being ranked. + :returns: ``(height_inches, font_size_offset, bar_height)``. + """ + height = min(max(12.0, 1.4 + 0.34 * n_models), 60.0) + font_adder = 6 if n_models <= 20 else (3 if n_models <= 50 else 1) + return height, font_adder, 0.65 + + +def _pcc_is_normalized(result: ExperimentResult) -> bool: + """Whether the leaderboard's PCC column holds reference-normalized values. + + :param result: Experiment the column was built from. + :returns: True when the values are normalized against a reference model. + """ + key = resolve_metric_key(metric_keys(result), "Pearson") + return key is not None and holds_normalized_values(result, key) + + +@register( + "leaderboard", + "Leaderboard visualization of normalized PCC and RMSE rankings", + requirements=frozenset({PlotRequirement.MULTIPLE_MODELS, PlotRequirement.MULTIPLE_FOLDS}), +) +class LeaderboardVisualization(ImageVisualization): + """Dual-panel leaderboard using Matplotlib.""" + + def __init__(self) -> None: + """Initialize with empty state.""" + self._result: ExperimentResult | None = None + self._fig: Figure | None = None + + def compute(self, result: ExperimentResult, dataset=None) -> None: + """Compute leaderboard rankings and create figure. + + :param result: Experiment result with multiple models and folds. + """ + self._result = result + self._fig = self._create_figure() + + def _create_figure(self) -> Figure: + """Create the leaderboard panels figure.""" + import matplotlib + from matplotlib.figure import Figure + + result = self._result + colors = DARK_THEME + + df = _build_leaderboard_df(result) + if df.empty or not np.isfinite(df[["PCC", "RMSE"]].to_numpy(dtype=float)).any(): + logger.warning("leaderboard: no finite Pearson/RMSE values to rank; emitting a placeholder panel") + fig = Figure(figsize=(10, 4)) + ax = fig.add_subplot() + ax.text(0.5, 0.5, "No data available for leaderboard", ha="center", va="center") + return fig + + normalized = _pcc_is_normalized(result) + n_ranked = int(df[["PCC", "RMSE"]].notna().any(axis=1).sum()) + # One tick label per model, so a 96-model experiment needs ~4x the height a + # 12-model one does or the names overlap into an unreadable smear. + fig_height, font_adder, bar_height = _figure_geometry(n_ranked) + + matplotlib.rcParams.update( + { + "figure.facecolor": colors["background"], + "axes.facecolor": colors["background"], + "axes.edgecolor": colors["grid"], + "axes.labelcolor": colors["text"], + "text.color": colors["text"], + "xtick.color": colors["text"], + "ytick.color": colors["text"], + "grid.color": colors["grid"], + "font.family": "sans-serif", + "font.size": 11 + font_adder, + "axes.spines.top": False, + "axes.spines.right": False, + } + ) + + fig = Figure(figsize=(16, fig_height), facecolor=colors["background"]) + ax1, ax2 = fig.subplots(1, 2) + fig.subplots_adjust(wspace=0.4) + + pcc_label = "Normalized PCC" if normalized else "PCC" + _draw_ranked_metric_axis( + ax1, + df, + metric_col="PCC", + std_col="PCC_std", + bar_height=bar_height, + font_adder=font_adder, + colors=colors, + ascending=False, + xlabel=pcc_label, + title=f"{'Normalized ' if normalized else ''}Pearson \u2191 higher is better", + title_color="#29ABCA", + ) + _draw_ranked_metric_axis( + ax2, + df, + metric_col="RMSE", + std_col="RMSE_std", + bar_height=bar_height, + font_adder=font_adder, + colors=colors, + ascending=True, + xlabel="Root Mean Square Error", + title="RMSE \u2193 lower is better", + title_color="#FF6B9D", + ) + + # Header and footer are placed in figure fractions but should occupy a constant + # number of inches, or a 96-model figure reserves a third of its canvas for them. + header = 1.8 / fig_height + footer = 0.9 / fig_height + _draw_gradient_title(fig, "DrEval Challenge Leaderboard", font_adder, y=1 - header * 0.28) + _draw_subtitle( + fig, + result.dataset_name if hasattr(result, "dataset_name") else "Dataset", + # Every run trains on ``Dataset.response_matrix``, i.e. the response + # modality's X, which curation fills with pEC50. + "pEC50", + _get_test_mode_name(result.split_mode if hasattr(result, "split_mode") else "LCO"), + font_adder, + colors, + y=1 - header * 0.75, + ) + _draw_legend(fig, font_adder, colors, y=footer * 0.25) + + fig.tight_layout(rect=(0, footer, 1, 1 - header)) + + return fig diff --git a/drevalpy/visualization/plots/regression_scatter.py b/drevalpy/visualization/plots/regression_scatter.py new file mode 100644 index 000000000..ebbbfcade --- /dev/null +++ b/drevalpy/visualization/plots/regression_scatter.py @@ -0,0 +1,164 @@ +"""Regression scatter: predicted against observed response, as a hexbin density. + +At production scale one model contributes ~231k predictions across its folds, +which is two orders of magnitude past MultiQC's ``plots_flat_numseries`` cutoff - +the interactive scatter was already being flattened to a static image, and the +per-point payload bought nothing. A log-scaled hexbin shows the same cloud, plus +the density structure that overplotting hides, from two float arrays. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from drevalpy.registry.visualization import register +from drevalpy.visualization.base import ImageVisualization, Section, embedded_png_html + +if TYPE_CHECKING: + from matplotlib.figure import Figure + + from drevalpy.types.results import ModelResult + +#: Hexagons per axis. 40 keeps the bins visible at report width without +#: degenerating into one-observation cells on a 20k-point cloud. +_GRID_SIZE = 40 + + +def _pooled_predictions(result: ModelResult) -> tuple[np.ndarray, np.ndarray]: + """Concatenate the finite (ground truth, prediction) pairs over all folds. + + :param result: Model result whose non-randomized runs to pool. + :returns: Two aligned ``float64`` arrays, empty when nothing is scorable. + """ + truths: list[np.ndarray] = [] + predictions: list[np.ndarray] = [] + for run in result.runs: + if run.randomization is not None: + continue + truth = np.asarray(run.ground_truth, dtype=np.float64) + prediction = np.asarray(run.predictions, dtype=np.float64) + keep = np.isfinite(truth) & np.isfinite(prediction) + truths.append(truth[keep]) + predictions.append(prediction[keep]) + if not truths: + return np.empty(0), np.empty(0) + return np.concatenate(truths), np.concatenate(predictions) + + +def _pearson(x: np.ndarray, y: np.ndarray) -> float: + """Pearson correlation of two aligned arrays, NaN where undefined. + + :param x: First variable. + :param y: Second variable, aligned with ``x``. + :returns: The correlation, or NaN for fewer than two points or zero variance + on either side. + """ + if x.size < 2 or x.std() == 0 or y.std() == 0: + return float("nan") + return float(np.corrcoef(x, y)[0, 1]) + + +@register( + "regression_scatter", + "Density of predicted vs. observed drug response values", + result_type="ModelResult", +) +class RegressionScatterVisualization(ImageVisualization): + """Predicted vs. ground-truth hexbin density for a single model.""" + + def __init__(self) -> None: + """Initialize with empty state.""" + self._fig: Figure | None = None + self._result: ModelResult | None = None + self._ground_truth: np.ndarray = np.empty(0) + self._predictions: np.ndarray = np.empty(0) + + def compute(self, result: ModelResult, dataset=None) -> None: + """Pool the model's predictions and render the density figure. + + :param result: Model result containing predictions and ground truth. + :param dataset: Unused; accepted for interface compatibility. + """ + self._result = result + self._ground_truth, self._predictions = _pooled_predictions(result) + self._fig = self._create_figure() + + def _create_figure(self) -> Figure: + """Create the hexbin figure with an identity line and a fit annotation. + + Built on :class:`matplotlib.figure.Figure` directly rather than through + ``pyplot``, so the figure never enters pyplot's global registry and is + released with this object. + + :returns: The rendered figure. + """ + from matplotlib.figure import Figure + + fig = Figure(figsize=(7, 6.5), layout="constrained") + ax = fig.add_subplot() + model_name = self._result.model_name if self._result is not None else "" + + if self._ground_truth.size == 0: + ax.text(0.5, 0.5, "No data available", ha="center", va="center") + ax.set_axis_off() + return fig + + low = float(min(self._ground_truth.min(), self._predictions.min())) + high = float(max(self._ground_truth.max(), self._predictions.max())) + if low == high: + low, high = low - 0.5, high + 0.5 + + hexes = ax.hexbin( + self._ground_truth, + self._predictions, + gridsize=_GRID_SIZE, + bins="log", + cmap="viridis", + mincnt=1, + extent=(low, high, low, high), + ) + fig.colorbar(hexes, ax=ax, label="Predictions per bin (log scale)") + ax.plot([low, high], [low, high], linestyle="--", linewidth=1, color="#d62728") + + pcc = _pearson(self._ground_truth, self._predictions) + ax.set_title(f"{model_name}: predicted vs. observed response") + ax.set_xlabel("Observed") + ax.set_ylabel("Predicted") + ax.set_xlim(low, high) + ax.set_ylim(low, high) + ax.text( + 0.03, + 0.97, + f"n = {self._ground_truth.size:,}\nPearson = {pcc:.3f}\nR² = {pcc**2:.3f}", + transform=ax.transAxes, + va="top", + ha="left", + fontsize=10, + bbox={"boxstyle": "round", "facecolor": "white", "alpha": 0.8, "edgecolor": "none"}, + ) + return fig + + def to_multiqc(self) -> list[Section]: + """Embed the figure in a Section anchored on the model name. + + The base implementation anchors on ``registry_name`` alone, which would + collide across the one module per model that the report adds. + + :returns: A single-element list. + :raises RuntimeError: If called before ``compute()``. + """ + if self._fig is None or self._result is None: + raise RuntimeError("Call compute() before to_multiqc()") + return [ + Section( + name=f"Regression density: {self._result.model_name}", + anchor=f"dreval_scatter_{self._result.model_name}", + description=( + f"Predicted vs. ground-truth values for {self._result.model_name} " + f"across {self._result.n_folds} fold(s), binned by density." + ), + content=embedded_png_html(self._fig), + ) + ] diff --git a/drevalpy/visualization/plots/violin.py b/drevalpy/visualization/plots/violin.py new file mode 100644 index 000000000..f1a4a5dfd --- /dev/null +++ b/drevalpy/visualization/plots/violin.py @@ -0,0 +1,122 @@ +"""Violin plot visualization (Plotly + MultiQC violin).""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +from drevalpy.log import get_logger +from drevalpy.registry.visualization import register +from drevalpy.visualization.base import PlotlyVisualization, Section +from drevalpy.visualization.plots._utils import runs_frame +from drevalpy.visualization.requirements import PlotRequirement + +if TYPE_CHECKING: + import plotly.graph_objects as go + + from drevalpy.types.results import ExperimentResult + +logger = get_logger(__name__) + +_ALL_METRICS = [ + "R^2", + "R^2: normalized", + "Pearson", + "Pearson: normalized", + "Spearman", + "Spearman: normalized", + "Kendall", + "Kendall: normalized", + "MSE", + "RMSE", + "MAE", +] + + +def _is_finite(value: float) -> bool: + """Whether *value* is a real number MultiQC can plot. + + :param value: Metric value, possibly NaN or non-numeric. + :returns: True if the value is finite. + """ + try: + return math.isfinite(float(value)) + except (TypeError, ValueError): + return False + + +@register( + "violin", + "Violin plots of evaluation metrics across CV folds", + requirements=frozenset({PlotRequirement.MULTIPLE_FOLDS}), +) +class ViolinVisualization(PlotlyVisualization): + """Violin plot showing metric distributions across folds per model.""" + + def __init__(self) -> None: + """Initialize with empty state.""" + self._fig: go.Figure | None = None + self._data: dict[str, dict[str, float]] | None = None + + def compute(self, result: ExperimentResult, dataset=None) -> None: + """Build violin plot figure from per-fold metrics. + + :param result: Experiment result with multiple folds. + """ + import plotly.graph_objects as go + + df = runs_frame(result).sort_index() + df["box"] = df["algorithm"] + "_" + df["rand_setting"] + "_" + df["test_mode"] + df = df.dropna(axis=1, how="all") + + metrics = [m for m in _ALL_METRICS if "normalized" not in m and m in df.columns] + if not metrics: + logger.warning("violin: no metric has a finite value in any fold; the section will be skipped") + + self._fig = go.Figure() + for metric in metrics: + for box in df["box"].unique(): + tmp_df = df[df["box"] == box] + label = box.split("_")[0] + ": " + metric + self._fig.add_trace( + go.Violin( + y=tmp_df[metric], + x=[label] * len(tmp_df[metric]), + name=label, + box_visible=True, + meanline_visible=True, + ) + ) + + self._fig.update_layout(title_text="All Metrics", height=600, width=1100) + + self._data = {} + for model in result.models: + for run in model.runs: + sample_name = f"{model.model_name}_fold{run.fold_index}" + self._data[sample_name] = dict(run.metrics) + + def to_multiqc(self) -> list[Section]: + """Return a MultiQC violin Section using native violin plot API.""" + if self._data is None: + raise RuntimeError("Call compute() before to_multiqc()") + try: + from multiqc.plots import violin as mqc_violin + except ImportError as e: + raise ImportError("multiqc is required for to_multiqc(). Install with: pip install drevalpy[report]") from e + + metric_names = sorted({m for metrics in self._data.values() for m in metrics if _is_finite(metrics[m])}) + if not metric_names: + logger.warning("violin: no metric has a finite value in any fold; skipping the section") + return [] + headers: dict[str, dict[str, str]] = {m: {"title": m, "description": f"Metric: {m}"} for m in metric_names} + plot = mqc_violin.plot(self._data, headers, pconfig={"id": "dreval_violin"}) + + return [ + Section( + name="Metric Distributions", + anchor="dreval_violin", + description="Distribution of evaluation metrics across cross-validation folds.", + plot=plot, + ) + ] diff --git a/drevalpy/visualization/regression_slider_plot.py b/drevalpy/visualization/regression_slider_plot.py deleted file mode 100644 index 3b56ef810..000000000 --- a/drevalpy/visualization/regression_slider_plot.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Module for generating regression plots with a slider for Pearson correlation coefficient.""" - -from io import TextIOWrapper - -import numpy as np -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go -from scipy.stats import pearsonr - -from .outplot import OutPlot - - -class RegressionSliderPlot(OutPlot): - """Generates regression plots with a slider for the Pearson correlation coefficient.""" - - def __init__( - self, - df: pd.DataFrame, - test_mode: str, - model: str, - group_by: str = "drug_name", - normalize=False, - ): - """ - Initialize the RegressionSliderPlot class. - - :param df: true vs. predicted values - :param test_mode: test_mode, e.g., LPO - :param model: model name - :param group_by: either "drug_name" or "cell_line_name" - :param normalize: whether to normalize the true and predicted values by the mean of the group - """ - self.df = df[(df["test_mode"] == test_mode) & (df["rand_setting"] == "predictions")] - model_df = self.df[(self.df["algorithm"] == model)] - self.df = model_df - self.group_by = group_by - self.normalize = normalize - self.fig = go.Figure() - self.model = model - - if self.normalize: - mean_effects_df = df[ - (df["algorithm"] == "NaiveMeanEffectsPredictor") - & (df["test_mode"] == test_mode) - & (df["rand_setting"] == "predictions") - ] - merged_df = model_df.merge( - mean_effects_df, - on=["pubchem_id", "drug_name", "cellosaurus_id", "cell_line_name", "rand_setting", "test_mode"], - how="left", - ) - merged_df.loc[:, "y_true"] = merged_df["y_true_x"] - merged_df["y_pred_y"] - merged_df.loc[:, "y_pred"] = merged_df["y_pred_x"] - merged_df["y_pred_y"] - merged_df = merged_df[ - [ - "model_x", - "pubchem_id", - "drug_name", - "cellosaurus_id", - "cell_line_name", - "y_true", - "y_pred", - "algorithm_x", - "rand_setting", - "test_mode", - "CV_split_x", - ] - ] - self.df = merged_df.rename( - columns={"model_x": "model", "algorithm_x": "algorithm", "CV_split_x": "CV_split"} - ) - - def draw_and_save(self, out_prefix: str, out_suffix: str) -> None: - """ - Draw the regression plot and save it to a file. - - :param out_prefix: e.g., results/my_run/regression_plots/ - :param out_suffix: e.g., LPO_drug_SimpleNeuralNetwork - """ - self._draw() - self.fig.write_html(f"{out_prefix}regression_lines_{out_suffix}.html") - - def _draw(self): - """Draw the regression plot.""" - print(f"Generating regression plots for {self.group_by}, normalize={self.normalize}, algorithm={self.model}...") - self.df = self.df.groupby(self.group_by).filter(lambda x: len(x) > 1) - pccs = self.df.groupby(self.group_by).apply( - lambda x: pearsonr(x["y_true"], x["y_pred"])[0], include_groups=False - ) - pccs = pccs.reset_index() - pccs.columns = [self.group_by, "pcc"] - self.df = self.df.merge(pccs, on=self.group_by) - self._render_plot() - - @staticmethod - def write_to_html(test_mode: str, f: TextIOWrapper, *args, **kwargs) -> TextIOWrapper: - """ - Write the plot to the final report file. - - :param test_mode: test_mode, e.g., LPO - :param f: final report file - :param args: additional arguments - :param kwargs: additional keyword arguments, in this case all files - :return: the final report file - """ - files: list[str] = kwargs.get("files", []) - f.write('

Regression plots

\n') - f.write("
    \n") - regr_files = [f for f in files if test_mode in f and f.startswith("regression_lines")] - regr_files.sort() - for regr_file in regr_files: - f.write(f'
  • {regr_file}
  • \n') - f.write("
\n") - return f - - def _render_plot(self): - """Render the regression plot.""" - # sort df by group name - df = self.df.sort_values(self.group_by) - setting_title = self.model + " " + df["test_mode"].unique()[0] - if self.normalize: - setting_title += ", normalized by mean effects" - hover_data = [ - "pcc", - "cell_line_name", - "cellosaurus_id", - "drug_name", - "pubchem_id", - "algorithm", - ] - - else: - hover_data = ["pcc", "cell_line_name", "cellosaurus_id", "drug_name", "pubchem_id", "algorithm"] - self.fig = px.scatter( - df, - x="y_true", - y="y_pred", - color=self.group_by, - trendline="ols", - hover_name=self.group_by, - hover_data=hover_data, - title=f"{setting_title}: Regression plot", - ) - - min_val = np.min([np.min(df["y_true"]), np.min(df["y_pred"])]) - max_val = np.max([np.max(df["y_true"]), np.max(df["y_pred"])]) - self.fig.update_xaxes(range=[min_val, max_val]) - self.fig.update_yaxes(range=[min_val, max_val]) - self._make_slider(setting_title) - - def _make_slider(self, setting_title: str) -> None: - """ - Make a slider for the Pearson correlation coefficient. - - :param setting_title: title of the plot - """ - n_ticks = 21 - steps = [] - # take the range from pcc (-1 - 1) and divide it into n_ticks-1 equal parts - pcc_parts = np.linspace(-1, 1, n_ticks) - for i in range(n_ticks): - # from the fig data, get the hover data and check if it is greater than the pcc_parts[i] - # only iterate over even numbers because there are scatter points and the ols line for each group - pccs = [0 for _ in range(0, len(self.fig.data))] - for j in range(0, len(self.fig.data)): - if j % 2 == 0: - pccs[j] = self.fig.data[j].customdata[0, 0] - else: - pccs[j] = self.fig.data[j - 1].customdata[0, 0] - if i == n_ticks - 1: - # last step - visible_traces = pccs >= pcc_parts[i] - title = ( - f"{setting_title}: Slider for PCCs >= {str(round(pcc_parts[i], 1))} (step {str(i + 1)} " - f"of {str(n_ticks)})" - ) - else: - # get traces between pcc_parts[i] and pcc_parts[i+1] - visible_traces_gt = pccs >= pcc_parts[i] - visible_traces_lt = pccs < pcc_parts[i + 1] - visible_traces = visible_traces_gt & visible_traces_lt - title = ( - f"{setting_title}: Slider for PCCs between {str(round(pcc_parts[i], 1))} " - f"and {str(round(pcc_parts[i + 1], 1))} (step {str(i + 1)} of {str(n_ticks)})" - ) - step = dict( - method="update", - args=[{"visible": visible_traces}, {"title": title}], - label=str(round(pcc_parts[i], 1)), - ) - steps.append(step) - - sliders = [ - dict( - active=0, - currentvalue={"prefix": "Pearson correlation coefficient="}, - pad={"t": 50}, - steps=steps, - ) - ] - - self.fig.update_layout( - sliders=sliders, - legend=dict(yanchor="top", y=1.0, xanchor="left", x=1.05), - ) diff --git a/drevalpy/visualization/report.py b/drevalpy/visualization/report.py new file mode 100644 index 000000000..c6a095f62 --- /dev/null +++ b/drevalpy/visualization/report.py @@ -0,0 +1,177 @@ +"""Report orchestrator using MultiQC Python API.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from upath import UPath as Path + +from drevalpy.log import get_logger +from drevalpy.visualization._progress import log_stage, rss_gb +from drevalpy.visualization.base import Visualization + +if TYPE_CHECKING: + from drevalpy.types.data.dataset import Dataset + from drevalpy.types.results import ExperimentResult, ModelResult, RunResult + +logger = get_logger(__name__) + +#: Log every Nth model in a per-model plot loop, plus always the first and last. +_MODEL_LOG_EVERY = 10 + + +def _add_module(sections, name: str, anchor: str) -> None: + """Create a MultiQC module from sections and append to the report.""" + import multiqc + + module = multiqc.BaseMultiqcModule(name=name, anchor=anchor) + for section in sections: + module.add_section( + plot=section.plot, + content=section.content or "", + name=section.name, + anchor=section.anchor, + description=section.description, + ) + multiqc.report.modules.append(module) + + +def _ensure_experiment(result): + """Wrap a ModelResult or RunResult into an ExperimentResult.""" + from drevalpy.types.results import ExperimentResult, ModelResult, RunResult + + if isinstance(result, RunResult): + return ExperimentResult([result]) + if isinstance(result, ModelResult): + return ExperimentResult(list(result.runs)) + return result + + +def _run_visualization(viz: Visualization, experiment, result_type: str, dataset=None) -> None: + """Compute a visualization and add its sections to the report.""" + started = time.monotonic() + rss_before = rss_gb() + if result_type == "ModelResult": + models = experiment.models + log_stage(logger, f"plot {viz.registry_name}: computing for {len(models)} models") + for i, model in enumerate(models, start=1): + if i == 1 or i == len(models) or i % _MODEL_LOG_EVERY == 0: + logger.info(" %s: model %d/%d (%s)", viz.registry_name, i, len(models), model.model_name) + viz.compute(model, dataset=dataset) + sections = viz.to_multiqc() + if sections: + name = f"{viz.registry_name} ({model.model_name})" + anchor = f"{viz.registry_name}_{model.model_name}" + _add_module(sections, name, anchor) + else: + log_stage(logger, f"plot {viz.registry_name}: computing") + viz.compute(experiment, dataset=dataset) + sections = viz.to_multiqc() + if sections: + _add_module(sections, viz.registry_name, viz.registry_name) + logger.info( + "plot %s: done in %.1fs, rss %+.2f GB", + viz.registry_name, + time.monotonic() - started, + rss_gb() - rss_before, + ) + + +def create_report( + result: ExperimentResult | ModelResult | RunResult, + output_dir: str | Path, + *, + title: str = "Drug Response Evaluation", + reference_model: str | None = None, + dataset: Dataset | None = None, +) -> None: + """Generate a MultiQC report for the given result. + + :param result: Experiment, model, or run result. + :param output_dir: Output directory for the report. + :param title: Report title. + :param reference_model: If set, normalize metrics against this model. + :param dataset: Optional dataset for drug/cell-line metadata in plots. + """ + try: + import multiqc + except ImportError as e: + raise ImportError( + "multiqc is required for report generation. Install it with: pip install drevalpy[report]" + ) from e + + import drevalpy.visualization.plots # noqa: F401 + from drevalpy.registry.visualization import visualization_registry + + experiment = _ensure_experiment(result) + n_models = experiment.n_models + logger.info( + "Building report %r from %d models (%d model pairs)", + title, + n_models, + n_models * (n_models - 1) // 2, + ) + if reference_model: + logger.info("Normalizing against reference model %r", reference_model) + experiment = experiment.normalize(reference_model) + # Only the normalized copy is plotted; drop the caller's argument reference so the + # pre-normalization arrays can be collected instead of being retained in parallel. + del result + logger.info("Normalized to %d models", experiment.n_models) + log_stage(logger, "report: experiment ready") + + multiqc.reset() + + for viz_cls in visualization_registry.applicable(experiment): + result_type = visualization_registry._result_types.get(viz_cls.registry_name, "ExperimentResult") + viz = viz_cls() + _run_visualization(viz, experiment, result_type, dataset=dataset) + + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + log_stage( + logger, + f"report: writing {len(multiqc.report.modules)} modules / " + f"{sum(len(m.sections) for m in multiqc.report.modules)} sections to {out}", + ) + multiqc.write_report(output_dir=str(out), title=title, force=True) + log_stage(logger, "report: written") + + +def save_all_png( + result: ExperimentResult | ModelResult | RunResult, + output_dir: str | Path, + *, + reference_model: str | None = None, + dataset: Dataset | None = None, +) -> None: + """Save all applicable plots as PNG files. + + :param result: Experiment, model, or run result. + :param output_dir: Output directory for the PNG files. + :param reference_model: If set, normalize metrics against this model. + :param dataset: Optional dataset for drug/cell-line metadata in plots. + """ + import drevalpy.visualization.plots # noqa: F401 + from drevalpy.registry.visualization import visualization_registry + + experiment = _ensure_experiment(result) + if reference_model: + experiment = experiment.normalize(reference_model) + # As in create_report: only the normalized copy is plotted from here on. + del result + + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + + for viz_cls in visualization_registry.applicable(experiment): + result_type = visualization_registry._result_types.get(viz_cls.registry_name, "ExperimentResult") + viz = viz_cls() + if result_type == "ModelResult": + for model in experiment.models: + viz.compute(model, dataset=dataset) + viz.to_png(out / f"{viz.registry_name}_{model.model_name}.png") + else: + viz.compute(experiment, dataset=dataset) + viz.to_png(out / f"{viz.registry_name}.png") diff --git a/drevalpy/visualization/requirements.py b/drevalpy/visualization/requirements.py new file mode 100644 index 000000000..9ca1c0d07 --- /dev/null +++ b/drevalpy/visualization/requirements.py @@ -0,0 +1,12 @@ +"""Plot requirement declarations for capability-based plot selection.""" + +from enum import Enum, auto + + +class PlotRequirement(Enum): + """Requirements that a plot class may declare.""" + + MULTIPLE_MODELS = auto() + MULTIPLE_FOLDS = auto() + RANDOMIZATION = auto() + ROBUSTNESS = auto() diff --git a/drevalpy/visualization/style_utils/favicon.png b/drevalpy/visualization/style_utils/favicon.png deleted file mode 100644 index 5f832e9b1..000000000 Binary files a/drevalpy/visualization/style_utils/favicon.png and /dev/null differ diff --git a/drevalpy/visualization/style_utils/index_layout.html b/drevalpy/visualization/style_utils/index_layout.html deleted file mode 100644 index 91bf392e8..000000000 --- a/drevalpy/visualization/style_utils/index_layout.html +++ /dev/null @@ -1,115 +0,0 @@ - - - - - Result report: Homepage - - - - - - - - - - - - - -
- Logo -

v0.1

-
- - diff --git a/drevalpy/visualization/style_utils/nf-core-drugresponseeval_logo_light.png b/drevalpy/visualization/style_utils/nf-core-drugresponseeval_logo_light.png deleted file mode 100644 index e7e1d74ab..000000000 Binary files a/drevalpy/visualization/style_utils/nf-core-drugresponseeval_logo_light.png and /dev/null differ diff --git a/drevalpy/visualization/style_utils/page_layout.html b/drevalpy/visualization/style_utils/page_layout.html deleted file mode 100644 index 572eab431..000000000 --- a/drevalpy/visualization/style_utils/page_layout.html +++ /dev/null @@ -1,220 +0,0 @@ - - - - - Result report: Subpage - - - - - - - - - - - - - - - - - - - - - - - - -
- Logo -
- - diff --git a/drevalpy/visualization/utils.py b/drevalpy/visualization/utils.py deleted file mode 100644 index a352f909c..000000000 --- a/drevalpy/visualization/utils.py +++ /dev/null @@ -1,851 +0,0 @@ -"""Utility functions for the visualization part of the package.""" - -import os -import pathlib -import shutil -from typing import TextIO - -import importlib_resources -import numpy as np -import pandas as pd - -from ..datasets.dataset import DrugResponseDataset -from ..datasets.splits import MANIFEST_FILENAME, read_split_manifest -from ..evaluation import AVAILABLE_METRICS, evaluate -from ..models.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER -from ..pipeline_function import pipeline_function -from . import ( - ComparisonScatter, - CriticalDifferencePlot, - CrossStudyTables, - Heatmap, - RegressionSliderPlot, - VioHeat, - Violin, -) - -_RESULT_CATEGORIES = ("predictions", "cross_study", "randomization", "robustness") - - -def _discover_result_csv_files(result_dir: pathlib.Path, dataset: str) -> list[pathlib.Path]: - """ - Collect prediction result CSV files from the known experiment directory layout. - - Expected layout: ``{result_dir}/{dataset}/{split_label}/{algorithm}/{category}/*.csv``. - - :param result_dir: root results directory for a run - :param dataset: dataset name, e.g., GDSC2 - :returns: paths to result CSV files - """ - dataset_dir = result_dir / dataset - if not dataset_dir.is_dir(): - return [] - - result_files: list[pathlib.Path] = [] - for split_dir in sorted(path for path in dataset_dir.iterdir() if path.is_dir()): - for algorithm_dir in sorted(path for path in split_dir.iterdir() if path.is_dir()): - if algorithm_dir.name == "splits": - continue - for category in _RESULT_CATEGORIES: - category_dir = algorithm_dir / category - if category_dir.is_dir(): - result_files.extend(sorted(category_dir.glob("*.csv"))) - return result_files - - -def create_output_directories(result_path: pathlib.Path, custom_id: str) -> None: - """ - If they do not exist yet, make directories for the visualization files. - - :param result_path: path to the results - :param custom_id: run id passed via command line - """ - for dir in [ - "violin_plots", - "heatmaps", - "regression_plots", - "comp_scatter", - "html_tables", - "critical_difference_plots", - ]: - os.makedirs(pathlib.Path(result_path / custom_id / dir), exist_ok=True) - - -def _parse_layout(f: TextIO, path_to_layout: str, test_mode: str) -> None: - """ - Parse the layout file and write it to the output file. - - :param f: file to write to - :param path_to_layout: path to the layout file - :param test_mode: test mode, e.g., LPO - """ - with open(path_to_layout, encoding="utf-8") as layout_f: - layout = layout_f.readlines() - if path_to_layout.endswith("index_layout.html"): - # remove the last 2 lines (, ) - layout = layout[:-2] - else: - # remove the last 3 lines (, , ) - layout = layout[:-3] - # replace LPOLCOLDO with the test mode - layout = [line.replace("LPOLCOLDO", test_mode) for line in layout] - f.write("".join(layout)) - - -def _resolve_result_test_mode(result_dir: pathlib.Path, dataset: str, split_label: str) -> str: - """ - Resolve the semantic test mode for a result directory. - - Custom split labels such as ``scaling-lco`` are path labels only. When a split - manifest is present, use its ``test_mode`` field; otherwise fall back to the label. - - :param result_dir: root results directory - :param dataset: dataset name, e.g., GDSC2 - :param split_label: directory label under the dataset folder - :returns: semantic test mode used for evaluation and plotting - """ - manifest_path = result_dir / dataset / split_label / "splits" / MANIFEST_FILENAME - manifest = read_split_manifest(manifest_path) - if manifest is not None: - test_mode = manifest.get("test_mode") - if isinstance(test_mode, str) and test_mode.strip(): - return test_mode.strip() - return split_label - - -def parse_results(path_to_results: str, dataset: str) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]: - """ - Parse the results from the given directory. - - :param path_to_results: path to the results directory - :param dataset: dataset name, e.g., GDSC2 - :returns: evaluation results, evaluation results per drug, evaluation results per cell line, and true vs. predicted - values - """ - print("Generating result tables ...") - result_dir = pathlib.Path(path_to_results) - result_files = _discover_result_csv_files(result_dir, dataset) - - # inititalize dictionaries to store the evaluation results - evaluation_results = None - evaluation_results_per_drug = None - evaluation_results_per_cell_line = None - true_vs_pred = None - - # read every result file and compute the evaluation metrics - for file in result_files: - rel_file = str(os.path.normpath(file.relative_to(result_dir))).replace("\\", "/") - print(f'Evaluating file: "{rel_file}" ...') - split_label = file.parent.parent.parent.name - algorithm = file.parent.parent.name - test_mode = _resolve_result_test_mode(result_dir, dataset, split_label) - ( - overall_eval, - eval_results_per_drug, - eval_results_per_cl, - t_vs_p, - model_name, - ) = evaluate_file(pred_file=file, test_mode=test_mode, model_name=algorithm) - - evaluation_results = ( - overall_eval if evaluation_results is None else pd.concat([evaluation_results, overall_eval]) - ) - true_vs_pred = t_vs_p if true_vs_pred is None else pd.concat([true_vs_pred, t_vs_p]) - - if eval_results_per_drug is not None: - evaluation_results_per_drug = ( - eval_results_per_drug - if evaluation_results_per_drug is None - else pd.concat([evaluation_results_per_drug, eval_results_per_drug]) - ) - - if eval_results_per_cl is not None: - evaluation_results_per_cell_line = ( - eval_results_per_cl - if evaluation_results_per_cell_line is None - else pd.concat([evaluation_results_per_cell_line, eval_results_per_cl]) - ) - - return ( - evaluation_results, - evaluation_results_per_drug, - evaluation_results_per_cell_line, - true_vs_pred, - ) - - -@pipeline_function -def evaluate_file( - pred_file: pathlib.Path, test_mode: str, model_name: str, dataset_name: str = "NO_DATASET_NAME" -) -> tuple[pd.DataFrame, pd.DataFrame | None, pd.DataFrame | None, pd.DataFrame, str]: - """ - Evaluate the predictions from the final models. - - :param pred_file: path to the prediction file - :param test_mode: test mode, e.g., LPO - :param model_name: model name, e.g., SimpleNeuralNetwork - :param dataset_name: name of the dataset, e.g., GDSC2 - :return: evaluation results, evaluation results per drug, evaluation results per cell line, true vs. predicted - values, and model name - """ - print("Parsing file:", os.path.normpath(pred_file)) - dataset = DrugResponseDataset.from_csv(input_file=pred_file, dataset_name=dataset_name) - - model = _generate_model_names(test_mode=test_mode, model_name=model_name, pred_file=pred_file) - - # overall evaluation - overall_eval = {model: evaluate(dataset, list(AVAILABLE_METRICS.keys()))} - - true_vs_pred = pd.DataFrame( - { - "model": [model for _ in range(len(dataset.response))], - "drug": dataset.drug_ids, - "cell_line": dataset.cell_line_ids, - "y_true": dataset.response, - "y_pred": dataset.predictions, - } - ) - - evaluation_results_per_drug = None - evaluation_results_per_cl = None - - if "LPO" in model or "LDO" in model: - evaluation_results_per_drug = _evaluate_per_group( - df=true_vs_pred, - group_by="drug", - eval_results_per_group=evaluation_results_per_drug, - model=model, - ) - if "LPO" in model or "LCO" in model or "LTO" in model: - evaluation_results_per_cl = _evaluate_per_group( - df=true_vs_pred, - group_by="cell_line", - eval_results_per_group=evaluation_results_per_cl, - model=model, - ) - overall_eval = pd.DataFrame.from_dict(overall_eval, orient="index") - - return ( - overall_eval, - evaluation_results_per_drug, - evaluation_results_per_cl, - true_vs_pred, - model, - ) - - -@pipeline_function -def prep_results( - eval_results: pd.DataFrame, - eval_results_per_drug: pd.DataFrame, - eval_results_per_cell_line: pd.DataFrame, - t_vs_p: pd.DataFrame, - path_data: pathlib.Path, -) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]: - """ - Prepare the results by introducing new columns for algorithm, randomization, test_mode, split, CV_split. - - :param eval_results: evaluation results - :param eval_results_per_drug: evaluation results per drug - :param eval_results_per_cell_line: evaluation results per cell line - :param t_vs_p: true vs. predicted values - :param path_data: path to the data - :returns: the same dataframes with new columns - :raises ValueError: if NaiveMeanEffectsPredictor is not found in the evaluation results - """ - # get metadata - print("Getting information about drugs and cell lines ...") - drug_metadata: dict[str, str] = dict() - cell_line_metadata: dict[str, str] = dict() - for root, _, files in os.walk(path_data): - for file in files: - if file == "drug_names.csv": - drug_names = pd.read_csv(os.path.join(root, file)) - drug_names["pubchem_id"] = drug_names["pubchem_id"].astype(str) - # index: pubchem_id, column: drug_name - drug_metadata.update(zip(drug_names["pubchem_id"], drug_names["drug_name"])) - elif file == "cell_line_names.csv": - cell_line_names = pd.read_csv(os.path.join(root, file)) - # index: cellosaurus_id, column: cell_line_name - try: - cellosaurus_ids = cell_line_names["cellosaurus_id"].astype(str) - # replace nan with unknown_id_{i} (patient derived cell lines might not have a cellosaurus id) - n_missing = cellosaurus_ids.isna().sum() - fill_values = [f"unknown_id_{i}" for i in range(n_missing)] - cellosaurus_ids = cellosaurus_ids.where( - cellosaurus_ids.notna(), - pd.Series(fill_values, index=cellosaurus_ids[cellosaurus_ids.isna()].index), - ) - - except KeyError: - cellosaurus_ids = pd.Series([f"unknown_id_{i}" for i in range(len(cell_line_names))]) - cell_line_metadata.update(zip(cell_line_names[CELL_LINE_IDENTIFIER], cellosaurus_ids)) - - # add variables - # split the index by "_" into: algorithm, randomization, test_mode, split, CV_split - print("Reformatting the evaluation results ...") - new_columns = eval_results.index.str.split("_", expand=True).to_frame() - new_columns.columns = [ - "algorithm", - "rand_setting", - "test_mode", - "split", - "CV_split", - ] - new_columns.index = eval_results.index - eval_results = pd.concat([new_columns.drop("split", axis=1), eval_results], axis=1) - if eval_results_per_drug is not None: - print("Reformatting the evaluation results per drug ...") - eval_results_per_drug[["algorithm", "rand_setting", "test_mode", "split", "CV_split"]] = eval_results_per_drug[ - "model" - ].str.split("_", expand=True) - all_drugs = [drug_metadata[drug] for drug in eval_results_per_drug["drug"]] - eval_results_per_drug["drug_name"] = all_drugs - # rename drug to pubchem_id - eval_results_per_drug = eval_results_per_drug.rename(columns={"drug": DRUG_IDENTIFIER}) - if eval_results_per_cell_line is not None: - print("Reformatting the evaluation results per cell line ...") - eval_results_per_cell_line[["algorithm", "rand_setting", "test_mode", "split", "CV_split"]] = ( - eval_results_per_cell_line["model"].str.split("_", expand=True) - ) - all_cello_ids = [cell_line_metadata[cell_line] for cell_line in eval_results_per_cell_line["cell_line"]] - eval_results_per_cell_line["cellosaurus_id"] = all_cello_ids - eval_results_per_cell_line = eval_results_per_cell_line.rename(columns={"cell_line": CELL_LINE_IDENTIFIER}) - - print("Reformatting the true vs. predicted values ...") - t_vs_p[["algorithm", "rand_setting", "test_mode", "split", "CV_split"]] = t_vs_p["model"].str.split( - "_", expand=True - ) - t_vs_p = t_vs_p.drop("split", axis=1) - all_drugs = [drug_metadata[drug] for drug in t_vs_p["drug"]] - t_vs_p["drug_name"] = all_drugs - all_cello_ids = [cell_line_metadata[cell_line] for cell_line in t_vs_p["cell_line"]] - t_vs_p["cellosaurus_id"] = all_cello_ids - t_vs_p = t_vs_p.rename(columns={"cell_line": CELL_LINE_IDENTIFIER, "drug": DRUG_IDENTIFIER}) - t_vs_p[DRUG_IDENTIFIER] = t_vs_p[DRUG_IDENTIFIER].astype(str) - - if "NaiveMeanEffectsPredictor" in eval_results["algorithm"].unique(): - eval_results = _normalize_metrics_by_mean_effects( - evaluation_results=eval_results, - true_vs_pred=t_vs_p, - ) - else: - raise ValueError( - "NaiveMeanEffectsPredictor not found in evaluation results. " - "Please check if the evaluation was run correctly." - ) - - return ( - eval_results, - eval_results_per_drug, - eval_results_per_cell_line, - t_vs_p, - ) - - -def _normalize_metrics_by_mean_effects( - evaluation_results: pd.DataFrame, - true_vs_pred: pd.DataFrame, -) -> pd.DataFrame: - """ - Normalize the y_true and y_pred values by the predictions of the NaiveMeanEffectsPredictor. - - Then recalculate the metrics. - :param evaluation_results: results of the evaluation - :param true_vs_pred: all true vs. predicted values - :return: modified evaluation results - """ - eval_results_mod = {} - naive_mean_effects_dict = {} - for rand_setting in evaluation_results["rand_setting"].unique(): - for test_mode in evaluation_results["test_mode"].unique(): - naive_mean_effects_dict[f"{test_mode}_{rand_setting}"] = true_vs_pred[ - (true_vs_pred["algorithm"] == "NaiveMeanEffectsPredictor") - & (true_vs_pred["rand_setting"] == rand_setting) - & (true_vs_pred["test_mode"] == test_mode) - ] - # do this: per algorithm, per rand test_mode, per test_mode, per CV split - for algorithm in evaluation_results["algorithm"].unique(): - for rand_setting in evaluation_results["rand_setting"].unique(): - for test_mode in evaluation_results["test_mode"].unique(): - - setting_subset = true_vs_pred[ - (true_vs_pred["algorithm"] == algorithm) - & (true_vs_pred["rand_setting"] == rand_setting) - & (true_vs_pred["test_mode"] == test_mode) - ] - if setting_subset.empty: - continue - naive_mean_effects = naive_mean_effects_dict[f"{test_mode}_{rand_setting}"] - naive_mean_effects = naive_mean_effects[["drug_name", "cell_line_name", "CV_split", "y_pred"]] - naive_mean_effects = naive_mean_effects.rename(columns={"y_pred": "y_pred_naive"}) - setting_subset = setting_subset[["drug_name", "cell_line_name", "CV_split", "y_true", "y_pred"]] - setting_subset = setting_subset.merge( - naive_mean_effects, on=["drug_name", "cell_line_name", "CV_split"], how="left" - ) - setting_subset["y_true"] = setting_subset["y_true"] - setting_subset["y_pred_naive"] - setting_subset["y_pred"] = setting_subset["y_pred"] - setting_subset["y_pred_naive"] - for cv_split in setting_subset["CV_split"].unique(): - setting_subset_cv = setting_subset[setting_subset["CV_split"] == cv_split] - dt = DrugResponseDataset( - response=setting_subset_cv["y_true"].to_numpy(), - cell_line_ids=setting_subset_cv["cell_line_name"].to_numpy(), - drug_ids=setting_subset_cv["drug_name"].to_numpy(), - predictions=setting_subset_cv["y_pred"].to_numpy(), - ) - res = evaluate( - dataset=dt, - metric=list(AVAILABLE_METRICS.keys() - {"MAE", "MSE", "RMSE"}), - ) - eval_results_mod[f"{algorithm}_{rand_setting}_{test_mode}_split_{cv_split}"] = res - mod_table = pd.DataFrame.from_dict(eval_results_mod, orient="index") - mod_table.columns = [f"{col}: normalized" for col in mod_table.columns] - evaluation_results = evaluation_results.merge(mod_table, left_index=True, right_index=True) - return evaluation_results - - -def _generate_model_names(test_mode: str, model_name: str, pred_file: pathlib.Path) -> str: - """ - Generate the model names based on the prediction file. - - :param test_mode: test mode, e.g., LPO - :param model_name: model name, e.g., SimpleNeuralNetwork - :param pred_file: file containing the predictions - :returns: unique name of run = {model_name}_{pred_setting}_{test_mode}_{split} - :raises ValueError: if the prediction test_mode is unknown - """ - file_parts = os.path.basename(pred_file).split("_") - pred_rand_rob = file_parts[0] - if pred_rand_rob == "predictions": - pred_setting = "predictions" - elif pred_rand_rob == "randomization": - pred_setting = "randomize-" + "-".join(file_parts[1:-2]) - elif pred_rand_rob == "robustness": - pred_setting = "-".join(file_parts[:2]) - elif pred_rand_rob == "cross": - pred_setting = "cross-study-" + file_parts[2] - else: - raise ValueError(f"Unknown prediction test_mode: {pred_rand_rob}") - split = "_".join(os.path.basename(pred_file).split(".")[0].split("_")[-2:]) - return f"{model_name}_{pred_setting}_{test_mode}_{split}" - - -def _evaluate_per_group( - df: pd.DataFrame, - group_by: str, - eval_results_per_group: pd.DataFrame | None, - model: str, -) -> pd.DataFrame: - """ - Evaluate the predictions per group. - - :param df: true vs. predicted values - :param group_by: cell line or drug - :param eval_results_per_group: evaluation results per group - :param model: model name - :returns: dictionary with the normalized group evaluation results and the evaluation results per group - """ - # calculate the mean of y_true per drug - print(f"Calculating {group_by}-wise evaluation measures …") - # evaluation per group - eval_results_per_group = compute_evaluation(df, eval_results_per_group, group_by, model) - return eval_results_per_group - - -def compute_evaluation(df: pd.DataFrame, return_df: pd.DataFrame | None, group_by: str, model: str) -> pd.DataFrame: - """ - Compute the evaluation metrics per group. - - :param df: true vs. predicted values with mean_y_true_per_{group_by} column - :param return_df: DataFrame to store the results - :param group_by: either cell line or drug - :param model: model name - :returns: dataframe with the evaluation results per group - """ - result_per_group = df.groupby(group_by)[["y_true", "cell_line", "drug", "y_pred"]].apply( - lambda x: evaluate( - DrugResponseDataset( - response=x["y_true"].to_numpy(), - cell_line_ids=x["cell_line"].to_numpy(), - drug_ids=x["drug"].to_numpy(), - predictions=x["y_pred"].to_numpy(), - ), - list(AVAILABLE_METRICS.keys()), - ) - ) - groups = result_per_group.index - result_per_group = pd.json_normalize(result_per_group) - result_per_group[group_by] = groups - result_per_group["model"] = model - if return_df is None: - return_df = pd.DataFrame(result_per_group) - else: - return_df = pd.concat([return_df, result_per_group]) - return return_df - - -@pipeline_function -def write_results( - path_out: str, - eval_results: pd.DataFrame, - eval_results_per_drug: pd.DataFrame, - eval_results_per_cl: pd.DataFrame, - t_vs_p: pd.DataFrame, -) -> None: - """ - Write the results to csv files. - - :param path_out: path to the output directory, e.g., results/my_run/ - :param eval_results: evaluation results - :param eval_results_per_drug: evaluation results per drug - :param eval_results_per_cl: evaluation results per cell line - :param t_vs_p: true vs. predicted values - """ - eval_results.to_csv(f"{path_out}evaluation_results.csv", index=True) - if eval_results_per_drug is not None: - eval_results_per_drug.to_csv(f"{path_out}evaluation_results_per_drug.csv", index=True) - if eval_results_per_cl is not None: - eval_results_per_cl.to_csv(f"{path_out}evaluation_results_per_cl.csv", index=True) - t_vs_p.to_csv(f"{path_out}true_vs_pred.csv", index=True) - - -@pipeline_function -def create_index_html(custom_id: str, test_modes: list[str], prefix_results: str) -> None: - """ - Create the index.html file. - - :param custom_id: custom id for the results, e.g., my_run - :param test_modes: list of test modes, e.g., ["LPO", "LCO", "LDO"] - :param prefix_results: path to the results directory, e.g., results/my_run - """ - # copy images to the results directory - file_to_copy = [ - "favicon.png", - "nf-core-drugresponseeval_logo_light.png", - ] - for file in file_to_copy: - file_path = os.path.join( - str(importlib_resources.files("drevalpy")), - "visualization", - "style_utils", - file, - ) - shutil.copyfile(file_path, os.path.join(prefix_results, file)) - - layout_path = os.path.join( - str(importlib_resources.files("drevalpy")), - "visualization", - "style_utils", - "index_layout.html", - ) - idx_html_path = os.path.join(prefix_results, "index.html") - with open(idx_html_path, "w", encoding="utf-8") as f: - _parse_layout(f=f, path_to_layout=layout_path, test_mode="") - f.write('
\n') - f.write('Logo\n') - f.write(f"

Results for {custom_id}

\n") - f.write("

Available settings

\n") - f.write('
\n') - f.write("

Click on the images to open the respective report in a new tab.

\n") - - test_modes.sort() - for test_mode in test_modes: - img_path = os.path.join( - str(importlib_resources.files("drevalpy")), - "visualization", - "style_utils", - f"{test_mode}.png", - ) - shutil.copyfile(img_path, os.path.join(prefix_results, f"{test_mode}.png")) - f.write( - f'\n' - ) - f.write("
\n") - f.write("
\n") - f.write("\n") - f.write("\n") - - -def create_html(run_id: str, test_mode: str, files: list, prefix_results: str) -> None: - """ - Create the html file for the given test mode, e.g., LPO.html. - - :param run_id: custom id for the results, e.g., my_run - :param test_mode: test mode, e.g., LPO - :param files: list of files in the results directory - :param prefix_results: path to the results directory, e.g., results/my_run - """ - page_layout = os.path.join( - str(importlib_resources.files("drevalpy")), - "visualization/style_utils/page_layout.html", - ) - html_path = os.path.join(prefix_results, f"{test_mode}.html") - - with open(html_path, "w", encoding="utf-8") as f: - _parse_layout(f=f, path_to_layout=page_layout, test_mode=test_mode) - f.write(f"

Results for {run_id}: {test_mode}

\n") - - # Critical difference plot - f = CriticalDifferencePlot.write_to_html(test_mode=test_mode, f=f) - - # Violin plots - f = VioHeat.write_to_html(test_mode=test_mode, f=f, files=files, plot="Violin") - - # Heatmaps - f = VioHeat.write_to_html(test_mode=test_mode, f=f, files=files, plot="Heatmap") - - # Regression plots - f = RegressionSliderPlot.write_to_html(test_mode=test_mode, f=f, files=files) - - # Correlation comparison: Drug - f = ComparisonScatter.write_to_html(test_mode=test_mode, f=f, files=files) - - # Cross-study evaluation tables - f = CrossStudyTables.write_to_html(test_mode=test_mode, f=f, files=files, prefix=prefix_results) - - f.write("\n") - f.write("\n") - f.write("\n") - - -def draw_test_mode_plots( - test_mode: str, - ev_res: pd.DataFrame, - ev_res_per_drug: pd.DataFrame | None, - ev_res_per_cell_line: pd.DataFrame | None, - custom_id: str, - path_data: pathlib.Path, - result_path: pathlib.Path, -) -> np.ndarray: - """ - Draw all plots for a specific test_mode (LPO, LCO, LDO, LTO). - - :param test_mode: test_mode - :param ev_res: overall evaluation results - :param ev_res_per_drug: evaluation results per drug - :param ev_res_per_cell_line: evaluation results per cell line - :param custom_id: run id passed via command line - :param path_data: path to the data - :param result_path: path to the results - :returns: list of unique algorithms - :raises ValueError: if no evaluation results are found for the given test_mode - """ - if ev_res.empty: - raise ValueError( - f"No evaluation results found for test_mode {test_mode}. " - "Please check if the evaluation was run correctly." - ) - ev_res_subset = ev_res[ev_res["test_mode"] == test_mode] - - # only draw figures for 'real' predictions comparing all models - eval_results_preds = ev_res_subset[ev_res_subset["rand_setting"] == "predictions"] - if eval_results_preds.empty: - raise ValueError( - f"No evaluation results found for test_mode {test_mode} with predictions. " - "Please check if the evaluation was run correctly." - ) - - cd_plot = CriticalDifferencePlot(eval_results_preds=eval_results_preds, metric="MSE") - cd_plot.draw_and_save( - out_prefix=f"{result_path}/{custom_id}/critical_difference_plots/", - out_suffix=test_mode, - ) - for plt_type in ["violinplot", "heatmap"]: - if plt_type == "violinplot": - out_dir = "violin_plots" - else: - out_dir = "heatmaps" - for normalized in [False, True]: - if normalized: - out_suffix = f"algorithms_{test_mode}_normalized" - else: - out_suffix = f"algorithms_{test_mode}" - out_plot: Violin | Heatmap - if plt_type == "violinplot": - - out_plot = Violin( - df=eval_results_preds, - normalized_metrics=normalized, - whole_name=False, - ) - - else: - out_plot = Heatmap( - df=eval_results_preds, - normalized_metrics=normalized, - whole_name=False, - ) - out_plot.draw_and_save( - out_prefix=f"{result_path}/{custom_id}/{out_dir}/", - out_suffix=out_suffix, - ) - - # per group plots - if test_mode in ("LPO", "LDO"): - _draw_per_grouping_setting_plots( - grouping="drug_name", - ev_res_per_group=ev_res_per_drug, - test_mode=test_mode, - custom_id=custom_id, - result_path=result_path, - ) - if test_mode in ("LPO", "LCO", "LTO"): - _draw_per_grouping_setting_plots( - grouping="cell_line_name", - ev_res_per_group=ev_res_per_cell_line, - test_mode=test_mode, - custom_id=custom_id, - result_path=result_path, - ) - - # Cross-study evaluation tables - cross_study_tables = CrossStudyTables(evaluation_metrics=ev_res_subset, path_data=path_data) - cross_study_tables.draw_and_save( - out_prefix=f"{result_path}/{custom_id}/html_tables/", - out_suffix=test_mode, - ) - - return eval_results_preds["algorithm"].unique() - - -def _draw_per_grouping_setting_plots( - grouping: str, ev_res_per_group: pd.DataFrame, test_mode: str, custom_id: str, result_path: pathlib.Path -) -> None: - """ - Draw plots for a specific grouping (drug or cell line) for a specific test_mode (LPO, LCO, LDO). - - :param grouping: drug or cell_line - :param ev_res_per_group: evaluation results per drug or per cell line - :param test_mode: test_mode - :param custom_id: run id passed over command line - :param result_path: path to the results - """ - corr_comp = ComparisonScatter( - df=ev_res_per_group, - color_by=grouping, - test_mode=test_mode, - algorithm="all", - ) - if corr_comp.name is not None: - corr_comp.draw_and_save( - out_prefix=f"{result_path}/{custom_id}/comp_scatter/", - out_suffix=corr_comp.name, - ) - - -def draw_algorithm_plots( - model: str, - ev_res: pd.DataFrame, - ev_res_per_drug: pd.DataFrame | None, - ev_res_per_cell_line: pd.DataFrame | None, - t_vs_p: pd.DataFrame, - test_mode: str, - custom_id: str, - result_path: pathlib.Path, -) -> None: - """ - Draw all plots for a specific algorithm. - - :param model: name of the model/algorithm - :param ev_res: overall evaluation results - :param ev_res_per_drug: evaluation results per drug - :param ev_res_per_cell_line: evaluation results per cell line - :param t_vs_p: true response values vs. predicted response values - :param test_mode: test_mode - :param custom_id: run id passed via command line - :param result_path: path to the results - """ - eval_results_algorithm = ev_res[(ev_res["test_mode"] == test_mode) & (ev_res["algorithm"] == model)] - for plt_type in ["violinplot", "heatmap"]: - if len(eval_results_algorithm["rand_setting"].unique()) < 2: - # only draw plots if there are predictions and another test_mode (randomization/robustness) - continue - out_plot: Violin | Heatmap - if plt_type == "violinplot": - out_dir = "violin_plots" - out_plot = Violin( - df=eval_results_algorithm, - normalized_metrics=False, - whole_name=True, - ) - else: - out_dir = "heatmaps" - out_plot = Heatmap( - df=eval_results_algorithm, - normalized_metrics=False, - whole_name=True, - ) - out_plot.draw_and_save( - out_prefix=f"{result_path}/{custom_id}/{out_dir}/", - out_suffix=f"{model}_{test_mode}", - ) - if test_mode in ("LPO", "LDO"): - _draw_per_grouping_algorithm_plots( - grouping="drug_name", - model=model, - ev_res_per_group=ev_res_per_drug, - t_v_p=t_vs_p, - test_mode=test_mode, - custom_id=custom_id, - result_path=result_path, - ) - if test_mode in ("LPO", "LCO", "LTO"): - _draw_per_grouping_algorithm_plots( - grouping="cell_line_name", - model=model, - ev_res_per_group=ev_res_per_cell_line, - t_v_p=t_vs_p, - test_mode=test_mode, - custom_id=custom_id, - result_path=result_path, - ) - - -def _draw_per_grouping_algorithm_plots( - grouping: str, - model: str, - ev_res_per_group: pd.DataFrame, - t_v_p: pd.DataFrame, - test_mode: str, - custom_id: str, - result_path: pathlib.Path, -): - """ - Draw plots for a specific grouping (drug or cell line) for a specific algorithm. - - :param grouping: drug or cell_line - :param model: name of the model/algorithm - :param ev_res_per_group: evaluation results per drug or per cell line - :param t_v_p: true response values vs. predicted response values - :param test_mode: test_mode - :param custom_id: run id passed via command line - :param result_path: path to the results - """ - if len(ev_res_per_group["rand_setting"].unique()) > 1: - # only draw plots if there are predictions and another test_mode (randomization/robustness) - comp_scatter = ComparisonScatter( - df=ev_res_per_group, - color_by=grouping, - test_mode=test_mode, - algorithm=model, - ) - if comp_scatter.name is not None: - comp_scatter.draw_and_save( - out_prefix=f"{result_path}/{custom_id}/comp_scatter/", - out_suffix=comp_scatter.name, - ) - for normalize in [False, True]: - name_suffix = "_normalized" if normalize else "" - name = f"{test_mode}_{grouping}{name_suffix}" - regr_slider = RegressionSliderPlot( - df=t_v_p, - test_mode=test_mode, - model=model, - group_by=grouping, - normalize=normalize, - ) - regr_slider.draw_and_save( - out_prefix=f"{result_path}/{custom_id}/regression_plots/", - out_suffix=f"{name}_{model}{name_suffix}", - ) diff --git a/drevalpy/visualization/vioheat.py b/drevalpy/visualization/vioheat.py deleted file mode 100644 index 7089f18a0..000000000 --- a/drevalpy/visualization/vioheat.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Parent class for Violin and Heatmap plots of performance measures over CV runs.""" - -from io import TextIOWrapper - -import pandas as pd - -from drevalpy.visualization.outplot import OutPlot - - -class VioHeat(OutPlot): - """Parent class for Violin and Heatmap plots of performance measures over CV runs.""" - - def __init__(self, df: pd.DataFrame, normalized_metrics=False, whole_name=False): - """ - Initialize the VioHeat class. - - :param df: evaluation results, either overall or per algorithm - :param normalized_metrics: whether the metrics are normalized - :param whole_name: whether the whole name should be displayed - """ - self.df = df.sort_index() - self.all_metrics = [ - "R^2", - "R^2: normalized", - "Pearson", - "Pearson: normalized", - "Spearman", - "Spearman: normalized", - "Kendall", - "Kendall: normalized", - "MSE", - "RMSE", - "MAE", - ] - self.normalized_metrics = normalized_metrics - self.whole_name = whole_name - if self.normalized_metrics: - self.all_metrics = [metric for metric in self.all_metrics if "normalized" in metric] - else: - self.all_metrics = [metric for metric in self.all_metrics if "normalized" not in metric] - - def draw_and_save(self, out_prefix: str, out_suffix: str) -> None: - """ - Draw and save the plot. - - :param out_prefix: e.g., results/my_run/heatmaps/ - :param out_suffix: e.g., algorithms_normalized - """ - pass - - def _draw(self) -> None: - pass - - @staticmethod - def write_to_html(test_mode: str, f: TextIOWrapper, *args, **kwargs) -> TextIOWrapper: - """ - Write the Violin and Heatmap plots into the result HTML file. - - :param test_mode: test_mode, e.g., LPO - :param f: result HTML file - :param args: additional arguments - :param kwargs: additional keyword arguments, in this case, the plot type and the files - :returns: the result HTML file - """ - plot: str = kwargs.get("plot", "") - files: list[str] = kwargs.get("files", []) - - if plot == "Violin": - nav_id = "violin" - dir_name = "violin_plots" - prefix = "violin" - else: - nav_id = "heatmap" - dir_name = "heatmaps" - prefix = "heatmap" - plot_list = [ - f - for f in files - if ( - test_mode in f - and f.startswith(prefix) - and f != f"{prefix}_algorithms_{test_mode}.html" - and f != f"{prefix}_algorithms_{test_mode}_normalized.html" - ) - ] - f.write(f"

{plot} Plots of Performance Measures over CV runs

\n") - f.write(f"

{plot} plots comparing all models

\n") - if plot == "Violin": - f.write( - "To focus on a specific metric, choose it in the dropdown menu in the top right corner." - "You can investigate the distribution of the performance measures by hovering over the plot.\n" - "To select/exclude specific algorithms, (double-)click them in the legend." - ) - elif plot == "Heatmap": - f.write( - "Unnormalized metrics collapsed over all CV runs with mean and standard deviation.\n" - "The strictly standardized mean difference is a measure of effect size which is calculated " - "pairwise. For two models, it is calculated as [mean1 - mean2] / [sqrt(var1 + var2)] for a " - "specific measure. The larger the absolute SSMD, the stronger the effect (a strong effect could, " - "is e.g., a |SSMD| > 2 ).\n" - ) - f.write( - f'\n' - ) - f.write(f"

{plot} plots comparing all models with normalized metrics

\n") - f.write( - "Before calculating the evaluation metrics, all values were normalized by the predictions of the " - "NaiveMeanEffectsPredictor. Since this only influences the R^2 and the correlation metrics, the error " - "metrics are not shown. \n" - ) - f.write( - f'\n' - ) - f.write(f"

{plot} plots comparing performance measures for tests within each model

\n") - f.write("
    ") - for plot in plot_list: - f.write(f'
  • {plot}
  • \n') - f.write("
\n") - return f diff --git a/drevalpy/visualization/violin.py b/drevalpy/visualization/violin.py deleted file mode 100644 index be4536c00..000000000 --- a/drevalpy/visualization/violin.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Plots a violin plot of the evaluation metrics.""" - -import pandas as pd -import plotly.graph_objects as go - -from .vioheat import VioHeat - - -class Violin(VioHeat): - """Plots a violin plot of the evaluation metrics.""" - - def __init__(self, df: pd.DataFrame, normalized_metrics=False, whole_name=False): - """ - Initialize the Violin class. - - :param df: either containing all predictions for all algorithms or all tests for one algorithm (including - robustness, randomization, … tests then) - :param normalized_metrics: whether the metrics are normalized - :param whole_name: whether the whole name should be displayed - """ - super().__init__(df, normalized_metrics, whole_name) - self.df["box"] = self.df["algorithm"] + "_" + self.df["rand_setting"] + "_" + self.df["test_mode"] - # remove columns with only NaN values - self.df = self.df.dropna(axis=1, how="all") - self.fig = go.Figure() - self.occurring_metrics = [metric for metric in self.all_metrics if metric in self.df.columns] - - def draw_and_save(self, out_prefix: str, out_suffix: str) -> None: - """ - Draw the violin and save it to a file. - - :param out_prefix: e.g., results/my_run/violin_plots/ - :param out_suffix: e.g., algorithms_normalized - """ - self._draw() - path_out = f"{out_prefix}violin_{out_suffix}.html" - self.fig.write_html(path_out) - - def _draw(self) -> None: - self._create_evaluation_violins() - count_sum = ( - self.count_r2 - + self.count_pearson - + self.count_spearman - + self.count_kendall - + self.count_mse - + self.count_rmse - + self.count_mae - ) - buttons_update = list( - [ - dict( - label="All Metrics", - method="update", - args=[ - {"visible": [True] * count_sum}, - {"title": "All Metrics"}, - ], - ), - dict( - label="R^2", - method="update", - args=[ - {"visible": [True] * self.count_r2 + [False] * (count_sum - self.count_r2)}, - {"title": "R^2"}, - ], - ), - dict( - label="Pearson", - method="update", - args=[ - { - "visible": [False] * self.count_r2 - + [True] * self.count_pearson - + [False] * (count_sum - self.count_r2 - self.count_pearson) - }, - {"title": "Pearson"}, - ], - ), - dict( - label="Spearman", - method="update", - args=[ - { - "visible": [False] * (self.count_r2 + self.count_pearson) - + [True] * self.count_spearman - + [False] * (count_sum - self.count_r2 - self.count_pearson - self.count_spearman) - }, - {"title": "Spearman"}, - ], - ), - dict( - label="Kendall", - method="update", - args=[ - { - "visible": [False] * (self.count_r2 + self.count_pearson + self.count_spearman) - + [True] * self.count_kendall - + [False] - * ( - count_sum - - self.count_r2 - - self.count_pearson - - self.count_spearman - - self.count_kendall - ) - }, - {"title": "Kendall"}, - ], - ), - ] - ) - if not self.normalized_metrics: - buttons_update += list( - [ - dict( - label="MSE", - method="update", - args=[ - { - "visible": [False] * (count_sum - self.count_mse - self.count_rmse - self.count_mae) - + [True] * self.count_mse - + [False] * (self.count_rmse + self.count_mae) - }, - {"title": "MSE"}, - ], - ), - dict( - label="RMSE", - method="update", - args=[ - { - "visible": [False] * (count_sum - self.count_rmse - self.count_mae) - + [True] * self.count_rmse - + [False] * self.count_mae - }, - {"title": "RMSE"}, - ], - ), - dict( - label="MAE", - method="update", - args=[ - {"visible": [False] * (count_sum - self.count_mae) + [True] * self.count_mae}, - {"title": "MAE"}, - ], - ), - ] - ) - self.fig.update_layout( - updatemenus=[ - dict( - active=0, - buttons=buttons_update, - ) - ] - ) - self.fig.update_layout(title_text="All Metrics", height=600, width=1100) - - def _create_evaluation_violins(self): - print("Drawing Violin plots ...") - self.count_r2 = 0 - self.count_pearson = 0 - self.count_spearman = 0 - self.count_kendall = 0 - self.count_mse = 0 - self.count_rmse = 0 - self.count_mae = 0 - for metric in self.occurring_metrics: - if "R^2" in metric: - self.count_r2 += 1 * len(self.df["box"].unique()) - elif "Pearson" in metric: - self.count_pearson += 1 * len(self.df["box"].unique()) - elif "Spearman" in metric: - self.count_spearman += 1 * len(self.df["box"].unique()) - elif "Kendall" in metric: - self.count_kendall += 1 * len(self.df["box"].unique()) - elif "RMSE" in metric: - self.count_rmse += 1 * len(self.df["box"].unique()) - elif "MSE" in metric: - self.count_mse += 1 * len(self.df["box"].unique()) - elif "MAE" in metric: - self.count_mae += 1 * len(self.df["box"].unique()) - self._add_violin(metric) - - def _add_violin(self, metric): - for box in self.df["box"].unique(): - tmp_df = self.df[self.df["box"] == box] - if self.whole_name: - label = box + ": " + metric - else: - label = box.split("_")[0] + ": " + metric - self.fig.add_trace( - go.Violin( - y=tmp_df[metric], - x=[label] * len(tmp_df[metric]), - name=label, - box_visible=True, - meanline_visible=True, - ) - ) diff --git a/examples/custom_split_lco_fraction.py b/examples/custom_split_lco_fraction.py deleted file mode 100644 index 9727403c0..000000000 --- a/examples/custom_split_lco_fraction.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Example custom split script for LCO-style scaling-law experiments. - -Define train/validation/test subsets in this file. ``test_mode=LCO`` must be used when -running drevalpy so cell-line disjointness is validated on the produced splits. - -Custom split scripts execute as local Python code. drevalpy validates obvious -overlap/leakage for the selected ``test_mode``, but cannot guarantee that the split -answers your scientific question. -""" - -from __future__ import annotations - -import numpy as np - -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.datasets.splits import SplitParams - -# Fraction of cell lines held out for test; remaining cell lines are split train/val. -TEST_FRACTION = 0.2 - - -def _subset(dataset: DrugResponseDataset, mask: np.ndarray) -> DrugResponseDataset: - return DrugResponseDataset( - response=dataset.response[mask], - cell_line_ids=dataset.cell_line_ids[mask], - drug_ids=dataset.drug_ids[mask], - tissues=dataset.tissue[mask] if dataset.tissue is not None else None, - dataset_name=dataset.dataset_name, - ) - - -def create_splits( - response_data: DrugResponseDataset, - params: SplitParams, -) -> list[dict[str, DrugResponseDataset]]: - """ - Return one LCO-style split with configurable train/validation/test cell-line groups. - - :param response_data: full response dataset to partition - :param params: pipeline split settings (seed, validation ratio, fold count, etc.) - :returns: list containing one split dict with train, validation, and test roles - """ - rng = np.random.default_rng(params.random_state) - unique_cell_lines = np.unique(response_data.cell_line_ids) - shuffled = rng.permutation(unique_cell_lines) - - n_test = max(1, int(len(shuffled) * TEST_FRACTION)) - n_val = max(1, int(len(shuffled) * params.validation_ratio)) - n_val = min(n_val, len(shuffled) - n_test - 1) - - test_cls = set(shuffled[:n_test]) - val_cls = set(shuffled[n_test : n_test + n_val]) # noqa: E203 - train_cls = set(shuffled[n_test + n_val :]) # noqa: E203 - - train_mask = np.isin(response_data.cell_line_ids, list(train_cls)) - val_mask = np.isin(response_data.cell_line_ids, list(val_cls)) - test_mask = np.isin(response_data.cell_line_ids, list(test_cls)) - - split = { - "train": _subset(response_data, train_mask), - "validation": _subset(response_data, val_mask), - "test": _subset(response_data, test_mask), - "metadata": { - "fraction_test": TEST_FRACTION, - "fraction_validation": params.validation_ratio, - "seed": params.random_state, - "n_cv_splits": params.n_cv_splits, - "test_mode": params.test_mode, - }, - } - return [split] diff --git a/noxfile.py b/noxfile.py deleted file mode 100644 index b01698f4c..000000000 --- a/noxfile.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Nox sessions.""" - -import os -import shlex -import shutil -import sys -from pathlib import Path -from textwrap import dedent - -import nox -from rich import print - -try: - from nox_poetry import Session, session -except ImportError: - print("[bold red]Did not find nox-poetry installed in your current environment!") - print("[bold blue]Try installing it using [bold green]pip install nox-poetry [bold blue]! ") - sys.exit(1) - -package = "drevalpy" -python_versions = ["3.12", "3.13"] -nox.options.sessions = ( - "pre-commit", - "mypy", - "tests", - "xdoctest", - "docs-build", -) - - -def activate_virtualenv_in_precommit_hooks(session: Session) -> None: - """Activate virtualenv in hooks installed by pre-commit. - - This function patches git hooks installed by pre-commit to activate the - session's virtual environment. This allows pre-commit to locate hooks in - that environment when invoked from git. - - :param session: The Session object. - """ - assert session.bin is not None # noqa: S101 - - # Only patch hooks containing a reference to this session's bindir. Support - # quoting rules for Python and bash, but strip the outermost quotes so we - # can detect paths within the bindir, like /python. - bindirs = [ - bindir[1:-1] if bindir[0] in "'\"" else bindir for bindir in (repr(session.bin), shlex.quote(session.bin)) - ] - - virtualenv = session.env.get("VIRTUAL_ENV") - if virtualenv is None: - return - - headers = { - # pre-commit < 2.16.0 - "python": f"""\ - import os - os.environ["VIRTUAL_ENV"] = {virtualenv!r} - os.environ["PATH"] = os.pathsep.join(( - {session.bin!r}, - os.environ.get("PATH", ""), - )) - """, - # pre-commit >= 2.16.0 - "bash": f"""\ - VIRTUAL_ENV={shlex.quote(virtualenv)} - PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" - """, - # pre-commit >= 2.17.0 on Windows forces sh shebang - "/bin/sh": f"""\ - VIRTUAL_ENV={shlex.quote(virtualenv)} - PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" - """, - } - - hookdir = Path(".git") / "hooks" - if not hookdir.is_dir(): - return - - for hook in hookdir.iterdir(): - if hook.name.endswith(".sample") or not hook.is_file(): - continue - - if not hook.read_bytes().startswith(b"#!"): - continue - - text = hook.read_text() - - if not any(Path("A") == Path("a") and bindir.lower() in text.lower() or bindir in text for bindir in bindirs): - continue - - lines = text.splitlines() - - for executable, header in headers.items(): - if executable in lines[0].lower(): - lines.insert(1, dedent(header)) - hook.write_text("\n".join(lines)) - break - - -@session(name="pre-commit", python=python_versions) -def precommit(session: Session) -> None: - """ - Lint using pre-commit. - - :param session: The Session object. - """ - args = session.posargs or ["run", "--all-files"] - session.install( - "black", - "flake8", - "flake8-bandit", - "flake8-bugbear", - "flake8-docstrings", - "darglint", - "flake8-rst-docstrings", - "isort", - "pep8-naming", - "pre-commit", - "pre-commit-hooks", - "pyupgrade", - ) - session.run("pre-commit", *args) - if args and args[0] == "install": - activate_virtualenv_in_precommit_hooks(session) - - -@session(python=python_versions) -def mypy(session: Session) -> None: - """ - Type-check using mypy. - - :param session: The Session object. - """ - args = session.posargs or ["drevalpy", "tests", "docs/conf.py"] - session.install(".") - session.install("mypy", "pytest", "types-requests", "types-attrs", "types-PyYAML", "types-toml") - session.run("mypy", *args) - - -@session(python=python_versions) -def tests(session: Session) -> None: - """ - Run the test suite. - - :param session: The Session object. - """ - session.install(".[xgboost,precily,sparsego]") - session.install("coverage[toml]", "pytest", "pygments") - try: - session.run( - "coverage", - "run", - "--parallel", - "-m", - "pytest", - "--ignore=tests/test_hpam_tune_raytune.py", # skip ray, not enough disk space on the runner for ray - *session.posargs, - ) - finally: - if session.interactive: - session.notify("coverage") - - -@session -def coverage(session: Session) -> None: - """ - Produce the coverage report. - - :param session: The Session object. - """ - # Do not use session.posargs unless this is the only session. - nsessions = len(session._runner.manifest) # type: ignore[attr-defined] - has_args = session.posargs and nsessions == 1 - args = session.posargs if has_args else ["report", "-i"] - - session.install("coverage[toml]") - - if not has_args and any(Path().glob(".coverage.*")): - session.run("coverage", "combine") - - session.run("coverage", *args) - - -@session(python=python_versions) -def typeguard(session: Session) -> None: - """ - Runtime type checking using Typeguard. - - :param session: The Session object. - """ - session.install(".[xgboost,precily,sparsego]") - - session.install("pytest", "typeguard", "pygments") - session.run( - "pytest", - f"--typeguard-packages={package}", - "--ignore=tests/test_hpam_tune_raytune.py", - *session.posargs, - ) - - -@session(python=python_versions) -def xdoctest(session: Session) -> None: - """ - Run examples with xdoctest. - - :param session: The Session object. - """ - args = session.posargs or ["all"] - session.install(".") - session.install("xdoctest[colors]") - session.run("python", "-m", "xdoctest", package, *args) - - -@session(name="docs-build", python=python_versions) -def docs_build(session: Session) -> None: - """ - Build the documentation. - - :param session: The Session object. - """ - args = session.posargs or ["docs", "docs/_build"] - session.install("-r", "./docs/requirements.txt") - - build_dir = Path("docs", "_build") - if build_dir.exists(): - shutil.rmtree(build_dir) - - session.run("sphinx-build", *args) - - -@session(python=python_versions) -def docs(session: Session) -> None: - """ - Build and serve the documentation with live reloading on file changes. - - :param session: The Session object. - """ - args = session.posargs or ["--open-browser", "docs", "docs/_build"] - session.install(".") - session.install( - "sphinx", - "sphinx-autobuild", - "sphinx-click", - "sphinx-rtd-theme", - "sphinx-rtd-dark-mode", - ) - - build_dir = Path("docs", "_build") - if build_dir.exists(): - shutil.rmtree(build_dir) - - session.run("sphinx-autobuild", *args) diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 71ef279ec..000000000 --- a/poetry.lock +++ /dev/null @@ -1,7010 +0,0 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.2" -description = "Happy Eyeballs for asyncio" -optional = false -python-versions = ">=3.10" -groups = ["main", "torch-cuda"] -files = [ - {file = "aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4"}, - {file = "aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64"}, -] - -[[package]] -name = "aiohttp" -version = "3.14.1" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.10" -groups = ["main", "torch-cuda"] -files = [ - {file = "aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e"}, - {file = "aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491"}, - {file = "aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb"}, - {file = "aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d"}, - {file = "aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966"}, - {file = "aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6"}, - {file = "aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df"}, - {file = "aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c"}, - {file = "aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2"}, - {file = "aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f"}, - {file = "aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730"}, - {file = "aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85"}, - {file = "aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3"}, - {file = "aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126"}, - {file = "aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5"}, - {file = "aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b"}, - {file = "aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480"}, - {file = "aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d"}, - {file = "aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c"}, - {file = "aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087"}, - {file = "aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3"}, - {file = "aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4"}, - {file = "aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271"}, - {file = "aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847"}, - {file = "aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178"}, - {file = "aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf"}, - {file = "aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd"}, - {file = "aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe"}, - {file = "aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4"}, - {file = "aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876"}, - {file = "aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da"}, - {file = "aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451"}, - {file = "aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345"}, - {file = "aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588"}, - {file = "aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780"}, - {file = "aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a"}, - {file = "aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8"}, - {file = "aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15"}, - {file = "aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8"}, - {file = "aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3"}, - {file = "aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba"}, - {file = "aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397"}, - {file = "aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448"}, - {file = "aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004"}, - {file = "aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602"}, - {file = "aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95"}, - {file = "aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444"}, - {file = "aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0"}, - {file = "aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719"}, - {file = "aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec"}, - {file = "aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2"}, - {file = "aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340"}, - {file = "aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3"}, - {file = "aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d"}, - {file = "aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c"}, - {file = "aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365"}, - {file = "aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9"}, - {file = "aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6"}, - {file = "aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035"}, -] - -[package.dependencies] -aiohappyeyeballs = ">=2.5.0" -aiosignal = ">=1.4.0" -attrs = ">=17.3.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -propcache = ">=0.2.0" -typing_extensions = {version = ">=4.4", markers = "python_version < \"3.13\""} -yarl = ">=1.17.0,<2.0" - -[package.extras] -speedups = ["Brotli (>=1.2) ; platform_python_implementation == \"CPython\" and sys_platform != \"android\" and sys_platform != \"ios\"", "aiodns (>=3.3.0) ; sys_platform != \"android\" and sys_platform != \"ios\"", "backports.zstd ; platform_python_implementation == \"CPython\" and python_version < \"3.14\" and sys_platform != \"android\" and sys_platform != \"ios\"", "brotlicffi (>=1.2) ; platform_python_implementation != \"CPython\""] - -[[package]] -name = "aiosignal" -version = "1.4.0" -description = "aiosignal: a list of registered asynchronous callbacks" -optional = false -python-versions = ">=3.9" -groups = ["main", "torch-cuda"] -files = [ - {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, - {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, -] - -[package.dependencies] -frozenlist = ">=1.1.0" -typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} - -[[package]] -name = "alabaster" -version = "1.0.0" -description = "A light, configurable Sphinx theme" -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"}, - {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"}, -] - -[[package]] -name = "annotated-doc" -version = "0.0.4" -description = "Document parameters, class attributes, return types, and variables inline, with Annotated." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, - {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.14.1" -description = "High-level concurrency and networking framework on top of asyncio or Trio" -optional = false -python-versions = ">=3.10" -groups = ["main", "development"] -files = [ - {file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"}, - {file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"}, -] - -[package.dependencies] -idna = ">=2.8" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -trio = ["trio (>=0.32.0)"] - -[[package]] -name = "argcomplete" -version = "3.6.3" -description = "Bash tab completion for argparse" -optional = false -python-versions = ">=3.8" -groups = ["development"] -files = [ - {file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"}, - {file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"}, -] - -[package.extras] -test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] - -[[package]] -name = "attrs" -version = "26.1.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.9" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, - {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, -] - -[[package]] -name = "babel" -version = "2.18.0" -description = "Internationalization utilities" -optional = false -python-versions = ">=3.8" -groups = ["development"] -files = [ - {file = "babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35"}, - {file = "babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d"}, -] - -[package.extras] -dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""] - -[[package]] -name = "backports-tarfile" -version = "1.2.0" -description = "Backport of CPython tarfile module" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.11\"" -files = [ - {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, - {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] - -[[package]] -name = "backports-zstd" -version = "1.6.0" -description = "Backport of compression.zstd" -optional = false -python-versions = "<3.14,>=3.10" -groups = ["main"] -files = [ - {file = "backports_zstd-1.6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:73000459db113a658c4fb0510100ef0e79137b5828bf957b7709aacae4eb1b87"}, - {file = "backports_zstd-1.6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6e78d5e28f812b39f92397806ecddd4a6f3bf35531a8c039a1f187abc931af8"}, - {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:32f04d54ec1fdf3aa648b24a10b1c9234ed2046cc4af7a8850cbc236c05d42f3"}, - {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83415af3c64550a56cc20b4cce59bbaa81f21d28466d7adf98feff011ecbc66d"}, - {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3c17e6a267d13de9cbf14bf2ebfa87e03d26692456fc67d2dbed9da4f479b18"}, - {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:75578c71644b031118ce938855a53530708db7f4af6e83e2f8840d5a1de990f8"}, - {file = "backports_zstd-1.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4ae7ed5a6d813450cc2d818284ea3db9721edcef50a56aae42ea06feec38c6e"}, - {file = "backports_zstd-1.6.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5e9a8370c8ed873083d5de956d6b2e60adbad31e52d7a11111c96ef01d1910ae"}, - {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c2d1ccfe088e8279d605011a3575619a74526c261be357695b3258c0f636115a"}, - {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e73a550dbeb84e8fa50f8385f7735e9a4735b465851ef617d02f80ab10e44e7e"}, - {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84f92e5a60a78c72ccda79d0417d311a1f6da18f446423ed411726d545bf7b56"}, - {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0eb4281f402b94d397b7482f6d9efd04c28274e4ed6eb57eb1f87bdd091a6a87"}, - {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d6b9b06323e3ba947c0003b2d70e02f33c90c36bc6262a92eb8201afc4a1aa08"}, - {file = "backports_zstd-1.6.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8872a0e9f1af975966b5be6af7eebd3dc4046f15e470b719316516dc3d137cd6"}, - {file = "backports_zstd-1.6.0-cp310-cp310-win32.whl", hash = "sha256:c14fa5dc39a804f1b92d63506f450eca5c59647a18d197d1a564b89dac1be1ce"}, - {file = "backports_zstd-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:8219d6fceae6b39535c4ac323dba0923d10f781d59962ff3504e693fdcafa92c"}, - {file = "backports_zstd-1.6.0-cp310-cp310-win_arm64.whl", hash = "sha256:b7bc9a0b66097f03820a54316d2fdd0beb38859cf98f10d63e94c55450ed8920"}, - {file = "backports_zstd-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4fc41b2df5529cad5ceb230319e82728096d4b353ce8d4df68a2ec37e291bb8"}, - {file = "backports_zstd-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:83391ef5935cc0f329b1abca414ae20ffe40d335fc21a4b5e664f08a74317d5f"}, - {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:7d3f64c503af7b60115b97c16feaf75bd191ef2c978d5c0c7725a6682bef63c5"}, - {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0308990ffc998df3c7ed35276bde049728b5c3956203cae40d80893576a41459"}, - {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c298785e2fadeab82342040f2d9ce764ce500e6da6a6d99a2de514e63580b5a"}, - {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae106fe16e36efc60ab098d02478d30aa0e31e1420eb4ecf0116459253bc6361"}, - {file = "backports_zstd-1.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7293fefe15f0e5852bdb4ad1e0e26f3cbd4d3e61c19f751ecc4ff34bc1eb237d"}, - {file = "backports_zstd-1.6.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ece8e7288db5b827ef8c64b2f78519f1a173a8991a625978fce02eccd7654fe9"}, - {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:28eef3881164f3c23ce58ed59e4684103bdd279583eb2d299858c9e9b72fde9a"}, - {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:481a1e9bd8f419fdc625307aa20234687f99368c75df511ef589693c5fea4c6f"}, - {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3b6713371f8987a1178df93cb36f29eef191f224021e2d656b2f11ce60d26816"}, - {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b0ddbcd2866b8ff1a2836e4b0e4d44788f5b992d83fac75a38cda8f9a2bee079"}, - {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2914abea516704bdafb2090acd3f15b5f9debecfabd15b8dd8285b2ad3b92209"}, - {file = "backports_zstd-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd085eafa2aac6f883afd28210a3231f717f25409a1e44a39bb7b04c8c5b5646"}, - {file = "backports_zstd-1.6.0-cp311-cp311-win32.whl", hash = "sha256:b81b4cf3d6e0ad7ac92bef248f49fafc954262c5fb0f7e19d6aac497e5a856b2"}, - {file = "backports_zstd-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:10b61850c4112952e05aa6e6cce8c9a5936fbeadb321e154216705cc76a14afa"}, - {file = "backports_zstd-1.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:068ef3d8c18815a2e3a752f766313e19910e7c50939b956923748d9c04ebcb1b"}, - {file = "backports_zstd-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0466b14723f3b7697669c00ee66fe16e30e25636b286b0a923fa86fa3d8a753c"}, - {file = "backports_zstd-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1d146926e997d2d3de8212bdcbf4985344a2622ca3bec458d8908000a84fd883"}, - {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:460fd6b3f338c659507ae36cfd6b58ac9942a2ff233c5cf574416dfec0451a84"}, - {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c2b1f4a640c51130caa92cef5bf72bd3c3dbbcfbf814c37403aa0601b1811b0"}, - {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:beb43e9885202c8d4f3762319ed4d5e98e197622afbff8439fbbdd81d08938b9"}, - {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fbb746522ebfc11155f1cd688e2c48ef3d74125e38b63eabdaab068a055c3e88"}, - {file = "backports_zstd-1.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a99710fbb225d459d66def4dc2bb2cd4a9a0bdc8b799fc0621cfdd863be9c93"}, - {file = "backports_zstd-1.6.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f69365ee2b836939137de024a302395a1cb8654fb6dc5ffef6381105259c8f87"}, - {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:66cf8038893c7708ec345ffb3ac63c775d10f430f323ac2f0334fdb6a397c57c"}, - {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e514c71ca72f3b56bd8fbda1a6a5b7d1100a2764b42a3c74a38841f25f9b00ab"}, - {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7741e44f7938ec94f9a52678c8d19b7bc548522ffdc39c9e4481af8db545fa9a"}, - {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97e8a9674652496c7612b528085dd5a296c052a2edc466ca1bfb7b0b27820413"}, - {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:23a793f2fed4dbf0517319759a2cded0b0dd8e8d3797fe30badd5693e320c175"}, - {file = "backports_zstd-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b951113113ed4b8d173418a4f155c14b739dace626b3fa3f82be1831958d39e4"}, - {file = "backports_zstd-1.6.0-cp312-cp312-win32.whl", hash = "sha256:6430b34a2ae6fcc604672f4f913102563473d9a015bdca1ce8c95041cc1f2677"}, - {file = "backports_zstd-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:08793876172551a930ce4d65c712cd516184d1a97070d4a1193e05bf0cf7040d"}, - {file = "backports_zstd-1.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:03b7c59c71f7a597e2bcb3f8368371e9a660a1bdf1c37afc1f1ad1496a013c19"}, - {file = "backports_zstd-1.6.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:2ace939e4d620e119423606f2d3d7115f8707733bf57f279ad9a9383f875986f"}, - {file = "backports_zstd-1.6.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:4c68a9ed2df0cca51d774c521e68a34d2e3d9ebfc687ef8096adfd4f345b551d"}, - {file = "backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:30576f49b82328ec8af16c11100efe52ca88526f71bbe100ef6b4e707dc13bf2"}, - {file = "backports_zstd-1.6.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b4bddfcfb6679215d6f4dc5f79a1f9301af339480d70527a14b57a1f2e6b6cbf"}, - {file = "backports_zstd-1.6.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:65048ed08c5124f05ff9f355ab9703014bb2dbe7f8d9948ce193685b1775f442"}, - {file = "backports_zstd-1.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5918fc6b31437208721276964323933cd86077b8d5b469c59c1b3fd2c8220a05"}, - {file = "backports_zstd-1.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b6c8b02ab0ccb2431bb7bc238be91d158b308915e7b07937388e540466fe7e7"}, - {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:711e6b98f8924e8b4a61ff97ab6321f33de024e1ed6a32f5123763aeda8459be"}, - {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2ba9ac10fc393e5123a08802e0e895a107cb4a66b9973d2844dbd8a343111e59"}, - {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f723219335387d7546412d8141e0303590600949b4184a1391a0c6a3c756058"}, - {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64b94d7a836568926a3309ff510c7f8261b881b341fd4992cabf4f0998878f8a"}, - {file = "backports_zstd-1.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e39258a09b1c7ca70b5e94a5c5ccfe4700b4250b8077cfeab31d0f79565d4c9b"}, - {file = "backports_zstd-1.6.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:15b1aae0f64cd742df4bba1d989d0a09a6ec619202543fdba684640454541fd3"}, - {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25b5ddc789480072551af571a746e9500356b2aff0499861cf2ca07ea7431e68"}, - {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a13cfa3410a75e4cb87abdb669aaf79da861cb79299159054ff8f77b9671bc40"}, - {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2ddab55a5f54dec8acfad68ef70f1c704fd21919990ddc238afbd6f496e61c6a"}, - {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fa305a84087e10d7a85e8a8a3dcba8cdbda4868f2180173b264b7b488fd37c55"}, - {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:df27b57d214a3124fbe4e933ef5a903d4567f154260d9aece8c797a987f2a205"}, - {file = "backports_zstd-1.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28fecd73459d74910ae1987ab84b7bef690d3dd860948430dd5555108b006daf"}, - {file = "backports_zstd-1.6.0-cp313-cp313-win32.whl", hash = "sha256:3e689af303df287142770abe3a48bbefd24dab4a09da5807d0e1fa8c75bab026"}, - {file = "backports_zstd-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:b067b1ef9c8e41fb0882c828aa37829938b5c0dab067eca72b23fc24c563b9da"}, - {file = "backports_zstd-1.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:a838296f5b84c920172fb579cac894d255c1fc25457c7234613ddcfa385e49b7"}, - {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6c73ae37dbf9207727ac095dedef864c05d836eaec962a47b3b64eaadaf1c6b6"}, - {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:839faf90a7eb525a401978dc925df8c44bd12526e8ba1529b9f8a7106e729637"}, - {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f8f5c1c7c69a4b00889e52d9304a918a5b49010f9645768eb5fd0ad404f790ba"}, - {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e80bceebc9b58e959bede9b26cafe15b5b9526f3533a6dd06330c5da73cb9329"}, - {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79284c1dd702f4f24ed1a36e51555c907dd237b6c0d829595978f4089a2aeea9"}, - {file = "backports_zstd-1.6.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1e20b3ecd0a711be82e964aca28554eabbc31ee69a20e5e7b8fd42268af46212"}, - {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:aeef8563b82ed4af328f98e5041c1b4800d86f68f857ffd1577d4d47dc9aa6cd"}, - {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb75e33131946fabd6319061df3b8b1d588fe0963183280e9b5f49f7772fc09"}, - {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:ef132cfb638e9a86bd5dc07fb4e1cb895bc55bce6bb5e759366e8b160d0747e2"}, - {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab70eace272d6f122b121c057e436709b50a28abf30d97aab28433c08f4a4095"}, - {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17efb3d11137de5166dd51eedab9c36ad633402acba386eee8d715213ea47e49"}, - {file = "backports_zstd-1.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:994167ff6551b9c1ce226e0aab16295b98c94507b5701aa60d2c32b7d50796b1"}, - {file = "backports_zstd-1.6.0.tar.gz", hash = "sha256:80a7859ffe70bf239d7a2ce15293bdeb5b4280ff7dc326ffab312b0e254dbb24"}, -] - -[[package]] -name = "bandit" -version = "1.9.4" -description = "Security oriented static analyser for python code." -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e"}, - {file = "bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628"}, -] - -[package.dependencies] -colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} -PyYAML = ">=5.3.1" -rich = "*" -stevedore = ">=1.20.0" - -[package.extras] -baseline = ["GitPython (>=3.1.30)"] -sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] -test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] -toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] -yaml = ["PyYAML"] - -[[package]] -name = "biothings-client" -version = "0.5.0" -description = "Python Client for BioThings API services." -optional = true -python-versions = ">=3.7" -groups = ["main"] -markers = "extra == \"sparsego\"" -files = [ - {file = "biothings_client-0.5.0-py3-none-any.whl", hash = "sha256:733488a5133bb6833a43cc895ad122654d8c72446926301d8607df0a9888c004"}, - {file = "biothings_client-0.5.0.tar.gz", hash = "sha256:f9fddc7436d59bcde4f9a2501431fc744d142062ac4796585470adca29c59ed4"}, -] - -[package.dependencies] -httpx = {version = ">=0.25.0", markers = "python_version >= \"3.8\""} - -[package.extras] -caching = ["anysqlite ; python_version >= \"3.8\"", "hishel[httpx] (==1.1.8) ; python_version == \"3.9\"", "hishel[httpx] (>=1.1.9) ; python_version > \"3.9\""] -dataframe = ["pandas (>=1.2.0)"] -jsonld = ["PyLD (>=0.7.2)"] -tests = ["pytest (>=7.4.4) ; python_version == \"3.7\"", "pytest (>=8.3.3) ; python_version >= \"3.8\"", "pytest-asyncio (>=0.21.2) ; python_version == \"3.7\"", "pytest-asyncio (>=0.23.8) ; python_version >= \"3.8\""] - -[[package]] -name = "black" -version = "26.5.1" -description = "The uncompromising code formatter." -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893"}, - {file = "black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90"}, - {file = "black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4"}, - {file = "black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef"}, - {file = "black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22"}, - {file = "black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c"}, - {file = "black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7"}, - {file = "black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59"}, - {file = "black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3"}, - {file = "black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe"}, - {file = "black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8"}, - {file = "black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217"}, - {file = "black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d"}, - {file = "black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264"}, - {file = "black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418"}, - {file = "black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3"}, - {file = "black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0"}, - {file = "black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294"}, - {file = "black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a"}, - {file = "black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52"}, - {file = "black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168"}, - {file = "black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3"}, - {file = "black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18"}, - {file = "black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50"}, - {file = "black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae"}, - {file = "black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2"}, - {file = "black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73"}, -] - -[package.dependencies] -click = ">=8.0.0" -mypy-extensions = ">=0.4.3" -packaging = ">=22.0" -pathspec = ">=1.0.0" -platformdirs = ">=2" -pytokens = ">=0.4.0,<0.5.0" - -[package.extras] -colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.10)"] -jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] -uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] - -[[package]] -name = "bokeh" -version = "3.7.3" -description = "Interactive plots and applications in the browser from Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "bokeh-3.7.3-py3-none-any.whl", hash = "sha256:b0e79dd737f088865212e4fdcb0f3b95d087f0f088bf8ca186a300ab1641e2c7"}, - {file = "bokeh-3.7.3.tar.gz", hash = "sha256:70a89a9f797b103d5ee6ad15fb7944adda115cf0da996ed0b75cfba61cb12f2b"}, -] - -[package.dependencies] -contourpy = ">=1.2" -Jinja2 = ">=2.9" -narwhals = ">=1.13" -numpy = ">=1.16" -packaging = ">=16.8" -pandas = ">=1.2" -pillow = ">=7.1.0" -PyYAML = ">=3.10" -tornado = {version = ">=6.2", markers = "sys_platform != \"emscripten\""} -xyzservices = ">=2021.9.1" - -[[package]] -name = "build" -version = "1.5.0" -description = "A simple, correct Python build frontend" -optional = false -python-versions = ">=3.10" -groups = ["main", "development"] -files = [ - {file = "build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f"}, - {file = "build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "os_name == \"nt\""} -packaging = ">=24.0" -pyproject_hooks = "*" - -[package.extras] -keyring = ["keyring"] -uv = ["uv (>=0.1.18)"] -virtualenv = ["virtualenv (>=20.17) ; python_version >= \"3.10\" and python_version < \"3.14\"", "virtualenv (>=20.31) ; python_version >= \"3.14\""] - -[[package]] -name = "cachecontrol" -version = "0.14.4" -description = "httplib2 caching for requests" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b"}, - {file = "cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1"}, -] - -[package.dependencies] -filelock = {version = ">=3.8.0", optional = true, markers = "extra == \"filecache\""} -msgpack = ">=0.5.2,<2.0.0" -requests = ">=2.16.0" - -[package.extras] -dev = ["cachecontrol[filecache,redis]", "cheroot (>=11.1.2)", "cherrypy", "codespell", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "types-redis", "types-requests"] -filecache = ["filelock (>=3.8.0)"] -redis = ["redis (>=2.10.5)"] - -[[package]] -name = "certifi" -version = "2026.6.17" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, - {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, -] - -[[package]] -name = "cffi" -version = "2.0.0" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\"" -files = [ - {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, - {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"}, - {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"}, - {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"}, - {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"}, - {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"}, - {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"}, - {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"}, - {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"}, - {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"}, - {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"}, - {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"}, - {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"}, - {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"}, - {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"}, - {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"}, - {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"}, - {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"}, - {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"}, - {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"}, - {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"}, - {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"}, - {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"}, - {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"}, - {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"}, - {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"}, - {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"}, - {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"}, - {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"}, - {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"}, - {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"}, - {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"}, - {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"}, - {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"}, - {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"}, - {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"}, - {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"}, - {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"}, - {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"}, - {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"}, - {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, - {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, -] - -[package.dependencies] -pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} - -[[package]] -name = "cfgv" -version = "3.5.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0"}, - {file = "cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.7" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, - {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, - {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, -] - -[[package]] -name = "cleo" -version = "2.1.0" -description = "Cleo allows you to create beautiful and testable command-line interfaces." -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "cleo-2.1.0-py3-none-any.whl", hash = "sha256:4a31bd4dd45695a64ee3c4758f583f134267c2bc518d8ae9a29cf237d009b07e"}, - {file = "cleo-2.1.0.tar.gz", hash = "sha256:0b2c880b5d13660a7ea651001fb4acb527696c01f15c9ee650f377aa543fd523"}, -] - -[package.dependencies] -crashtest = ">=0.4.1,<0.5.0" -rapidfuzz = ">=3.0.0,<4.0.0" - -[[package]] -name = "click" -version = "8.4.2" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.10" -groups = ["main", "development"] -files = [ - {file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"}, - {file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\" or os_name == \"nt\"", torch-cuda = "platform_system == \"Windows\""} - -[[package]] -name = "colorlog" -version = "6.10.1" -description = "Add colours to the output of Python's logging module." -optional = false -python-versions = ">=3.6" -groups = ["development"] -files = [ - {file = "colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c"}, - {file = "colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} - -[package.extras] -development = ["black", "flake8", "mypy", "pytest", "types-colorama"] - -[[package]] -name = "contourpy" -version = "1.3.3" -description = "Python library for calculating contours of 2D quadrilateral grids" -optional = false -python-versions = ">=3.11" -groups = ["main"] -files = [ - {file = "contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1"}, - {file = "contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381"}, - {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7"}, - {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1"}, - {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a"}, - {file = "contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db"}, - {file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620"}, - {file = "contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f"}, - {file = "contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff"}, - {file = "contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42"}, - {file = "contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470"}, - {file = "contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb"}, - {file = "contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6"}, - {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7"}, - {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8"}, - {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea"}, - {file = "contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1"}, - {file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7"}, - {file = "contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411"}, - {file = "contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69"}, - {file = "contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b"}, - {file = "contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc"}, - {file = "contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5"}, - {file = "contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1"}, - {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286"}, - {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5"}, - {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67"}, - {file = "contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9"}, - {file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659"}, - {file = "contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7"}, - {file = "contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d"}, - {file = "contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263"}, - {file = "contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9"}, - {file = "contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d"}, - {file = "contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216"}, - {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae"}, - {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20"}, - {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99"}, - {file = "contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b"}, - {file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a"}, - {file = "contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e"}, - {file = "contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3"}, - {file = "contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8"}, - {file = "contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301"}, - {file = "contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a"}, - {file = "contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77"}, - {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5"}, - {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4"}, - {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36"}, - {file = "contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3"}, - {file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b"}, - {file = "contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36"}, - {file = "contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d"}, - {file = "contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd"}, - {file = "contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339"}, - {file = "contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772"}, - {file = "contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77"}, - {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13"}, - {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe"}, - {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f"}, - {file = "contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0"}, - {file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4"}, - {file = "contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f"}, - {file = "contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae"}, - {file = "contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc"}, - {file = "contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989"}, - {file = "contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77"}, - {file = "contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880"}, -] - -[package.dependencies] -numpy = ">=1.25" - -[package.extras] -bokeh = ["bokeh", "selenium"] -docs = ["furo", "sphinx (>=7.2)", "sphinx-copybutton"] -mypy = ["bokeh", "contourpy[bokeh,docs]", "docutils-stubs", "mypy (==1.17.0)", "types-Pillow"] -test = ["Pillow", "contourpy[test-no-images]", "matplotlib"] -test-no-images = ["pytest", "pytest-cov", "pytest-rerunfailures", "pytest-xdist", "wurlitzer"] - -[[package]] -name = "crashtest" -version = "0.4.1" -description = "Manage Python errors with ease" -optional = false -python-versions = ">=3.7,<4.0" -groups = ["main"] -files = [ - {file = "crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5"}, - {file = "crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce"}, -] - -[[package]] -name = "cryptography" -version = "49.0.0" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.9" -groups = ["main"] -markers = "sys_platform == \"linux\"" -files = [ - {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, - {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, - {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, - {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, - {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, - {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, - {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, - {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, - {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, - {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, - {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, - {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, - {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, - {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, - {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, - {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, - {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -ssh = ["bcrypt (>=3.1.5)"] - -[[package]] -name = "cuda-bindings" -version = "13.3.1" -description = "Python bindings for CUDA" -optional = false -python-versions = ">=3.10" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86"}, - {file = "cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0"}, - {file = "cuda_bindings-13.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:c0c4b1a995098c46695c24257a342dc97d6e6d3f3050b944c9f43bd26d734051"}, - {file = "cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474"}, - {file = "cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708"}, - {file = "cuda_bindings-13.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:507b0e19e7f934c5e30f30f0244ad70a75812619a7d3a0d742543caae1bd50f1"}, - {file = "cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49"}, - {file = "cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a"}, - {file = "cuda_bindings-13.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:b134dd8c5c66ae4c4ad814f7aee88fd215353c077010cbc47e3b55ed35ec9eff"}, - {file = "cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf"}, - {file = "cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7"}, - {file = "cuda_bindings-13.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:18c8c167c8907b8f02531ca810534315c458dabef31f7965095619bf647b9202"}, - {file = "cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8"}, - {file = "cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80"}, - {file = "cuda_bindings-13.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:8de12ef60bf40756852cb62bbb40460609269f6ece522903d1cc93d73a3ececb"}, - {file = "cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76"}, - {file = "cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9"}, - {file = "cuda_bindings-13.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61120b5e4f4a63f67efd7e7396914cb9ef871bb1f0021e990fb70277be240a4d"}, -] - -[package.dependencies] -cuda-pathfinder = ">=1.4.2" - -[package.extras] -all = ["cuda-toolkit (==13.*)", "cuda-toolkit[cufile] (==13.*) ; sys_platform == \"linux\"", "cuda-toolkit[nvfatbin,nvjitlink,nvrtc,nvvm] (==13.*)", "nvidia-cudla (==13.*) ; platform_system == \"Linux\" and platform_machine == \"aarch64\""] - -[[package]] -name = "cuda-pathfinder" -version = "1.5.6" -description = "Pathfinder for CUDA components" -optional = false -python-versions = ">=3.10" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "cuda_pathfinder-1.5.6-py3-none-any.whl", hash = "sha256:7e4c07c117b78ba1fb35dac4c444d21f3677b1b1ff56175c53a8e3025c5b43c0"}, -] - -[[package]] -name = "cuda-toolkit" -version = "13.0.2" -description = "CUDA Toolkit meta-package" -optional = false -python-versions = "*" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb"}, -] - -[package.dependencies] -nvidia-cuda-cupti = {version = "==13.0.85.*", optional = true, markers = "(sys_platform == \"linux\" or sys_platform == \"win32\") and extra == \"cupti\""} -nvidia-cuda-nvrtc = {version = "==13.0.88.*", optional = true, markers = "(sys_platform == \"linux\" or sys_platform == \"win32\") and extra == \"nvrtc\""} -nvidia-cuda-runtime = {version = "==13.0.96.*", optional = true, markers = "(sys_platform == \"linux\" or sys_platform == \"win32\") and extra == \"cudart\""} -nvidia-cufft = {version = "==12.0.0.61.*", optional = true, markers = "(sys_platform == \"linux\" or sys_platform == \"win32\") and extra == \"cufft\""} -nvidia-cufile = {version = "==1.15.1.6.*", optional = true, markers = "sys_platform == \"linux\" and extra == \"cufile\""} -nvidia-curand = {version = "==10.4.0.35.*", optional = true, markers = "(sys_platform == \"linux\" or sys_platform == \"win32\") and extra == \"curand\""} -nvidia-cusolver = {version = "==12.0.4.66.*", optional = true, markers = "(sys_platform == \"linux\" or sys_platform == \"win32\") and extra == \"cusolver\""} -nvidia-cusparse = {version = "==12.6.3.3.*", optional = true, markers = "(sys_platform == \"linux\" or sys_platform == \"win32\") and extra == \"cusparse\""} -nvidia-nvjitlink = {version = "==13.0.88.*", optional = true, markers = "(sys_platform == \"linux\" or sys_platform == \"win32\") and extra == \"nvjitlink\""} -nvidia-nvtx = {version = "==13.0.85.*", optional = true, markers = "(sys_platform == \"linux\" or sys_platform == \"win32\") and extra == \"nvtx\""} - -[package.extras] -all = ["nvidia-cublas (==13.1.0.3.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-cccl (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-crt (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-culibos (==13.0.85.*) ; sys_platform == \"linux\"", "nvidia-cuda-cupti (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-cuxxfilt (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-nvcc (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-nvrtc (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-opencl (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-profiler-api (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-runtime (==13.0.96.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cuda-sanitizer-api (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cufft (==12.0.0.61.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cufile (==1.15.1.6.*) ; sys_platform == \"linux\"", "nvidia-curand (==10.4.0.35.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cusolver (==12.0.4.66.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-cusparse (==12.6.3.3.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-npp (==13.0.1.2.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-nvfatbin (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-nvjitlink (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-nvjpeg (==13.0.1.86.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-nvml-dev (==13.0.87.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-nvptxcompiler (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-nvtx (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\"", "nvidia-nvvm (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -cccl = ["nvidia-cuda-cccl (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -crt = ["nvidia-cuda-crt (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -cublas = ["nvidia-cublas (==13.1.0.3.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -cudart = ["nvidia-cuda-runtime (==13.0.96.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -cufft = ["nvidia-cufft (==12.0.0.61.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -cufile = ["nvidia-cufile (==1.15.1.6.*) ; sys_platform == \"linux\""] -culibos = ["nvidia-cuda-culibos (==13.0.85.*) ; sys_platform == \"linux\""] -cupti = ["nvidia-cuda-cupti (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -curand = ["nvidia-curand (==10.4.0.35.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -cusolver = ["nvidia-cusolver (==12.0.4.66.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -cusparse = ["nvidia-cusparse (==12.6.3.3.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -cuxxfilt = ["nvidia-cuda-cuxxfilt (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -npp = ["nvidia-npp (==13.0.1.2.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -nvcc = ["nvidia-cuda-nvcc (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -nvfatbin = ["nvidia-nvfatbin (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -nvjitlink = ["nvidia-nvjitlink (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -nvjpeg = ["nvidia-nvjpeg (==13.0.1.86.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -nvml = ["nvidia-nvml-dev (==13.0.87.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -nvptxcompiler = ["nvidia-nvptxcompiler (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -nvrtc = ["nvidia-cuda-nvrtc (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -nvtx = ["nvidia-nvtx (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -nvvm = ["nvidia-nvvm (==13.0.88.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -opencl = ["nvidia-cuda-opencl (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -profiler = ["nvidia-cuda-profiler-api (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] -sanitizer = ["nvidia-cuda-sanitizer-api (==13.0.85.*) ; sys_platform == \"linux\" or sys_platform == \"win32\""] - -[[package]] -name = "curve-curator" -version = "0.6.0" -description = "CurveCurator is an open-source analysis platform for any dose-dependent data. It fits a classical 4-parameter equation to estimate effect potency, effect size, and the statistical significance of the observed response. 2D-thresholding efficiently reduces false positives in high-throughput experiments and separates relevant from irrelevant or insignificant hits in an automated and unbiased manner. An interactive dashboard allows users to quickly explore data locally." -optional = false -python-versions = "<3.14,>=3.11" -groups = ["main"] -files = [ - {file = "curve_curator-0.6.0-py3-none-any.whl", hash = "sha256:c4345ff856e6de68826fd80536731989e88d9910ba81b00a8ffb5665e318f7e2"}, - {file = "curve_curator-0.6.0.tar.gz", hash = "sha256:d323dbf900a2c390de7e4ee744a74268d328f917decb277216043ffabccd1a92"}, -] - -[package.dependencies] -bokeh = ">=3.4.0,<3.8.0" -numpy = ">=1.25.0,<3.0" -pandas = ">=2.1.0,<3.0.0" -pytest = ">=7.4.3,<8.0.0" -scipy = ">=1.10.1,<2.0.0" -statsmodels = ">=0.14.0,<0.15.0" -tqdm = ">=4.66.1,<5.0.0" - -[[package]] -name = "cycler" -version = "0.12.1" -description = "Composable style cycles" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30"}, - {file = "cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c"}, -] - -[package.extras] -docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] -tests = ["pytest", "pytest-cov", "pytest-xdist"] - -[[package]] -name = "darglint" -version = "1.8.1" -description = "A utility for ensuring Google-style docstrings stay up to date with the source code." -optional = false -python-versions = ">=3.6,<4.0" -groups = ["development"] -files = [ - {file = "darglint-1.8.1-py3-none-any.whl", hash = "sha256:5ae11c259c17b0701618a20c3da343a3eb98b3bc4b5a83d31cdd94f5ebdced8d"}, - {file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"}, -] - -[[package]] -name = "dependency-groups" -version = "1.3.1" -description = "A tool for resolving PEP 735 Dependency Group data" -optional = false -python-versions = ">=3.8" -groups = ["development"] -files = [ - {file = "dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030"}, - {file = "dependency_groups-1.3.1.tar.gz", hash = "sha256:78078301090517fd938c19f64a53ce98c32834dfe0dee6b88004a569a6adfefd"}, -] - -[package.dependencies] -packaging = "*" - -[package.extras] -cli = ["tomli ; python_version < \"3.11\""] - -[[package]] -name = "distlib" -version = "0.4.3" -description = "Distribution utilities" -optional = false -python-versions = "*" -groups = ["main", "development"] -files = [ - {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, - {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, -] - -[[package]] -name = "docutils" -version = "0.21.2" -description = "Docutils -- Python Documentation Utilities" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, - {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, -] - -[[package]] -name = "dulwich" -version = "1.2.7" -description = "Python Git Library" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "dulwich-1.2.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5f543a3c141975b455e1d1db12ae020fa36e87141196983e83e7728fd2fbd154"}, - {file = "dulwich-1.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e7248b629e70bc7aea0a9f39dfbbb57d23d3584765def4217566ecbdc703999e"}, - {file = "dulwich-1.2.7-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:454f420fd8795130098d69788347570edb55c28598a42c40556ec3df93b52cb3"}, - {file = "dulwich-1.2.7-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:2fd54b89dbeda61801c2c8afaa6ed692c0306e6ed2633c6125679cb6cae04e27"}, - {file = "dulwich-1.2.7-cp310-cp310-win32.whl", hash = "sha256:44e65814ec97097d6eb82d1f3c92043a23af14c2e17cf9143f1fa890bc367186"}, - {file = "dulwich-1.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:cd73e12fccaab06ccc5a47955b143b26dc1187ed30b0e5a64b149527f8eaa5cf"}, - {file = "dulwich-1.2.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:81ad402d3378d5e294867a8fb5c00b583e7ea2800486a9cf4488b97f454dc287"}, - {file = "dulwich-1.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65924e19e70786efec160555eb7f81bf0499dc5638d26af864c603104cc2a7d4"}, - {file = "dulwich-1.2.7-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:598a7a1311c09fbccda4a3c1c252864db538f0153331bb95d62a79c3f3637df9"}, - {file = "dulwich-1.2.7-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:46d48fa1a0376ddfc0ba22a07506d1b11d020e403b3caa2aecaeb7fb734cd7e3"}, - {file = "dulwich-1.2.7-cp311-cp311-win32.whl", hash = "sha256:61609efd234a0a593cc332ee789fab7849ea9792fcc9e6483382d6d520116401"}, - {file = "dulwich-1.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:f71def1d6cde5da954cbb0d200b759d106eb5fffba119751e709b306c56d73bc"}, - {file = "dulwich-1.2.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b2209775a547ac2cde272b03040a60cf40e8a95bb9011cb688b5bf1f49018c2d"}, - {file = "dulwich-1.2.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf1b21a46f57a6b88c26ea03f44ac7fd12f4a3f588c9f8353c077d61bbdd7dd0"}, - {file = "dulwich-1.2.7-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:73e098a93fa166e022119e095a511fe496ece238dd4f13af95dce240d89f6d2a"}, - {file = "dulwich-1.2.7-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4eb7c4600988c94f37e471cce6e037a23c90d28e6fe48638782bede1635676b0"}, - {file = "dulwich-1.2.7-cp312-cp312-win32.whl", hash = "sha256:6b2ef8bf71a9002dcc9a08030f9715778015c0e940b6e06d49ca3414b46c09e7"}, - {file = "dulwich-1.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:5fdbe9bcdb1ef1330d290cad5497f8614c1af0073c0a9942f556bf1f4ecbfa7e"}, - {file = "dulwich-1.2.7-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:e30e5c08bd33f9422285b563a219b93f4a43a2cfe6849cb37e82c553bdb717af"}, - {file = "dulwich-1.2.7-cp313-cp313-android_24_x86_64.whl", hash = "sha256:0a434ffbae0f745ba2477bd3bb27ac3fd1d54e52874e93a43ecd3e40f1e96c41"}, - {file = "dulwich-1.2.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0744969ed174a1c811dd8710c2c013b55c7e51f98ddcfaed360e79522e32dc20"}, - {file = "dulwich-1.2.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:153a9c3fcb148cac492689549411b43067a792fef5b13b4735dda01612363a42"}, - {file = "dulwich-1.2.7-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b40b5db59b4c62e72f3ba8eee6a11a9f3f4eba524470a4d7ebf7923c32f8f2a0"}, - {file = "dulwich-1.2.7-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3e3a57b143b94b97918e671017cb4343c1a7a06235c23b7a0d47dcaa188d81b1"}, - {file = "dulwich-1.2.7-cp313-cp313-win32.whl", hash = "sha256:fb981da1ce83d0ddc88cf253d55cd5977476cbfb8fbed5893ac219eec6fe896e"}, - {file = "dulwich-1.2.7-cp313-cp313-win_amd64.whl", hash = "sha256:adfab1784b598aebdfa6e449eb5a425a2d198fcd1c77cbb3df16aee58d841ba8"}, - {file = "dulwich-1.2.7-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:221e902210c3450a4bb40b4fc7e5b97bf4dc78da82b88701199ac2890bcda1d5"}, - {file = "dulwich-1.2.7-cp314-cp314-android_24_x86_64.whl", hash = "sha256:a24fbb3228c50c1415d945bec6397770558fd95d13a6b5e194ed714245733560"}, - {file = "dulwich-1.2.7-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:92268584bb45f5324e71b5ab8504f87d880a3676c10b069b2eabe5c0aa43f23a"}, - {file = "dulwich-1.2.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9740803cc3ca13bef54b733e24d72844454d88fc6dc0546ee55795ab19905b31"}, - {file = "dulwich-1.2.7-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0ef0c0d2ade80e37969d2aeb572c91e828c31869846b184b425dbca653f2db95"}, - {file = "dulwich-1.2.7-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:fe9e7408160320927f84457f0e6ceb312b2038d14ff2ff063264656c0eff98f2"}, - {file = "dulwich-1.2.7-cp314-cp314-win32.whl", hash = "sha256:e91e983490455fa9014bdd519ea1c9a255559b27c4a0a85043f8845477f829bf"}, - {file = "dulwich-1.2.7-cp314-cp314-win_amd64.whl", hash = "sha256:686859f74194f5ee6eb60df696a423d88645e2a8cd196bb10e286ac0b74192b9"}, - {file = "dulwich-1.2.7-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eb018bdf1de34b6631aa45d450849e2d770119627f1210f885fa5564fbf2d7f5"}, - {file = "dulwich-1.2.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb1179b086414118e5430c0a7238cedcbe69b6ae3e81e93cb7445f7bfbb2aace"}, - {file = "dulwich-1.2.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:97173db8633a30c94d9a16edde936cb7ed94ddf4091fc1ec84e4e67331382ebb"}, - {file = "dulwich-1.2.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0888fe5f97621627c0ee6d9126e3c5bb3328ffaba1ca5c27c60d6b0193a0bb5e"}, - {file = "dulwich-1.2.7-cp314-cp314t-win32.whl", hash = "sha256:5ab91b73a8a0cee8de446caef14b6b1dc3dabfbf0bf7d2f059018f7604bf4f3f"}, - {file = "dulwich-1.2.7-cp314-cp314t-win_amd64.whl", hash = "sha256:8f83ac36c9ffb042eccdd13907e336256ef58ce118490bb4d9d404ddf0f9da23"}, - {file = "dulwich-1.2.7-py3-none-any.whl", hash = "sha256:b2134098974f2721f0ff36408ba810240ffd04aa9ab8b6ff0407ec6d5119cbf2"}, - {file = "dulwich-1.2.7.tar.gz", hash = "sha256:02378954bab6027a2fb41e00a0b40f8124c94fbc15a81c2b6f8bc5af09d09f45"}, -] - -[package.dependencies] -typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.12\""} -urllib3 = ">=2.2.2" - -[package.extras] -aiohttp = ["aiohttp"] -colordiff = ["rich"] -dev = ["codespell (==2.4.2)", "dissolve (>=0.1.1)", "mypy (==2.1.0)", "ruff (==0.15.15)"] -fastimport = ["fastimport"] -fuzzing = ["atheris"] -https = ["urllib3 (>=2.2.2)"] -hypothesis = ["hypothesis (>=6)"] -merge = ["merge3"] -paramiko = ["paramiko"] -patiencediff = ["patiencediff"] -pgp = ["gpg"] -range-diff = ["munkres"] - -[[package]] -name = "fastjsonschema" -version = "2.21.2" -description = "Fastest Python implementation of JSON schema" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463"}, - {file = "fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de"}, -] - -[package.extras] -devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] - -[[package]] -name = "filelock" -version = "3.29.4" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.10" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767"}, - {file = "filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a"}, -] - -[[package]] -name = "findpython" -version = "0.8.0" -description = "A utility to find python versions on your system" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "findpython-0.8.0-py3-none-any.whl", hash = "sha256:4a61ee1618a8b55014f7d41f59345d322be93f6ce62395bdccccc651b3f7e28a"}, - {file = "findpython-0.8.0.tar.gz", hash = "sha256:53b32264874dfa5990bd09d717819386d8db3149d89fe20f88fe1078de286bae"}, -] - -[package.dependencies] -packaging = ">=20" -platformdirs = ">=4.3.6" - -[[package]] -name = "flake8" -version = "7.3.0" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, - {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.14.0,<2.15.0" -pyflakes = ">=3.4.0,<3.5.0" - -[[package]] -name = "flake8-bandit" -version = "4.1.1" -description = "Automated security testing with bandit and flake8." -optional = false -python-versions = ">=3.6" -groups = ["development"] -files = [ - {file = "flake8_bandit-4.1.1-py3-none-any.whl", hash = "sha256:4c8a53eb48f23d4ef1e59293657181a3c989d0077c9952717e98a0eace43e06d"}, - {file = "flake8_bandit-4.1.1.tar.gz", hash = "sha256:068e09287189cbfd7f986e92605adea2067630b75380c6b5733dab7d87f9a84e"}, -] - -[package.dependencies] -bandit = ">=1.7.3" -flake8 = ">=5.0.0" - -[[package]] -name = "flake8-bugbear" -version = "25.11.29" -description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "flake8_bugbear-25.11.29-py3-none-any.whl", hash = "sha256:9bf15e2970e736d2340da4c0a70493db964061c9c38f708cfe1f7b2d87392298"}, - {file = "flake8_bugbear-25.11.29.tar.gz", hash = "sha256:b5d06710f3d26e595541ad303ad4d5cb52578bd4bccbb2c2c0b2c72e243dafc8"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -flake8 = ">=7.2.0" - -[package.extras] -dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] - -[[package]] -name = "flake8-docstrings" -version = "1.7.0" -description = "Extension for flake8 which uses pydocstyle to check docstrings" -optional = false -python-versions = ">=3.7" -groups = ["development"] -files = [ - {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, - {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, -] - -[package.dependencies] -flake8 = ">=3" -pydocstyle = ">=2.1" - -[[package]] -name = "flake8-rst-docstrings" -version = "0.4.0" -description = "Python docstring reStructuredText (RST) validator for flake8" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "flake8_rst_docstrings-0.4.0-py3-none-any.whl", hash = "sha256:f0fe9027af48ef84550bd641eedfbd58a35d182014d372dcb0321535e5face3a"}, - {file = "flake8_rst_docstrings-0.4.0.tar.gz", hash = "sha256:a885cccfac9ff9b1e6d062ac0f8ba79a63fd0cf0fdcf220a5e3d7e6378acc7d0"}, -] - -[package.dependencies] -flake8 = ">=3" -pygments = "*" -restructuredtext_lint = "*" - -[package.extras] -develop = ["build", "twine"] - -[[package]] -name = "flaky" -version = "3.8.1" -description = "Plugin for pytest that automatically reruns flaky tests." -optional = false -python-versions = ">=3.5" -groups = ["main"] -files = [ - {file = "flaky-3.8.1-py2.py3-none-any.whl", hash = "sha256:194ccf4f0d3a22b2de7130f4b62e45e977ac1b5ccad74d4d48f3005dcc38815e"}, - {file = "flaky-3.8.1.tar.gz", hash = "sha256:47204a81ec905f3d5acfbd61daeabcada8f9d4031616d9bcb0618461729699f5"}, -] - -[[package]] -name = "fonttools" -version = "4.63.0" -description = "Tools to manipulate font files" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b"}, - {file = "fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94"}, - {file = "fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579"}, - {file = "fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22"}, - {file = "fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e"}, - {file = "fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69"}, - {file = "fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e"}, - {file = "fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac"}, - {file = "fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f"}, - {file = "fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9"}, - {file = "fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b"}, - {file = "fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18"}, - {file = "fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0"}, - {file = "fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007"}, - {file = "fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb"}, - {file = "fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c"}, - {file = "fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02"}, - {file = "fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0"}, - {file = "fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af"}, - {file = "fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8"}, - {file = "fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b"}, - {file = "fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78"}, - {file = "fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263"}, - {file = "fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272"}, - {file = "fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd"}, - {file = "fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59"}, - {file = "fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d"}, - {file = "fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68"}, - {file = "fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be"}, - {file = "fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27"}, - {file = "fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380"}, - {file = "fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b"}, - {file = "fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745"}, - {file = "fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03"}, - {file = "fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49"}, - {file = "fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b"}, - {file = "fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6"}, - {file = "fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4"}, - {file = "fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616"}, - {file = "fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5"}, - {file = "fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001"}, - {file = "fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e"}, - {file = "fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096"}, - {file = "fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f"}, - {file = "fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40"}, - {file = "fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196"}, - {file = "fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8"}, - {file = "fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419"}, - {file = "fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d"}, - {file = "fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0"}, -] - -[package.extras] -all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0) ; python_version <= \"3.14\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] -graphite = ["lz4 (>=1.7.4.2)"] -interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] -lxml = ["lxml (>=4.0)"] -pathops = ["skia-pathops (>=0.5.0)"] -plot = ["matplotlib"] -repacker = ["uharfbuzz (>=0.45.0)"] -symfont = ["sympy"] -type1 = ["xattr ; sys_platform == \"darwin\""] -unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""] -woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] - -[[package]] -name = "frozenlist" -version = "1.8.0" -description = "A list-like structure which implements collections.abc.MutableSequence" -optional = false -python-versions = ">=3.9" -groups = ["main", "torch-cuda"] -files = [ - {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011"}, - {file = "frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565"}, - {file = "frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450"}, - {file = "frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f"}, - {file = "frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7"}, - {file = "frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a"}, - {file = "frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6"}, - {file = "frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e"}, - {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84"}, - {file = "frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9"}, - {file = "frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581"}, - {file = "frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd"}, - {file = "frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967"}, - {file = "frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25"}, - {file = "frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b"}, - {file = "frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a"}, - {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"}, - {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"}, - {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"}, - {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"}, - {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"}, - {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"}, - {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"}, - {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"}, - {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a"}, - {file = "frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7"}, - {file = "frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5"}, - {file = "frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8"}, - {file = "frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed"}, - {file = "frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496"}, - {file = "frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231"}, - {file = "frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62"}, - {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94"}, - {file = "frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c"}, - {file = "frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714"}, - {file = "frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0"}, - {file = "frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41"}, - {file = "frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b"}, - {file = "frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888"}, - {file = "frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042"}, - {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0"}, - {file = "frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f"}, - {file = "frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e"}, - {file = "frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30"}, - {file = "frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7"}, - {file = "frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806"}, - {file = "frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0"}, - {file = "frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b"}, - {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d"}, - {file = "frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed"}, - {file = "frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a"}, - {file = "frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a"}, - {file = "frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e"}, - {file = "frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df"}, - {file = "frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd"}, - {file = "frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79"}, - {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47"}, - {file = "frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca"}, - {file = "frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61"}, - {file = "frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178"}, - {file = "frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda"}, - {file = "frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087"}, - {file = "frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a"}, - {file = "frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103"}, - {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"}, - {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, -] - -[[package]] -name = "fsspec" -version = "2026.6.0" -description = "File-system specification" -optional = false -python-versions = ">=3.10" -groups = ["main", "torch-cuda"] -files = [ - {file = "fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1"}, - {file = "fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a"}, -] - -[package.dependencies] -aiohttp = {version = "<4.0.0a0 || >4.0.0a0,<4.0.0a1 || >4.0.0a1", optional = true, markers = "extra == \"http\""} - -[package.extras] -abfs = ["adlfs"] -adl = ["adlfs"] -arrow = ["pyarrow (>=1)"] -dask = ["dask", "distributed"] -dev = ["pre-commit", "ruff (>=0.5)"] -doc = ["numpydoc", "sphinx", "sphinx-design", "sphinx-rtd-theme", "yarl"] -dropbox = ["dropbox", "dropboxdrivefs", "requests"] -full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs (>2024.2.0)", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs (>2024.2.0)", "smbprotocol", "tqdm"] -fuse = ["fusepy"] -gcs = ["gcsfs (>2024.2.0)"] -git = ["pygit2"] -github = ["requests"] -gs = ["gcsfs"] -gui = ["panel"] -hdfs = ["pyarrow (>=1)"] -http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"] -libarchive = ["libarchive-c"] -oci = ["ocifs"] -s3 = ["s3fs (>2024.2.0)"] -sftp = ["paramiko"] -smb = ["smbprotocol"] -ssh = ["paramiko"] -test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] -test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"] -test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "backports-zstd ; python_version < \"3.14\"", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas (<3.0.0)", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr (<3.2.0)", "zstandard ; python_version < \"3.14\""] -tqdm = ["tqdm"] - -[[package]] -name = "gitdb" -version = "4.0.12" -description = "Git Object Database" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"}, - {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"}, -] - -[package.dependencies] -smmap = ">=3.0.1,<6" - -[[package]] -name = "gitpython" -version = "3.1.50" -description = "GitPython is a Python library used to interact with Git repositories" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9"}, - {file = "gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc"}, -] - -[package.dependencies] -gitdb = ">=4.0.1,<5" - -[package.extras] -doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] -test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] - -[[package]] -name = "gseapy" -version = "1.3.0" -description = "Gene Set Enrichment Analysis in Python" -optional = true -python-versions = ">=3.9" -groups = ["main"] -markers = "extra == \"precily\"" -files = [ - {file = "gseapy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bb26b213f909f03e4ee539d6289b4f608060855a2d36023a7c0be1a73fd05d43"}, - {file = "gseapy-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a2961534179ddc3902e534760e780d8338c89aae8010f3c65666a5ec3b89904b"}, - {file = "gseapy-1.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75f1ded37cb634968ea72b3333f1d141df74b56b6bf13dea20ee208bea10d48b"}, - {file = "gseapy-1.3.0-cp310-cp310-win32.whl", hash = "sha256:aa823a4dec677f20660e5b89fed19e8b339bad52f6cc08a7ace0e546b97a8805"}, - {file = "gseapy-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:ed9bdcc282ea1d49d045f498f70110603b4d341b227c2e4ddacb10228b55d2f4"}, - {file = "gseapy-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:01aad664f7abe37d6a2d735e36f13cea77f0738f79adb60ab7b49a1f191c0ec3"}, - {file = "gseapy-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4bb82b7b8101a7560a1846287f141b6cb0a397fe0e679ab2d6fbb32ad19ed0d"}, - {file = "gseapy-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1175da16fe4cc4b04f06c26f6553727ddf4b1d1bace473790dfa14768080e125"}, - {file = "gseapy-1.3.0-cp311-cp311-win32.whl", hash = "sha256:2329f7c1647b1012a5ac2692322a976da568024dfb6b983b1bb45685e1382522"}, - {file = "gseapy-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:5a9b6763e2510f0d8b7c9fe743697dbb3230b05e1124658f1cfd4611d9d214eb"}, - {file = "gseapy-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ae3dcbd7ad3d16250b92be7d28e8a346ed3fb051b438387b4cdd1c48400432e1"}, - {file = "gseapy-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6f34bd01509bd629c0012dcb638e08e053cc06e4c84490d74ace4312554e1f3"}, - {file = "gseapy-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:218057c8c11ceca3a260fde536416495364528785a3a0e599930853cfe47c61f"}, - {file = "gseapy-1.3.0-cp312-cp312-win32.whl", hash = "sha256:43af0dfb4b7fc4eea7c9e291371b7e2c773d7b3c50c18b6ee5dd582e9dac2de9"}, - {file = "gseapy-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:87cbdda670d515b5ff2d945f6dcdfcd82bc12272e416b2a47f81ab5959503f27"}, - {file = "gseapy-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b180fd519522851a73540c2765fac3943ff659717bfad5fc2d14663d0c20783b"}, - {file = "gseapy-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a0c0f9daff5ffbe10f7dd8b6a147c9ca47ba6d66fee1eb75f776369d241fa94"}, - {file = "gseapy-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7500991530d90558f4c60c293a905f3804b398e6b65909190026e12fbfe30f8e"}, - {file = "gseapy-1.3.0-cp313-cp313-win32.whl", hash = "sha256:59bb951ed0634e36674432cb554e893cbe90be742d5bdbf548d505eee8c34b16"}, - {file = "gseapy-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:c6cb42eb900228f4f079aa8d8a05590b5dea76025664916cdb6d25bec55dfea9"}, - {file = "gseapy-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:270422906e8f19443ae788f1c6bd47279f9d5342bd18a292104cf5f239a65d06"}, - {file = "gseapy-1.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3d6e18e8f2c2f1d81d1e88640c03d6fb25ab3719f61ba275f10d223c5af2abae"}, - {file = "gseapy-1.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6785033c4b33748f3b5db4c88bb893dc794612ec09f41d96baa002ed21d8db1"}, - {file = "gseapy-1.3.0-cp314-cp314-win32.whl", hash = "sha256:6333a7403dbca988f58c4eaef908e717f2d8f6677b5b5009d073d405bd17c751"}, - {file = "gseapy-1.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:f6fce30e6f3c4e5449913dad80db9bbb354e87d4f3fac43a75b51210a2de70e5"}, - {file = "gseapy-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f6d0b1e99c5c367e4370d1b2fa4fe42ea7b0581be7a9551d9c513ed7a1fa0274"}, - {file = "gseapy-1.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2c2c5a8b559e7f5039a53c334cbd8d0e43a93ef7b24957c55a88f25e8e99aa20"}, - {file = "gseapy-1.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e34676c7de463d11457f99a81b2b0dafb16f97001ac60317cb32b6c2b0d630c"}, - {file = "gseapy-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:17ae514935967ff18e3270106f1489777fe7e8b2bd09697e0213b5addb60a9bf"}, - {file = "gseapy-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4dee0f8c1e232e4dadc375655fa50e2a3ba0f9e79c00dea1be448febf230536e"}, - {file = "gseapy-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fedba9d73678bc75d32c93087267432592f650d8ad983e23af2bfdbfb3d4b949"}, - {file = "gseapy-1.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88fea363a2cfd434824ed0af89de0313bf9d790e7232d8929924e5695737c2c4"}, - {file = "gseapy-1.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa8cbf1641a02e90f10cc440f83edceec0007dde665c4288b3f64a3a70952347"}, - {file = "gseapy-1.3.0-cp39-cp39-win32.whl", hash = "sha256:f86f41d2687512ae2c8f5a636b6e8874b68b4b476d2a300630d3275a377554a2"}, - {file = "gseapy-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:a358f7510111ceb32041dbaabeffe0423466ce49dae333a337e6d7f036a5df61"}, - {file = "gseapy-1.3.0.tar.gz", hash = "sha256:bc5daa80376751c42c0beb5cfbb42131ad2de3c9426f97a415df37d713ceaae3"}, -] - -[package.dependencies] -matplotlib = ">=2.2" -numpy = ">=1.13.0" -pandas = "*" -requests = "*" -scipy = "*" - -[package.extras] -dev = ["lxml", "pytest", "pytest-cov", "ruff", "twine (>=6.1.0)"] -docs = ["numpydoc", "sphinx", "sphinx-rtd-theme"] -test = ["lxml", "pytest", "pytest-cov", "pyyaml", "ruff"] - -[[package]] -name = "h11" -version = "0.16.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.8" -groups = ["main", "development"] -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.16" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] - -[[package]] -name = "httpx" -version = "0.28.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" - -[package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "humanize" -version = "4.15.0" -description = "Python humanize utilities" -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769"}, - {file = "humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10"}, -] - -[package.extras] -tests = ["freezegun", "pytest", "pytest-cov"] - -[[package]] -name = "identify" -version = "2.6.19" -description = "File identification library for Python" -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a"}, - {file = "identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.18" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.9" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, - {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, -] - -[package.extras] -all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "imagesize" -version = "2.0.0" -description = "Get image size from headers (BMP/PNG/JPEG/JPEG2000/GIF/TIFF/SVG/Netpbm/WebP/AVIF/HEIC/HEIF)" -optional = false -python-versions = "<3.15,>=3.10" -groups = ["development"] -files = [ - {file = "imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96"}, - {file = "imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3"}, -] - -[[package]] -name = "importlib-metadata" -version = "9.0.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version == \"3.11\"" -files = [ - {file = "importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7"}, - {file = "importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -perf = ["ipython"] -test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] - -[[package]] -name = "importlib-resources" -version = "7.1.0" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1"}, - {file = "importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] -type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] - -[[package]] -name = "iniconfig" -version = "2.3.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.10" -groups = ["main", "development"] -files = [ - {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, - {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, -] - -[[package]] -name = "installer" -version = "1.0.1" -description = "A library for installing Python wheels." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "installer-1.0.1-py3-none-any.whl", hash = "sha256:011d045df8b954ced7dde3a7e42ae4418da40ecda7990f2d11d5ed7c146fd98b"}, - {file = "installer-1.0.1.tar.gz", hash = "sha256:052c7fc3721d54c696e2dea019be67539d7b144e924f559f54beb3121831c364"}, -] - -[[package]] -name = "isort" -version = "8.0.1" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.10.0" -groups = ["development"] -files = [ - {file = "isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75"}, - {file = "isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d"}, -] - -[package.extras] -colors = ["colorama"] - -[[package]] -name = "jaraco-classes" -version = "3.4.0" -description = "Utility functions for Python class constructs" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, - {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, -] - -[package.dependencies] -more-itertools = "*" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] - -[[package]] -name = "jaraco-context" -version = "6.1.2" -description = "Useful decorators and context managers" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535"}, - {file = "jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"}, -] - -[package.dependencies] -"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"] -type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] - -[[package]] -name = "jaraco-functools" -version = "4.5.0" -description = "Functools like those found in stdlib" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4"}, - {file = "jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03"}, -] - -[package.dependencies] -more_itertools = "*" - -[package.extras] -check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] -type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] - -[[package]] -name = "jeepney" -version = "0.9.0" -description = "Low-level, pure Python DBus protocol wrapper." -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "sys_platform == \"linux\"" -files = [ - {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, - {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, -] - -[package.extras] -test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] -trio = ["trio"] - -[[package]] -name = "jinja2" -version = "3.1.6" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, - {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "joblib" -version = "1.5.3" -description = "Lightweight pipelining with Python functions" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713"}, - {file = "joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3"}, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"multiprocessing\"" -files = [ - {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, - {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" -referencing = ">=0.28.4" -rpds-py = ">=0.25.0" - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "extra == \"multiprocessing\"" -files = [ - {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, - {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, -] - -[package.dependencies] -referencing = ">=0.31.0" - -[[package]] -name = "keyring" -version = "25.7.0" -description = "Store and access your passwords safely." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"}, - {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"}, -] - -[package.dependencies] -importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} -"jaraco.classes" = "*" -"jaraco.context" = "*" -"jaraco.functools" = "*" -jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} -pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} -SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -completion = ["shtab (>=1.1.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] -type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"] - -[[package]] -name = "kiwisolver" -version = "1.5.0" -description = "A fast implementation of the Cassowary constraint solver" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374"}, - {file = "kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd"}, - {file = "kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476"}, - {file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22"}, - {file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b"}, - {file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e"}, - {file = "kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb"}, - {file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537"}, - {file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4"}, - {file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c"}, - {file = "kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede"}, - {file = "kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2"}, - {file = "kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875"}, - {file = "kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c"}, - {file = "kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb"}, - {file = "kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac"}, - {file = "kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27"}, - {file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398"}, - {file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db"}, - {file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc"}, - {file = "kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679"}, - {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309"}, - {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2"}, - {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c"}, - {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08"}, - {file = "kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4"}, - {file = "kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b"}, - {file = "kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac"}, - {file = "kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9"}, - {file = "kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588"}, - {file = "kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819"}, - {file = "kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f"}, - {file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf"}, - {file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d"}, - {file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083"}, - {file = "kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6"}, - {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1"}, - {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0"}, - {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15"}, - {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314"}, - {file = "kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9"}, - {file = "kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384"}, - {file = "kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7"}, - {file = "kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09"}, - {file = "kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3"}, - {file = "kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd"}, - {file = "kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3"}, - {file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96"}, - {file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099"}, - {file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8"}, - {file = "kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87"}, - {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23"}, - {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859"}, - {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902"}, - {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167"}, - {file = "kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0"}, - {file = "kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276"}, - {file = "kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2"}, - {file = "kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53"}, - {file = "kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615"}, - {file = "kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02"}, - {file = "kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e"}, - {file = "kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac"}, - {file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05"}, - {file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd"}, - {file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a"}, - {file = "kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554"}, - {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581"}, - {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303"}, - {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9"}, - {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79"}, - {file = "kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796"}, - {file = "kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e"}, - {file = "kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681"}, - {file = "kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57"}, - {file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797"}, - {file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203"}, - {file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7"}, - {file = "kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57"}, - {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4"}, - {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca"}, - {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f"}, - {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed"}, - {file = "kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc"}, - {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232"}, - {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a"}, - {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737"}, - {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16"}, - {file = "kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1"}, - {file = "kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a"}, -] - -[[package]] -name = "librt" -version = "0.11.0" -description = "Mypyc runtime library" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "platform_python_implementation != \"PyPy\"" -files = [ - {file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"}, - {file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"}, - {file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"}, - {file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"}, - {file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"}, - {file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"}, - {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"}, - {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"}, - {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"}, - {file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"}, - {file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"}, - {file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"}, - {file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"}, - {file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"}, - {file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"}, - {file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"}, - {file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"}, - {file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"}, - {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"}, - {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"}, - {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"}, - {file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"}, - {file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"}, - {file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"}, - {file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"}, - {file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"}, - {file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"}, - {file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"}, - {file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"}, - {file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"}, - {file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"}, - {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"}, - {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"}, - {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"}, - {file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"}, - {file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"}, - {file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"}, - {file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"}, - {file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"}, - {file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"}, - {file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"}, - {file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"}, - {file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"}, - {file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"}, - {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"}, - {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"}, - {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"}, - {file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"}, - {file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"}, - {file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"}, - {file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"}, - {file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"}, - {file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"}, - {file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"}, - {file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"}, - {file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"}, - {file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"}, - {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"}, - {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"}, - {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"}, - {file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"}, - {file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"}, - {file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"}, - {file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"}, - {file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"}, - {file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"}, - {file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"}, - {file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"}, - {file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"}, - {file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"}, - {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"}, - {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"}, - {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"}, - {file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"}, - {file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"}, - {file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"}, - {file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"}, - {file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"}, - {file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"}, - {file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"}, - {file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"}, - {file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"}, - {file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"}, - {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"}, - {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"}, - {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"}, - {file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"}, - {file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"}, - {file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"}, - {file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"}, -] - -[[package]] -name = "lightning-utilities" -version = "0.15.3" -description = "Lightning toolbox for across the our ecosystem." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91"}, - {file = "lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033"}, -] - -[package.dependencies] -packaging = ">=22" -typing_extensions = "*" - -[package.extras] -cli = ["jsonargparse[signatures] (>=4.38.0)", "tomlkit"] -docs = ["requests (>=2.0.0)"] -typing = ["mypy (>=1.0.0)", "types-setuptools"] - -[[package]] -name = "markdown-it-py" -version = "4.2.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.10" -groups = ["main", "development"] -files = [ - {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, - {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins (>=0.5.0)"] -profiling = ["gprof2dot"] -rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] - -[[package]] -name = "markupsafe" -version = "3.0.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, - {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, - {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, - {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, - {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, - {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, - {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, - {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, - {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, - {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, - {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, - {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, - {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, - {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, - {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, - {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, - {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, - {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, - {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, - {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, - {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, - {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, - {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, - {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, - {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, - {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, - {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, - {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, - {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, - {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, - {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, - {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, - {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, - {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, - {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, - {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, - {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, - {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, - {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, - {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, -] - -[[package]] -name = "matplotlib" -version = "3.11.0" -description = "Python plotting package" -optional = false -python-versions = ">=3.11" -groups = ["main"] -files = [ - {file = "matplotlib-3.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f857524b442f0f36e641868ce2171aafa88cb0bc0644f4e1d8a5df9b32649fef"}, - {file = "matplotlib-3.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:57baa92fdc82948ed716eae6d2579d4d6f40965cd8d2f416755b4a72580a3233"}, - {file = "matplotlib-3.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630eee0e67d35cce2019a0e670719f4816e3b86aff0fa72729f6c69786fceb45"}, - {file = "matplotlib-3.11.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5106c444d0bf966eee2853548c03772af4ab7199118e086c62fbac8ccb07c055"}, - {file = "matplotlib-3.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d7aea652b58e686444079be3376ef546bffa1eee9b9bb9c472b9fcf6cf410d3"}, - {file = "matplotlib-3.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:70a5b3e9a5dab708c0f039709ae7c68d5b4d254e291ef76492cdba230c8bb5e4"}, - {file = "matplotlib-3.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:3d68266213e73823ac3be90615bab0cf31f88851e114cdb1dd25dacf3b01e1a7"}, - {file = "matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918"}, - {file = "matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97"}, - {file = "matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344"}, - {file = "matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1"}, - {file = "matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e"}, - {file = "matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e"}, - {file = "matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb"}, - {file = "matplotlib-3.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ce3b839b34ae1f430b4616893a2945a2999debaa7e94e7e29a2a8bbf286f7b5"}, - {file = "matplotlib-3.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373db8f91214e8ccaf35ac833cc1dd59dd961e148bbd55dd027141591dde1313"}, - {file = "matplotlib-3.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be152b7570324dc8d01574cc9474dd2d803237acf528bcbb5b211fa347461a09"}, - {file = "matplotlib-3.11.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:126f256df600652d7e4b394cf3164ff75210a00038f287c95a012a6f58d0e83f"}, - {file = "matplotlib-3.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:03acfeddf87b0dddb11b081ef7740ad445a3ca8bcb6b8e3011b08f2cf802b75c"}, - {file = "matplotlib-3.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:ab3722f04f3ff34c23b5012c5873d2894174e06c3822fcdac3610965a5ac7d06"}, - {file = "matplotlib-3.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:c945824670fb8915b4ac879e5e61f3c58e0913022f70a0de4c082b17372f8771"}, - {file = "matplotlib-3.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3489c3dc487669b4a980bc3068f87856de7a1564248d3f6c629efb2a58b03f24"}, - {file = "matplotlib-3.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6a98f5476ce784a50ce09998f4ae1e6a9f25043cef8a480c98949902eda74620"}, - {file = "matplotlib-3.11.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:565af866fd63e4bd3f987d580afe27c44c2552a3b3305f4ecbb85133601ea6f3"}, - {file = "matplotlib-3.11.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b3e64dea5062c570f04358e2711859f3531b459f29516274fbad889079e4f3"}, - {file = "matplotlib-3.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:942b37c5db1899610bd1543ce8e13e4ecff9a4633e7f63bb6aa9205d2644ebd1"}, - {file = "matplotlib-3.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c08e649a6313e1291e713623b97a38e5bb4aa580b2a100a94a3309bc6b9c8eb3"}, - {file = "matplotlib-3.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2746cd2c113742ff6ce37a864c5ac5fd7aa644568f445e66166e457ac78e40e0"}, - {file = "matplotlib-3.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3338e3e3de128cf50d0d2fb92a122815daf9c755bd882a474343c05f8fd7ec79"}, - {file = "matplotlib-3.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:25c2e5455efd8d99f41fb79871a31feb7d301569642e332ec58d72cfe9282bc3"}, - {file = "matplotlib-3.11.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9695457a467ff86d23f35037a43deb6f1134dd6d3e2ac8ce1e2087cff09ffb9"}, - {file = "matplotlib-3.11.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c16c61dea63b3582918503e6b294193961261d9daa806d4ae2151f1ad05430"}, - {file = "matplotlib-3.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2d72ea8b7924f3cb955e61518d21e43b3df1e6c8a793b480a0c1214f185d30ba"}, - {file = "matplotlib-3.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:1c02da0a629dfa9debf52725ea06866b74c1fb70a895bae05e4493d34074f9f2"}, - {file = "matplotlib-3.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aa55d73b3117d4b07f959cd9eb6f69b375d8df3414139c479388e551aa5d999d"}, - {file = "matplotlib-3.11.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a9d8c6e7cd2f0ddf11d8d92e520dd1d9d2abb0cf6ac8831e338666c81e905847"}, - {file = "matplotlib-3.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:be050fcf32f729eda99f7f75a80bf67612ce16ab9ac1c23a387dcaede95cb70e"}, - {file = "matplotlib-3.11.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfabef0230d0697aa0d717385194dd41162e00207a68bf4abf94c2bf4c27dca0"}, - {file = "matplotlib-3.11.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1644db30e759199443493ac5e5caec24fdb775a8f6123021f85ba47c4133c3cb"}, - {file = "matplotlib-3.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15b0d160079cb10699a0e98b5989c70677b2df7cacdc62af67c30f2facec46d9"}, - {file = "matplotlib-3.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:446307e6b04b57b1f1239e228a1ec2af0d589a1008cebc3dfa3f5441d095cfb6"}, - {file = "matplotlib-3.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:652fb5696271d4c50f196d22a5ff4f8e4444c74f847423570d7dc0aa2bbd0159"}, - {file = "matplotlib-3.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81ae77077a1e16d37a5b61096ccb07c8d90a99b518fa8256b8f21578932f2f62"}, - {file = "matplotlib-3.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ddef37840695f5eef65f9f070fe2d2f510f584c2156203f9f622a5b0584efffd"}, - {file = "matplotlib-3.11.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf662e5ac5707658cb931e19972c4bd99f7b4f8b7bf79d3c821d239fa6b71e64"}, - {file = "matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57"}, -] - -[package.dependencies] -contourpy = ">=1.0.1" -cycler = ">=0.10" -fonttools = ">=4.22.0" -kiwisolver = ">=1.3.1" -numpy = ">=1.25" -packaging = ">=20.0" -pillow = ">=9" -pyparsing = ">=3" -python-dateutil = ">=2.7" - -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -groups = ["development"] -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -groups = ["main", "development"] -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "mock" -version = "5.2.0" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, - {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, -] - -[package.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "more-itertools" -version = "11.1.0" -description = "More routines for operating on iterables, beyond itertools" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192"}, - {file = "more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d"}, -] - -[[package]] -name = "mpmath" -version = "1.3.0" -description = "Python library for arbitrary-precision floating-point arithmetic" -optional = false -python-versions = "*" -groups = ["main", "torch-cuda"] -files = [ - {file = "mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"}, - {file = "mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f"}, -] - -[package.extras] -develop = ["codecov", "pycodestyle", "pytest (>=4.6)", "pytest-cov", "wheel"] -docs = ["sphinx"] -gmpy = ["gmpy2 (>=2.1.0a4) ; platform_python_implementation != \"PyPy\""] -tests = ["pytest (>=4.6)"] - -[[package]] -name = "msgpack" -version = "1.2.1" -description = "MessagePack serializer" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c"}, - {file = "msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895"}, - {file = "msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203"}, - {file = "msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73"}, - {file = "msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833"}, - {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8"}, - {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7"}, - {file = "msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce"}, - {file = "msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74"}, - {file = "msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb"}, - {file = "msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22"}, - {file = "msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5"}, - {file = "msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06"}, - {file = "msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4"}, - {file = "msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8"}, - {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b"}, - {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e"}, - {file = "msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f"}, - {file = "msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d"}, - {file = "msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8"}, - {file = "msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66"}, - {file = "msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35"}, - {file = "msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c"}, - {file = "msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0"}, - {file = "msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a"}, - {file = "msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6"}, - {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a"}, - {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1"}, - {file = "msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64"}, - {file = "msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac"}, - {file = "msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24"}, - {file = "msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07"}, - {file = "msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064"}, - {file = "msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056"}, - {file = "msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc"}, - {file = "msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d"}, - {file = "msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155"}, - {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402"}, - {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c"}, - {file = "msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6"}, - {file = "msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707"}, - {file = "msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9"}, - {file = "msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a"}, - {file = "msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d"}, - {file = "msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7"}, - {file = "msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889"}, - {file = "msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720"}, - {file = "msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190"}, - {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d"}, - {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24"}, - {file = "msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7"}, - {file = "msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb"}, - {file = "msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b"}, - {file = "msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7"}, - {file = "msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273"}, - {file = "msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1"}, - {file = "msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc"}, - {file = "msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde"}, - {file = "msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4"}, - {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d"}, - {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355"}, - {file = "msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c"}, - {file = "msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1"}, - {file = "msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2"}, - {file = "msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107"}, - {file = "msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647"}, -] - -[[package]] -name = "multidict" -version = "6.7.1" -description = "multidict implementation" -optional = false -python-versions = ">=3.9" -groups = ["main", "torch-cuda"] -files = [ - {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, - {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, - {file = "multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190"}, - {file = "multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962"}, - {file = "multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505"}, - {file = "multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122"}, - {file = "multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df"}, - {file = "multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db"}, - {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d"}, - {file = "multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e"}, - {file = "multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0"}, - {file = "multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0"}, - {file = "multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa"}, - {file = "multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a"}, - {file = "multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b"}, - {file = "multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6"}, - {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172"}, - {file = "multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd"}, - {file = "multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a"}, - {file = "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a"}, - {file = "multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba"}, - {file = "multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511"}, - {file = "multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19"}, - {file = "multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf"}, - {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23"}, - {file = "multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2"}, - {file = "multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed"}, - {file = "multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d"}, - {file = "multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33"}, - {file = "multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3"}, - {file = "multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5"}, - {file = "multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df"}, - {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1"}, - {file = "multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963"}, - {file = "multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd"}, - {file = "multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52"}, - {file = "multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108"}, - {file = "multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32"}, - {file = "multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8"}, - {file = "multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118"}, - {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee"}, - {file = "multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2"}, - {file = "multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37"}, - {file = "multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1"}, - {file = "multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b"}, - {file = "multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d"}, - {file = "multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f"}, - {file = "multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5"}, - {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581"}, - {file = "multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a"}, - {file = "multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d"}, - {file = "multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9"}, - {file = "multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2"}, - {file = "multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7"}, - {file = "multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5"}, - {file = "multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2"}, - {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:65573858d27cdeaca41893185677dc82395159aa28875a8867af66532d413a8f"}, - {file = "multidict-6.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c524c6fb8fc342793708ab111c4dbc90ff9abd568de220432500e47e990c0358"}, - {file = "multidict-6.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa23b001d968faef416ff70dc0f1ab045517b9b42a90edd3e9bcdb06479e31d5"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6704fa2b7453b2fb121740555fa1ee20cd98c4d011120caf4d2b8d4e7c76eec0"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:121a34e5bfa410cdf2c8c49716de160de3b1dbcd86b49656f5681e4543bcd1a8"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:026d264228bcd637d4e060844e39cdc60f86c479e463d49075dedc21b18fbbe0"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e697826df7eb63418ee190fd06ce9f1803593bb4b9517d08c60d9b9a7f69d8f"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb08271280173720e9fea9ede98e5231defcbad90f1624bea26f32ec8a956e2f"}, - {file = "multidict-6.7.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6b3228e1d80af737b72925ce5fb4daf5a335e49cd7ab77ed7b9fdfbf58c526e"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3943debf0fbb57bdde5901695c11094a9a36723e5c03875f87718ee15ca2f4d2"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:98c5787b0a0d9a41d9311eae44c3b76e6753def8d8870ab501320efe75a6a5f8"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:08ccb2a6dc72009093ebe7f3f073e5ec5964cba9a706fa94b1a1484039b87941"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb351f72c26dc9abe338ca7294661aa22969ad8ffe7ef7d5541d19f368dc854a"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ac1c665bad8b5d762f5f85ebe4d94130c26965f11de70c708c75671297c776de"}, - {file = "multidict-6.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fa6609d0364f4f6f58351b4659a1f3e0e898ba2a8c5cac04cb2c7bc556b0bc5"}, - {file = "multidict-6.7.1-cp39-cp39-win32.whl", hash = "sha256:6f77ce314a29263e67adadc7e7c1bc699fcb3a305059ab973d038f87caa42ed0"}, - {file = "multidict-6.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:f537b55778cd3cbee430abe3131255d3a78202e0f9ea7ffc6ada893a4bcaeea4"}, - {file = "multidict-6.7.1-cp39-cp39-win_arm64.whl", hash = "sha256:749aa54f578f2e5f439538706a475aa844bfa8ef75854b1401e6e528e4937cf9"}, - {file = "multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56"}, - {file = "multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d"}, -] - -[[package]] -name = "mygene" -version = "3.2.2" -description = "Python Client for MyGene.Info services." -optional = true -python-versions = "*" -groups = ["main"] -markers = "extra == \"sparsego\"" -files = [ - {file = "mygene-3.2.2-py2.py3-none-any.whl", hash = "sha256:18d85d1b28ecee2be31d844607fb0c5f7d7c58573278432df819ee2a5e88fe46"}, - {file = "mygene-3.2.2.tar.gz", hash = "sha256:e729cabbc28cf5afb221bca1ab637883b375cb1a3e2f067587ec79f71affdaea"}, -] - -[package.dependencies] -biothings-client = ">=0.2.6" - -[[package]] -name = "mypy" -version = "1.20.2" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4"}, - {file = "mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997"}, - {file = "mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14"}, - {file = "mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99"}, - {file = "mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c"}, - {file = "mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd"}, - {file = "mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2"}, - {file = "mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c"}, - {file = "mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3"}, - {file = "mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254"}, - {file = "mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98"}, - {file = "mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac"}, - {file = "mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67"}, - {file = "mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100"}, - {file = "mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b"}, - {file = "mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4"}, - {file = "mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6"}, - {file = "mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066"}, - {file = "mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102"}, - {file = "mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9"}, - {file = "mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58"}, - {file = "mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026"}, - {file = "mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943"}, - {file = "mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517"}, - {file = "mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15"}, - {file = "mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee"}, - {file = "mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f"}, - {file = "mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330"}, - {file = "mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30"}, - {file = "mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924"}, - {file = "mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb"}, - {file = "mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc"}, - {file = "mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558"}, - {file = "mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8"}, - {file = "mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3"}, - {file = "mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609"}, - {file = "mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2"}, - {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c"}, - {file = "mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744"}, - {file = "mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6"}, - {file = "mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec"}, - {file = "mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382"}, - {file = "mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563"}, - {file = "mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665"}, -] - -[package.dependencies] -librt = {version = ">=0.8.0", markers = "platform_python_implementation != \"PyPy\""} -mypy_extensions = ">=1.0.0" -pathspec = ">=1.0.0" -typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""} - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -native-parser = ["ast-serialize (>=0.1.1,<1.0.0)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.8" -groups = ["dev", "development"] -files = [ - {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, - {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, -] - -[[package]] -name = "narwhals" -version = "2.22.1" -description = "Extremely lightweight compatibility layer between dataframe libraries" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53"}, - {file = "narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9"}, -] - -[package.extras] -cudf = ["cudf-cu12 (>=24.10.0) ; sys_platform == \"linux\""] -dask = ["dask[dataframe] (>=2024.8)"] -duckdb = ["duckdb (>=1.1)"] -ibis = ["ibis-framework (>=6.0.0)", "packaging (>=21.3)", "pyarrow-hotfix (>=0.7)", "rich (>=12.4.4)"] -modin = ["modin (>=0.22.0)"] -pandas = ["pandas (>=1.3.4)"] -polars = ["polars (>=0.20.4)"] -pyarrow = ["pyarrow (>=13.0.0)"] -pyspark = ["pyspark (>=3.5.0)"] -pyspark-connect = ["pyspark[connect] (>=3.5.0)"] -sql = ["narwhals[duckdb]", "sqlparse (>=0.5.5)"] -sqlframe = ["sqlframe (>=3.22.0,!=3.39.3)"] - -[[package]] -name = "networkx" -version = "3.6.1" -description = "Python package for creating and manipulating graphs and networks" -optional = false -python-versions = "!=3.14.1,>=3.11" -groups = ["main", "torch-cuda"] -files = [ - {file = "networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762"}, - {file = "networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509"}, -] - -[package.extras] -benchmarking = ["asv", "virtualenv"] -default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"] -developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"] -doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"] -example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "iplotx (>=0.9.0)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] -extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] -release = ["build (>=0.10)", "changelist (==0.5)", "twine (>=4.0)", "wheel (>=0.40)"] -test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"] -test-extras = ["pytest-mpl", "pytest-randomly"] - -[[package]] -name = "nodeenv" -version = "1.10.0" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["development"] -files = [ - {file = "nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827"}, - {file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"}, -] - -[[package]] -name = "nox" -version = "2026.4.10" -description = "Flexible test automation." -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "nox-2026.4.10-py3-none-any.whl", hash = "sha256:082c117627590d9b90aa21f86df89b310b07c5842539524203bcb3c719f116c1"}, - {file = "nox-2026.4.10.tar.gz", hash = "sha256:2d0af5374f3f37a295428c927d1b04a8182aa01762897d172446dda2f1ce9692"}, -] - -[package.dependencies] -argcomplete = ">=1.9.4,<4" -attrs = ">=24.1" -colorlog = ">=2.6.1,<7" -dependency-groups = ">=1.1" -humanize = ">=4" -packaging = ">=22" -virtualenv = {version = ">=20.15", markers = "python_version >= \"3.10\""} - -[package.extras] -pbs = ["pbs-installer[all] (>=2025.1.6)"] -tox-to-nox = ["jinja2", "tox (>=4)"] -uv = ["uv (>=0.1.6)"] - -[[package]] -name = "nox-poetry" -version = "1.2.0" -description = "nox-poetry" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "nox_poetry-1.2.0-py3-none-any.whl", hash = "sha256:266eea7a0ab3cad7f4121ecc05b76945036db3b67e6e347557f05010a18e2682"}, - {file = "nox_poetry-1.2.0.tar.gz", hash = "sha256:2531a404e3a21eb73fc1a587a548506a8e2c4c1e6e7ef0c1d0d8d6453b7e5d26"}, -] - -[package.dependencies] -build = ">=1.2" -nox = ">=2020.8.22" -packaging = ">=20.9" -tomlkit = ">=0.7" - -[[package]] -name = "numpy" -version = "2.4.6" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.11" -groups = ["main", "torch-cuda"] -markers = "python_version == \"3.11\"" -files = [ - {file = "numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4"}, - {file = "numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d"}, - {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8"}, - {file = "numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538"}, - {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47"}, - {file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93"}, - {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8"}, - {file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6"}, - {file = "numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8"}, - {file = "numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147"}, - {file = "numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577"}, - {file = "numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1"}, - {file = "numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb"}, - {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41"}, - {file = "numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698"}, - {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f"}, - {file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853"}, - {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a"}, - {file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2"}, - {file = "numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45"}, - {file = "numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751"}, - {file = "numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8"}, - {file = "numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0"}, - {file = "numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb"}, - {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f"}, - {file = "numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3"}, - {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b"}, - {file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089"}, - {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a"}, - {file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605"}, - {file = "numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91"}, - {file = "numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359"}, - {file = "numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778"}, - {file = "numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1"}, - {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe"}, - {file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997"}, - {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"}, - {file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d"}, - {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67"}, - {file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd"}, - {file = "numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab"}, - {file = "numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75"}, - {file = "numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd"}, - {file = "numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079"}, - {file = "numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7"}, - {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5"}, - {file = "numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096"}, - {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b"}, - {file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8"}, - {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402"}, - {file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb"}, - {file = "numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1"}, - {file = "numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261"}, - {file = "numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6"}, - {file = "numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a"}, - {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e"}, - {file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e"}, - {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43"}, - {file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e"}, - {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895"}, - {file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4"}, - {file = "numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063"}, - {file = "numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627"}, - {file = "numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66"}, - {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662"}, - {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7"}, - {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f"}, - {file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c"}, - {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0"}, - {file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02"}, - {file = "numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73"}, - {file = "numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda"}, -] - -[[package]] -name = "numpy" -version = "2.5.0" -description = "Fundamental package for array computing in Python" -optional = false -python-versions = ">=3.12" -groups = ["main", "torch-cuda"] -markers = "python_version >= \"3.12\"" -files = [ - {file = "numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561"}, - {file = "numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6"}, - {file = "numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be"}, - {file = "numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2"}, - {file = "numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8"}, - {file = "numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a"}, - {file = "numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0"}, - {file = "numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54"}, - {file = "numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5"}, - {file = "numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2"}, - {file = "numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca"}, - {file = "numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2"}, - {file = "numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c"}, - {file = "numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd"}, - {file = "numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd"}, - {file = "numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e"}, - {file = "numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9"}, - {file = "numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab"}, - {file = "numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4"}, - {file = "numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988"}, - {file = "numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748"}, - {file = "numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60"}, - {file = "numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9"}, - {file = "numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446"}, - {file = "numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c"}, - {file = "numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303"}, - {file = "numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22"}, - {file = "numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03"}, - {file = "numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a"}, - {file = "numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21"}, - {file = "numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731"}, - {file = "numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73"}, - {file = "numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e"}, - {file = "numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b"}, - {file = "numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927"}, - {file = "numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826"}, - {file = "numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd"}, - {file = "numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6"}, - {file = "numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a"}, - {file = "numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9"}, - {file = "numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7"}, - {file = "numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa"}, - {file = "numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865"}, - {file = "numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c"}, -] - -[[package]] -name = "nvidia-cublas" -version = "13.1.1.3" -description = "CUBLAS native runtime libraries" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5"}, - {file = "nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436"}, - {file = "nvidia_cublas-13.1.1.3-py3-none-win_amd64.whl", hash = "sha256:b6cdce694e47ff6aadf0a69df1cab6628d696f5ff56e8d16af50309d855fa20f"}, -] - -[package.dependencies] -nvidia-cuda-nvrtc = "*" - -[[package]] -name = "nvidia-cuda-cupti" -version = "13.0.85" -description = "CUDA profiling tools runtime libs." -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\" and (sys_platform == \"linux\" or sys_platform == \"win32\")" -files = [ - {file = "nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151"}, - {file = "nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8"}, - {file = "nvidia_cuda_cupti-13.0.85-py3-none-win_amd64.whl", hash = "sha256:683f58d301548deeefcb8f6fac1b8d907691b9d8b18eccab417f51e362102f00"}, -] - -[[package]] -name = "nvidia-cuda-nvrtc" -version = "13.0.88" -description = "NVRTC native runtime libraries" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575"}, - {file = "nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b"}, - {file = "nvidia_cuda_nvrtc-13.0.88-py3-none-win_amd64.whl", hash = "sha256:6bcd4e7f8e205cbe644f5a98f2f799bef9556fefc89dd786e79a16312ce49872"}, -] - -[[package]] -name = "nvidia-cuda-runtime" -version = "13.0.96" -description = "CUDA Runtime native Libraries" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\" and (sys_platform == \"linux\" or sys_platform == \"win32\")" -files = [ - {file = "nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55"}, - {file = "nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548"}, - {file = "nvidia_cuda_runtime-13.0.96-py3-none-win_amd64.whl", hash = "sha256:f79298c8a098cec150a597c8eba58ecdab96e3bdc4b9bc4f9983635031740492"}, -] - -[[package]] -name = "nvidia-cudnn-cu13" -version = "9.20.0.48" -description = "cuDNN runtime libraries" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1"}, - {file = "nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304"}, - {file = "nvidia_cudnn_cu13-9.20.0.48-py3-none-win_amd64.whl", hash = "sha256:af8139732b99c0118be65ea5aac97f0d46018f8c552889e49d2fb0c6261a4a24"}, -] - -[package.dependencies] -nvidia-cublas = "*" - -[[package]] -name = "nvidia-cufft" -version = "12.0.0.61" -description = "CUFFT native runtime libraries" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\" and (sys_platform == \"linux\" or sys_platform == \"win32\")" -files = [ - {file = "nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5"}, - {file = "nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3"}, - {file = "nvidia_cufft-12.0.0.61-py3-none-win_amd64.whl", hash = "sha256:2abce5b39d2f5ae12730fb7e5db6696533e36c26e2d3e8fd1750bdd2853364eb"}, -] - -[package.dependencies] -nvidia-nvjitlink = "*" - -[[package]] -name = "nvidia-cufile" -version = "1.15.1.6" -description = "cuFile GPUDirect libraries" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\" and sys_platform == \"linux\"" -files = [ - {file = "nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44"}, - {file = "nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1"}, -] - -[[package]] -name = "nvidia-curand" -version = "10.4.0.35" -description = "CURAND native runtime libraries" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\" and (sys_platform == \"linux\" or sys_platform == \"win32\")" -files = [ - {file = "nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a"}, - {file = "nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc"}, - {file = "nvidia_curand-10.4.0.35-py3-none-win_amd64.whl", hash = "sha256:65b1710aa6961d326b411e314b374290904c5ddf41dc3f766ebc3f1d7d4ca69f"}, -] - -[[package]] -name = "nvidia-cusolver" -version = "12.0.4.66" -description = "CUDA solver native runtime libraries" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\" and (sys_platform == \"linux\" or sys_platform == \"win32\")" -files = [ - {file = "nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2"}, - {file = "nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112"}, - {file = "nvidia_cusolver-12.0.4.66-py3-none-win_amd64.whl", hash = "sha256:16515bd33a8e76bb54d024cfa068fa68d30e80fc34b9e1090813ea9362e0cb65"}, -] - -[package.dependencies] -nvidia-cublas = "*" -nvidia-cusparse = "*" -nvidia-nvjitlink = "*" - -[[package]] -name = "nvidia-cusparse" -version = "12.6.3.3" -description = "CUSPARSE native runtime libraries" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\" and (sys_platform == \"linux\" or sys_platform == \"win32\")" -files = [ - {file = "nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c"}, - {file = "nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b"}, - {file = "nvidia_cusparse-12.6.3.3-py3-none-win_amd64.whl", hash = "sha256:cbcf42feb737bd7ec15b4c0a63e62351886bd3f975027b8815d7f720a2b5ea79"}, -] - -[package.dependencies] -nvidia-nvjitlink = "*" - -[[package]] -name = "nvidia-cusparselt-cu13" -version = "0.8.1" -description = "NVIDIA cuSPARSELt" -optional = false -python-versions = "*" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f"}, - {file = "nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0"}, - {file = "nvidia_cusparselt_cu13-0.8.1-py3-none-win_amd64.whl", hash = "sha256:dccbd362f91a7b9024d1f55ee9f548ac065027ff15d8c8b0db889ab3a8f31215"}, -] - -[[package]] -name = "nvidia-nccl-cu12" -version = "2.30.7" -description = "NVIDIA Collective Communication Library (NCCL) Runtime" -optional = true -python-versions = ">=3" -groups = ["main"] -markers = "extra == \"xgboost\" and platform_system == \"Linux\"" -files = [ - {file = "nvidia_nccl_cu12-2.30.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:03ecd776fd1d58fd2c9a0a687dcf8db9ecd0057382dba646fa3d65786d4a9ea1"}, - {file = "nvidia_nccl_cu12-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:8ce1b8213f61f2bfac132e6df890af6450b77cbd140c6ce4e98cb0c2d8e678c9"}, -] - -[[package]] -name = "nvidia-nccl-cu13" -version = "2.29.7" -description = "NVIDIA Collective Communication Library (NCCL) Runtime" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5"}, - {file = "nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d"}, -] - -[[package]] -name = "nvidia-nvjitlink" -version = "13.0.88" -description = "Nvidia JIT LTO Library" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\" and (sys_platform == \"linux\" or sys_platform == \"win32\")" -files = [ - {file = "nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b"}, - {file = "nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c"}, - {file = "nvidia_nvjitlink-13.0.88-py3-none-win_amd64.whl", hash = "sha256:634e96e3da9ef845ae744097a1f289238ecf946ce0b82e93cdce14b9782e682f"}, -] - -[[package]] -name = "nvidia-nvshmem-cu13" -version = "3.4.5" -description = "NVSHMEM creates a global address space that provides efficient and scalable communication for NVIDIA GPU clusters." -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9"}, - {file = "nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80"}, -] - -[[package]] -name = "nvidia-nvtx" -version = "13.0.85" -description = "NVIDIA Tools Extension" -optional = false -python-versions = ">=3" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\" and (sys_platform == \"linux\" or sys_platform == \"win32\")" -files = [ - {file = "nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4"}, - {file = "nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6"}, - {file = "nvidia_nvtx-13.0.85-py3-none-win_amd64.whl", hash = "sha256:d66ea44254dd3c6eacc300047af6e1288d2269dd072b417e0adffbf479e18519"}, -] - -[[package]] -name = "obonet" -version = "1.3.0" -description = "Parse OBO formatted ontologies into networkx" -optional = true -python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"sparsego\"" -files = [ - {file = "obonet-1.3.0-py3-none-any.whl", hash = "sha256:77d981082aa95cf0d6c78568a8079f43c781c8dd5df30a9b2f7c665b87e4f0fc"}, - {file = "obonet-1.3.0.tar.gz", hash = "sha256:7c128bfe455bd7a134a3e6122e89338b896b4dea116511a850cec1a2e2a98294"}, -] - -[package.dependencies] -networkx = ">=2" - -[package.extras] -dev = ["mypy (==2.1.0)", "prek", "pytest"] - -[[package]] -name = "packaging" -version = "26.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -groups = ["main", "development"] -files = [ - {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, - {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, -] - -[[package]] -name = "pandas" -version = "2.3.3" -description = "Powerful data structures for data analysis, time series, and statistics" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c"}, - {file = "pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a"}, - {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1"}, - {file = "pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838"}, - {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250"}, - {file = "pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4"}, - {file = "pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826"}, - {file = "pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523"}, - {file = "pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45"}, - {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66"}, - {file = "pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b"}, - {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791"}, - {file = "pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151"}, - {file = "pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c"}, - {file = "pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53"}, - {file = "pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35"}, - {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908"}, - {file = "pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89"}, - {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98"}, - {file = "pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084"}, - {file = "pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b"}, - {file = "pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713"}, - {file = "pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8"}, - {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d"}, - {file = "pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac"}, - {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c"}, - {file = "pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493"}, - {file = "pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee"}, - {file = "pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5"}, - {file = "pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21"}, - {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78"}, - {file = "pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110"}, - {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86"}, - {file = "pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc"}, - {file = "pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0"}, - {file = "pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593"}, - {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c"}, - {file = "pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b"}, - {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6"}, - {file = "pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3"}, - {file = "pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5"}, - {file = "pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec"}, - {file = "pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7"}, - {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450"}, - {file = "pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5"}, - {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788"}, - {file = "pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87"}, - {file = "pandas-2.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c503ba5216814e295f40711470446bc3fd00f0faea8a086cbc688808e26f92a2"}, - {file = "pandas-2.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a637c5cdfa04b6d6e2ecedcb81fc52ffb0fd78ce2ebccc9ea964df9f658de8c8"}, - {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854d00d556406bffe66a4c0802f334c9ad5a96b4f1f868adf036a21b11ef13ff"}, - {file = "pandas-2.3.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf1f8a81d04ca90e32a0aceb819d34dbd378a98bf923b6398b9a3ec0bf44de29"}, - {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23ebd657a4d38268c7dfbdf089fbc31ea709d82e4923c5ffd4fbd5747133ce73"}, - {file = "pandas-2.3.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5554c929ccc317d41a5e3d1234f3be588248e61f08a74dd17c9eabb535777dc9"}, - {file = "pandas-2.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:d3e28b3e83862ccf4d85ff19cf8c20b2ae7e503881711ff2d534dc8f761131aa"}, - {file = "pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b"}, -] - -[package.dependencies] -numpy = [ - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, -] -python-dateutil = ">=2.8.2" -pytz = ">=2020.1" -tzdata = ">=2022.7" - -[package.extras] -all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] -aws = ["s3fs (>=2022.11.0)"] -clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] -compression = ["zstandard (>=0.19.0)"] -computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] -consortium-standard = ["dataframe-api-compat (>=0.1.7)"] -excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] -feather = ["pyarrow (>=10.0.1)"] -fss = ["fsspec (>=2022.11.0)"] -gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] -hdf5 = ["tables (>=3.8.0)"] -html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] -mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] -output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] -parquet = ["pyarrow (>=10.0.1)"] -performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] -plot = ["matplotlib (>=3.6.3)"] -postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] -pyarrow = ["pyarrow (>=10.0.1)"] -spss = ["pyreadstat (>=1.2.0)"] -sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] -test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] -xml = ["lxml (>=4.9.2)"] - -[[package]] -name = "pathspec" -version = "1.1.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.9" -groups = ["dev", "development"] -files = [ - {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, - {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, -] - -[package.extras] -hyperscan = ["hyperscan (>=0.7)"] -optional = ["typing-extensions (>=4)"] -re2 = ["google-re2 (>=1.1)"] - -[[package]] -name = "patsy" -version = "1.0.2" -description = "A Python package for describing statistical models and for building design matrices." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a"}, - {file = "patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0"}, -] - -[package.dependencies] -numpy = ">=1.4" - -[package.extras] -test = ["pytest", "pytest-cov", "scipy"] - -[[package]] -name = "pbs-installer" -version = "2026.6.10" -description = "Installer for Python Build Standalone" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pbs_installer-2026.6.10-py3-none-any.whl", hash = "sha256:c8d7faa06d529ec8db53975da810376fff44e850e5b7b440a29f3b88edd62bd6"}, - {file = "pbs_installer-2026.6.10.tar.gz", hash = "sha256:85f6665692aeb4297e1295761c9f4a6c7f047e0bb6947847ab3abf6de9650a10"}, -] - -[package.dependencies] -"backports.zstd" = {version = ">=1.0.0", optional = true, markers = "python_version < \"3.14\" and extra == \"install\""} -httpx = {version = ">=0.27.0,<1", optional = true, markers = "extra == \"download\""} - -[package.extras] -all = ["pbs-installer[download,install]"] -download = ["httpx (>=0.27.0,<1)"] -install = ["backports.zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "pep8-naming" -version = "0.15.1" -description = "Check PEP-8 naming conventions, plugin for flake8" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "pep8_naming-0.15.1-py3-none-any.whl", hash = "sha256:eb63925e7fd9e028c7f7ee7b1e413ec03d1ee5de0e627012102ee0222c273c86"}, - {file = "pep8_naming-0.15.1.tar.gz", hash = "sha256:f6f4a499aba2deeda93c1f26ccc02f3da32b035c8b2db9696b730ef2c9639d29"}, -] - -[package.dependencies] -flake8 = ">=5.0.0" - -[[package]] -name = "pillow" -version = "12.2.0" -description = "Python Imaging Library (fork)" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, - {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, - {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, - {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, - {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, - {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, - {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, - {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, - {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, - {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, - {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, - {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, - {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, - {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, - {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, - {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, - {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, - {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, - {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, - {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, - {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, - {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, - {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, - {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, - {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, - {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, - {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, - {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, - {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, - {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, - {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, - {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, - {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, - {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, - {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, - {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, - {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, - {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, - {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, - {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, - {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, - {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, - {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, - {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, - {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, - {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, - {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, - {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, - {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, - {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, - {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, - {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, - {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, - {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, - {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, - {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -xmp = ["defusedxml"] - -[[package]] -name = "pkginfo" -version = "1.12.1.2" -description = "Query metadata from sdists / bdists / installed packages." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"}, - {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"}, -] - -[package.extras] -testing = ["pytest", "pytest-cov", "wheel"] - -[[package]] -name = "platformdirs" -version = "4.10.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.10" -groups = ["main", "development"] -files = [ - {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, - {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, -] - -[[package]] -name = "plotly" -version = "6.8.0" -description = "An open-source interactive data visualization library for Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "plotly-6.8.0-py3-none-any.whl", hash = "sha256:13c5c4a0f70b74cab1913eda0de49b826df5931708eb6f9c3010040614700ec8"}, - {file = "plotly-6.8.0.tar.gz", hash = "sha256:e088e7ddc68d4f70e3d66659224727a45296d71d2b8284181862d3d8f1f0d88f"}, -] - -[package.dependencies] -narwhals = ">=1.15.1" -packaging = "*" - -[package.extras] -dev = ["anywidget", "build", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "jupyterlab", "kaleido (>=1.3.0)", "numpy (>=1.22)", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "polars[timezone]", "pyarrow", "pyshp", "pytest", "pytz", "requests", "ruff (==0.11.12)", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] -dev-build = ["build", "jupyterlab", "pytest", "requests", "ruff (==0.11.12)"] -dev-core = ["pytest", "requests", "ruff (==0.11.12)"] -dev-optional = ["anywidget", "build", "colorcet", "fiona (<=1.9.6) ; python_version <= \"3.8\"", "geopandas", "inflect", "jupyterlab", "kaleido (>=1.3.0)", "numpy (>=1.22)", "orjson", "pandas", "pdfrw", "pillow", "plotly-geo", "polars[timezone]", "pyarrow", "pyshp", "pytest", "pytz", "requests", "ruff (==0.11.12)", "scikit-image", "scipy", "shapely", "statsmodels", "vaex ; python_version <= \"3.9\"", "xarray"] -dev-pandas1 = ["numpy (>=1,<2)", "pandas (>=1,<2)", "setuptools (<82)"] -dev-pandas2 = ["pandas (>=2,<3)"] -dev-pandas3 = ["pandas (>=3) ; python_version >= \"3.11\""] -express = ["numpy (>=1.22)"] -kaleido = ["kaleido (>=1.3.0)"] - -[[package]] -name = "pluggy" -version = "1.6.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.9" -groups = ["main", "development"] -files = [ - {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, - {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["coverage", "pytest", "pytest-benchmark"] - -[[package]] -name = "poetry" -version = "2.4.1" -description = "Python dependency management and packaging made easy." -optional = false -python-versions = "<4.0,>=3.10" -groups = ["main"] -files = [ - {file = "poetry-2.4.1-py3-none-any.whl", hash = "sha256:a91f13279a3c9add0d12c5ca5c7cb173622930a5c8272fee68c751cb5c72f951"}, - {file = "poetry-2.4.1.tar.gz", hash = "sha256:189399b80347ecf908244b2564a7b1d92b648fa1fe2a204888f94a472fec0cac"}, -] - -[package.dependencies] -build = ">=1.2.1,<2.0.0" -cachecontrol = {version = ">=0.14.0,<0.15.0", extras = ["filecache"]} -cleo = ">=2.1.0,<3.0.0" -dulwich = ">=0.25.0,<2" -fastjsonschema = ">=2.18.0,<3.0.0" -findpython = ">=0.6.2,<0.9.0" -installer = ">=0.7.0,<2.0.0" -keyring = ">=25.1.0,<26.0.0" -packaging = ">=24.2" -pbs-installer = {version = ">=2025.6.10", extras = ["download", "install"]} -pkginfo = ">=1.12,<2.0" -platformdirs = ">=3.0.0,<5" -poetry-core = "2.4.0" -pyproject-hooks = ">=1.0.0,<2.0.0" -requests = ">=2.26,<3.0" -requests-toolbelt = ">=1.0.0,<2.0.0" -shellingham = ">=1.5,<2.0" -tomlkit = ">=0.11.4,<1.0.0" -trove-classifiers = ">=2022.5.19" -virtualenv = ">=20.26.6" -xattr = {version = ">=1.0.0,<2.0.0", markers = "sys_platform == \"darwin\""} - -[[package]] -name = "poetry-core" -version = "2.4.0" -description = "Poetry PEP 517 Build Backend" -optional = false -python-versions = "<4.0,>=3.10" -groups = ["main"] -files = [ - {file = "poetry_core-2.4.0-py3-none-any.whl", hash = "sha256:4305848477da00272bebd3f615bbec87f64bd117cdb858ab660b626a06a9d96c"}, - {file = "poetry_core-2.4.0.tar.gz", hash = "sha256:4e8c7496cf797998ffc493f2e23eba4b038c894c08eadacdcdf688945de6b43a"}, -] - -[[package]] -name = "pre-commit" -version = "4.6.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b"}, - {file = "pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "pre-commit-hooks" -version = "6.0.0" -description = "Some out-of-the-box hooks for pre-commit." -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "pre_commit_hooks-6.0.0-py2.py3-none-any.whl", hash = "sha256:76161b76d321d2f8ee2a8e0b84c30ee8443e01376121fd1c90851e33e3bd7ee2"}, - {file = "pre_commit_hooks-6.0.0.tar.gz", hash = "sha256:76d8370c006f5026cdd638a397a678d26dda735a3c88137e05885a020f824034"}, -] - -[package.dependencies] -"ruamel.yaml" = ">=0.15" - -[[package]] -name = "propcache" -version = "0.5.2" -description = "Accelerated property cache" -optional = false -python-versions = ">=3.10" -groups = ["main", "torch-cuda"] -files = [ - {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b"}, - {file = "propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c"}, - {file = "propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d"}, - {file = "propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e"}, - {file = "propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274"}, - {file = "propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe"}, - {file = "propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d"}, - {file = "propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5"}, - {file = "propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78"}, - {file = "propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959"}, - {file = "propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b"}, - {file = "propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27"}, - {file = "propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f"}, - {file = "propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0"}, - {file = "propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82"}, - {file = "propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab"}, - {file = "propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba"}, - {file = "propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a"}, - {file = "propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476"}, - {file = "propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33"}, - {file = "propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a"}, - {file = "propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031"}, - {file = "propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42"}, - {file = "propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84"}, - {file = "propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a"}, - {file = "propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117"}, - {file = "propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d"}, - {file = "propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704"}, - {file = "propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4"}, - {file = "propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d"}, - {file = "propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757"}, - {file = "propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f"}, - {file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d"}, - {file = "propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa"}, - {file = "propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc"}, - {file = "propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55"}, - {file = "propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568"}, - {file = "propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191"}, - {file = "propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7"}, - {file = "propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96"}, - {file = "propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999"}, - {file = "propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e"}, - {file = "propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825"}, - {file = "propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5"}, - {file = "propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4"}, - {file = "propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0"}, - {file = "propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c"}, - {file = "propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0"}, - {file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb"}, - {file = "propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078"}, - {file = "propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335"}, - {file = "propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d"}, - {file = "propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2"}, - {file = "propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821"}, - {file = "propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370"}, - {file = "propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6"}, - {file = "propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe"}, - {file = "propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427"}, -] - -[[package]] -name = "protobuf" -version = "7.35.1" -description = "" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6"}, - {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799"}, - {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4"}, - {file = "protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4"}, - {file = "protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30"}, - {file = "protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87"}, - {file = "protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9"}, - {file = "protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a"}, -] - -[[package]] -name = "psutil" -version = "7.2.2" -description = "Cross-platform lib for process and system monitoring." -optional = false -python-versions = ">=3.6" -groups = ["main", "torch-cuda"] -files = [ - {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, - {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"}, - {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"}, - {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"}, - {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"}, - {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"}, - {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"}, - {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"}, - {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"}, - {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"}, - {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"}, - {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"}, - {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"}, - {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"}, - {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"}, - {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"}, - {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"}, - {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"}, - {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"}, - {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"}, - {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"}, -] - -[package.extras] -dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] -test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""] - -[[package]] -name = "pyarrow" -version = "24.0.0" -description = "Python library for Apache Arrow" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"multiprocessing\"" -files = [ - {file = "pyarrow-24.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb"}, - {file = "pyarrow-24.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147"}, - {file = "pyarrow-24.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c"}, - {file = "pyarrow-24.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041"}, - {file = "pyarrow-24.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491"}, - {file = "pyarrow-24.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1"}, - {file = "pyarrow-24.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591"}, - {file = "pyarrow-24.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74"}, - {file = "pyarrow-24.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3"}, - {file = "pyarrow-24.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868"}, - {file = "pyarrow-24.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e"}, - {file = "pyarrow-24.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57"}, - {file = "pyarrow-24.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c"}, - {file = "pyarrow-24.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981"}, - {file = "pyarrow-24.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810"}, - {file = "pyarrow-24.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a"}, - {file = "pyarrow-24.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66"}, - {file = "pyarrow-24.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb"}, - {file = "pyarrow-24.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e"}, - {file = "pyarrow-24.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6"}, - {file = "pyarrow-24.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826"}, - {file = "pyarrow-24.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba"}, - {file = "pyarrow-24.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68"}, - {file = "pyarrow-24.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2"}, - {file = "pyarrow-24.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0"}, - {file = "pyarrow-24.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495"}, - {file = "pyarrow-24.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f"}, - {file = "pyarrow-24.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91"}, - {file = "pyarrow-24.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275"}, - {file = "pyarrow-24.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b"}, - {file = "pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42"}, - {file = "pyarrow-24.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b"}, - {file = "pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37"}, - {file = "pyarrow-24.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca"}, - {file = "pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d"}, - {file = "pyarrow-24.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838"}, - {file = "pyarrow-24.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b"}, - {file = "pyarrow-24.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795"}, - {file = "pyarrow-24.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26"}, - {file = "pyarrow-24.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde"}, - {file = "pyarrow-24.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76"}, - {file = "pyarrow-24.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e"}, - {file = "pyarrow-24.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05"}, - {file = "pyarrow-24.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a"}, - {file = "pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072"}, - {file = "pyarrow-24.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931"}, - {file = "pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699"}, - {file = "pyarrow-24.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136"}, - {file = "pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19"}, - {file = "pyarrow-24.0.0.tar.gz", hash = "sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83"}, -] - -[[package]] -name = "pycodestyle" -version = "2.14.0" -description = "Python style guide checker" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, - {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, -] - -[[package]] -name = "pycparser" -version = "3.0" -description = "C parser in Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "(sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" or sys_platform == \"darwin\") and implementation_name != \"PyPy\"" -files = [ - {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, - {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, -] - -[[package]] -name = "pydantic" -version = "2.13.4" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, - {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.46.4" -typing-extensions = ">=4.14.1" -typing-inspection = ">=0.4.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.46.4" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, - {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, - {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, - {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, - {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, - {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, - {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, - {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, - {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, - {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, - {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, - {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, - {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, - {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, - {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, - {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, - {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, - {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, - {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, - {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, - {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, - {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, - {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, - {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, - {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, - {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, - {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, - {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, - {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, - {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, - {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, - {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, - {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, - {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, - {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, - {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, - {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, - {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, - {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, - {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, - {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, - {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, - {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, - {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, - {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, - {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, - {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, - {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, - {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, - {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, - {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, - {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, - {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, - {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, - {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, - {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, - {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, - {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, - {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, - {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, - {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, - {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, - {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, -] - -[package.dependencies] -typing-extensions = ">=4.14.1" - -[[package]] -name = "pydocstyle" -version = "6.3.0" -description = "Python docstring style checker" -optional = false -python-versions = ">=3.6" -groups = ["development"] -files = [ - {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, - {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, -] - -[package.dependencies] -snowballstemmer = ">=2.2.0" - -[package.extras] -toml = ["tomli (>=1.2.3) ; python_version < \"3.11\""] - -[[package]] -name = "pyflakes" -version = "3.4.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, - {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, -] - -[[package]] -name = "pygments" -version = "2.20.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.9" -groups = ["main", "development"] -files = [ - {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, - {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyparsing" -version = "3.3.2" -description = "pyparsing - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.9" -groups = ["main", "torch-cuda"] -files = [ - {file = "pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d"}, - {file = "pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "pyproject-hooks" -version = "1.2.0" -description = "Wrappers to call pyproject.toml-based build backend hooks." -optional = false -python-versions = ">=3.7" -groups = ["main", "development"] -files = [ - {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, - {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, -] - -[[package]] -name = "pytest" -version = "7.4.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.7" -groups = ["main", "development"] -files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "python-discovery" -version = "1.4.2" -description = "Python interpreter discovery" -optional = false -python-versions = ">=3.8" -groups = ["main", "development"] -files = [ - {file = "python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500"}, - {file = "python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690"}, -] - -[package.dependencies] -filelock = ">=3.15.4" -platformdirs = ">=4.3.6,<5" - -[package.extras] -docs = ["furo (>=2025.12.19)", "sphinx (>=9.1)", "sphinx-autodoc-typehints (>=3.6.3)", "sphinxcontrib-mermaid (>=2)", "sphinxcontrib-towncrier (>=0.4)", "towncrier (>=25.8)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pytest-mock (>=3.14)", "setuptools (>=75.1)"] - -[[package]] -name = "pytokens" -version = "0.4.1" -description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." -optional = false -python-versions = ">=3.8" -groups = ["development"] -files = [ - {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, - {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, - {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, - {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, - {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, - {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, - {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, - {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, - {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, - {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, - {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, - {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, - {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, - {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, - {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, - {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, - {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, - {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, - {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, - {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, - {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, - {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, - {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, - {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, - {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, - {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, - {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, - {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, - {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, - {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, - {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, - {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, - {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, - {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, - {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, - {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, - {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, - {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, - {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, - {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, - {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, - {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, -] - -[package.extras] -dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] - -[[package]] -name = "pytorch-lightning" -version = "2.6.5" -description = "PyTorch Lightning is the lightweight PyTorch wrapper for ML researchers. Scale your models. Write less boilerplate." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pytorch_lightning-2.6.5-py3-none-any.whl", hash = "sha256:62d9c8549b2278fedc3364f0a5607a56c6063d18635008f8cf3fae8d802b0d76"}, - {file = "pytorch_lightning-2.6.5.tar.gz", hash = "sha256:1c32cefa76a1a9c4c5250338272d961d1e48b180e68396849efe128538ddb28e"}, -] - -[package.dependencies] -fsspec = {version = ">=2022.5.0", extras = ["http"]} -lightning-utilities = ">=0.10.0" -packaging = ">=23.0" -PyYAML = ">5.4" -torch = ">=2.1.0" -torchmetrics = ">0.7.0" -tqdm = ">=4.57.0" -typing-extensions = ">4.5.0" - -[package.extras] -all = ["bitsandbytes (>=0.45.2) ; platform_system != \"Darwin\"", "deepspeed (>=0.15.0,<0.17.0) ; platform_system != \"Windows\" and platform_system != \"Darwin\"", "hydra-core (>=1.2.0)", "ipython[all] (>=8.0.0)", "jsonargparse[jsonnet,signatures] (>=4.39.0)", "matplotlib (>3.1)", "omegaconf (>=2.2.3)", "requests (<2.33.0)", "rich (>=12.3.0)", "tensorboardX (>=2.2)", "torchmetrics (>=0.10.0)", "torchvision (>=0.16.0)"] -deepspeed = ["deepspeed (>=0.15.0,<0.17.0) ; platform_system != \"Windows\" and platform_system != \"Darwin\""] -dev = ["bitsandbytes (>=0.45.2) ; platform_system != \"Darwin\"", "cloudpickle (>=1.3)", "coverage (==7.10.7) ; python_version < \"3.10\"", "coverage (==7.13.4) ; python_version >= \"3.10\"", "deepspeed (>=0.15.0,<0.17.0) ; platform_system != \"Windows\" and platform_system != \"Darwin\"", "fastapi", "huggingface-hub", "hydra-core (>=1.2.0)", "ipython[all] (>=8.0.0)", "jsonargparse[jsonnet,signatures] (>=4.39.0)", "matplotlib (>3.1)", "numpy (>1.21.0) ; python_version < \"3.12\"", "numpy (>2.1.0) ; python_version >= \"3.12\"", "omegaconf (>=2.2.3)", "onnx (>1.12.0)", "onnx-ir (<0.1.16)", "onnxruntime (>=1.12.0)", "onnxscript (>=0.1.0)", "pandas (>2.0)", "psutil (<7.3.0)", "pytest (==9.0.2)", "pytest-cov (==7.0.0)", "pytest-random-order (==1.2.0)", "pytest-rerunfailures (==16.0.1) ; python_version < \"3.10\"", "pytest-rerunfailures (==16.1) ; python_version >= \"3.10\"", "pytest-timeout (==2.4.0)", "requests (<2.33.0)", "rich (>=12.3.0)", "scikit-learn (>0.22.1)", "tensorboard (>=2.11)", "tensorboardX (>=2.2)", "torchmetrics (>=0.10.0)", "torchvision (>=0.16.0)", "uvicorn"] -examples = ["ipython[all] (>=8.0.0)", "requests (<2.33.0)", "torchmetrics (>=0.10.0)", "torchvision (>=0.16.0)"] -extra = ["bitsandbytes (>=0.45.2) ; platform_system != \"Darwin\"", "hydra-core (>=1.2.0)", "jsonargparse[jsonnet,signatures] (>=4.39.0)", "matplotlib (>3.1)", "omegaconf (>=2.2.3)", "rich (>=12.3.0)", "tensorboardX (>=2.2)"] -strategies = ["deepspeed (>=0.15.0,<0.17.0) ; platform_system != \"Windows\" and platform_system != \"Darwin\""] -test = ["cloudpickle (>=1.3)", "coverage (==7.10.7) ; python_version < \"3.10\"", "coverage (==7.13.4) ; python_version >= \"3.10\"", "fastapi", "huggingface-hub", "numpy (>1.21.0) ; python_version < \"3.12\"", "numpy (>2.1.0) ; python_version >= \"3.12\"", "onnx (>1.12.0)", "onnx-ir (<0.1.16)", "onnxruntime (>=1.12.0)", "onnxscript (>=0.1.0)", "pandas (>2.0)", "psutil (<7.3.0)", "pytest (==9.0.2)", "pytest-cov (==7.0.0)", "pytest-random-order (==1.2.0)", "pytest-rerunfailures (==16.0.1) ; python_version < \"3.10\"", "pytest-rerunfailures (==16.1) ; python_version >= \"3.10\"", "pytest-timeout (==2.4.0)", "scikit-learn (>0.22.1)", "tensorboard (>=2.11)", "uvicorn"] -test-gpu = ["torch-tensorrt ; platform_system != \"Darwin\" and python_version >= \"3.12\""] - -[[package]] -name = "pytz" -version = "2026.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126"}, - {file = "pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a"}, -] - -[[package]] -name = "pyupgrade" -version = "3.21.2" -description = "A tool to automatically upgrade syntax for newer versions." -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "pyupgrade-3.21.2-py2.py3-none-any.whl", hash = "sha256:2ac7b95cbd176475041e4dfe8ef81298bd4654a244f957167bd68af37d52be9f"}, - {file = "pyupgrade-3.21.2.tar.gz", hash = "sha256:1a361bea39deda78d1460f65d9dd548d3a36ff8171d2482298539b9dc11c9c06"}, -] - -[package.dependencies] -tokenize-rt = ">=6.1.0" - -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -description = "A (partial) reimplementation of pywin32 using ctypes/cffi" -optional = false -python-versions = ">=3.6" -groups = ["main"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, - {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "development"] -files = [ - {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, - {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, - {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, - {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, - {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, - {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, - {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, - {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, - {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, - {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, - {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, - {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, - {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, - {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, - {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, - {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, - {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, - {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, - {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, - {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, - {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, - {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, - {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, - {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, - {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, - {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, - {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, - {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, - {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, - {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, - {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, - {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, - {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, - {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, - {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, - {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, - {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, - {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, - {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, -] - -[[package]] -name = "rapidfuzz" -version = "3.14.5" -description = "rapid fuzzy string matching" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "rapidfuzz-3.14.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:071d96b957a33b9296b9284b6350a0fb6d030b154a04efd7c15e56b98b79a517"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667f40fe9c81ad129b198d236881b00dd9e8314d9cc72d03c3e16bdfe5879051"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9fff308486bbd2c8c24f25e8e152c7594d3fe8db265a2d6a1ce24d58671127f"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dfa552338f51aec280f17b02d28bace1e162d1a84ccd80e3339a57f98aedb56b"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:068b3e965ca9d9ee4debe40001ae7c3938ba646308afd33cf0c66618147db65c"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88b7d31ff1cc5e9bc0e4406e6b1fa00b6d37163d50bb58091e9b976ff1129faa"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eacb434410b8d9ca99a8d42352ef085cf423e3c76c1f0b86be2fcba3bff2952c"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:649712823f3abcdc48427147a5384fac15623ba435d0013959b52e6462521397"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-win32.whl", hash = "sha256:13cb79c23ef5516e4c4e3830877be8b19aa75203636be1163d690d37803f6504"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-win_amd64.whl", hash = "sha256:f2073495a7f9b75e57e600747ac09510d67683fd64d3228e009740b7ef88f9fe"}, - {file = "rapidfuzz-3.14.5-cp310-cp310-win_arm64.whl", hash = "sha256:8166efddea49fdbc61185559f47593239e4794fd7c9044dd5a789d1a90af852d"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e251126d48615e1f02b4a178f2cd0cd4f0332b8a019c01a2e10480f7552554b4"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ab449c9abd0d4e1f8145dce0798a4c822a1a1933d613c764a641bea88b8bdab"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb2829fedd672dd7107267189dabe2bbe07972801d636014417c6861eb89e358"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d50e5861872935fece391351cbb5ba21d1bced277cf5e1143d207a0a35f1925"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:7092a216728f80c960bd6b3807275d1ee318b168986bd5dc523349581d4890b8"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9669753caef7fdc6529f6adcc5883ed98d65976445d9322e7dbdb6b697feee13"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:823b1b9d9230809d8edcc18872770764bfe8ef4357995e16744047c8ccf0e489"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f0b2af76b7e7060c09e1a0dfa9410eb19369cbe6164509bff2ef94094b54d2b6"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-win32.whl", hash = "sha256:c5801a89604c65ab4cc9e91b23bc4076d0ca80efd8c976fb63843d7879a85d7f"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:d7ca16637c0ede8243f84074044bd0b2335a0341421f8227c85756de2d18c819"}, - {file = "rapidfuzz-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:8c90cdf8516d9057e502aa6003cea71cf5ec27cc44699ca52412b502a04761bb"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0d3378f471ef440473a396ce2f8e97ee12f89a78b495540e0a5617bbfe895638"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e910eebca9fd0eba245c0555e764597e8a0cccb673a92da2dc2397050725f48"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01550fe5f60fd176aa66b7611289d46dc4aa4b1b904874c7b6d1d54e581c5ec1"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48bee0b91bebfaec41e1081e351000659ab7570cc4598d617aa04d5bf827f9e6"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:7e580cb04ad849ae9b786fa21383c6b994b6e6c1444ad1cb9f22392759d72741"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:09d6c9ba091854f07817055d795d604179c12a8f308ba4c7d56f3719dfea1646"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1e989f86113be66574113b9c7bdf4793f3f863d248e47d911b355e05ca6b6b10"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ebd1a18e2e47bc0b292a07e6ed9c3642f8aaa672d12253885f599b50807a4f9"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-win32.whl", hash = "sha256:9981d38a703b86f0e315a3cd229fd1906fe1d91c989ed121fb975b3c849f89f5"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:d8375e3da319593389727c3187ccaf3e0e84199accc530866b8e0f2b79af05e9"}, - {file = "rapidfuzz-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:478b59bb018a6780d73f33e38d0b3ec5e968a6c1ed42876b993dd456b7aa20e8"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ebd8fd343bf8492a1e60bcb6dc99f90f74f65d98d8241a6b3e1fed225b76ecd6"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6737b35d5af7479c5bf9710f7b17edd9d2c43128d974d25fb4ea653e42c64609"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b002c7994cc9f2bc9d9856f0fbaee6e8072c983873846c92f25cefba5b2a925f"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17a34330cd2a538c1ce5d400b61ba358c5b72c654b928ff87b362e88f8b864c7"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:95d937e74c1a7a1287dfb03b62a827be08ede10a155cf1af73bbf47f2b73ee6e"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:46b92a9970dcc34f0096901c792644094cab49554ac3547f35e3aebbdf0a3610"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e012177c8e8a8a0754ae0d6027d63042aa5ff036d9f40f07cb3466a6082e21b8"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ae6f53f99c9a0eca7a0afc5b4e45fc73bc1dd4ac74c00509031d76df80ed98"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-win32.whl", hash = "sha256:4a60f0057231188e3bd30216f7b4e0f279b11fa4ec818bb6c1d9f014d1562fbc"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:11bfc2ed8fbe4ab86bd516fadefab126f90e6dcadffa761739fcb304707dfd35"}, - {file = "rapidfuzz-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:b486b5218808f6f4dc471b114b1054e63553db69705c97da0271f47bd706aedd"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:39ef8658aaf67d51667e7bdaf7096f432333377d8302ac43c70b5df8a4cf89b8"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ad37a0be705b544af6296da8edddc260d10a8ae5462530fc9991f66498bb1f9"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d45e06f60729e07d9b20c205f7e5cff90b6ef2584e852eecf46e045aea69627d"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52da10236aa6212de71b9e170bace65b64b129c0dea7fc243d6c9ce976f5074"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:440d30faaf682ca496170a7f0cc5453ec942e3e079f0fd802c9a7f938dfb50a3"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:56227a61fd3d17b0cd9793132431f3a3d07c8654be96794ba9f89fe0fc8b2d09"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2e83cd2e25bb4edd97b689d9979d9c3acccdaaf26ceac08212ceece202febcfa"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:af3b859726cd3374287e405e14b9634563c078c5531a4f62375508addebddad1"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-win32.whl", hash = "sha256:8ce1d850b3c0178440efde9e884d98421b5e87ff925f364d6d79e23910d7593f"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-win_amd64.whl", hash = "sha256:c84af70bcf34e99aee894e46a0f1ac77f17d0ef828179c387407642e2466d28a"}, - {file = "rapidfuzz-3.14.5-cp313-cp313t-win_arm64.whl", hash = "sha256:aac0ad28c686a5e72b81668b906c030ee28050b244544b8af68e12fb32543895"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1a31cc6d7d03e7318a0974c038959c59e19c752b81115f2e9138b3331cd64d45"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0298d357e2bc59d572da4db0bc631009b6f8f6c9bc8c11e99a12b833f16b6575"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:59b3dba758661a318995655435c6ab20a04ade79fa51e75bc8dc107cac8df280"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4900143d82071bdda533b00300c40b14b963ff826b3642cc463b6dd0f036585e"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:feedf219672eef83ea6be6f3bb093bba396a8560fc75be85ba225f082903df0a"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419e4397a36e2665ec992d8d64c20ba4b2a42500c76ecadeca78a4f19cb9cc32"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:97131ab2be39043054ee28d99e09efe316e6d53449b7e962dfcf3c2de8b2b246"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:593c00dac4e30231c35bf3b4f1da8ec0998762e9e94425586a5d636fcd57f9d0"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-win32.whl", hash = "sha256:0084b687b02b4e569b46d8d6d4ad25659528e6081cd6d067ca453a69035f07e4"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:5dfa89d78f22cd773054caff44827b846161a29f2dcf7e78b8f90d086621e502"}, - {file = "rapidfuzz-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:67f3f9d2b444268ab53e47d31bab89954888d23c04c6789f2c727e51fe4b1d13"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77eac0526899b3c3ad1454bb2b03cdb491d67358ec8ef0c9c48bd61b632b431d"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b9c6bd754d11f6e78ac54e3d86b4b11dc1ba2f13e5fc958899574532897f5a99"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:738c96944d076deeaff70e92b65696ab4f7ecb8081d7791c5403a3257dfaf8ff"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4c1bca487a17fe4226b4ffb2d30e799d2b274d692cffa76bd0746f56235fca3"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:af6a90a4ed2a48fa1a2d17e9d824e6c7c950bea5bad0b707c77fd55751e6bfef"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bf5018938208d4597b2e679a4f8cff9fd252f1df53583130ae56281a21801b64"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c0919d1f89ddf91129906705723118ea09754171e4116f5a5dbc667c7bc9b261"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:93d8da883a35116d6813432177f35e570db5b0a5e30ecb0cbd7cb39c815735df"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:0f23e37019ec07712d58976b1ab2b889f8649a7f7c2f626a2f34ea9139e79279"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7d5ca9c7832e6879a707296d1463685f7c243a27846227044504741640caec66"}, - {file = "rapidfuzz-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3e91dcd2549b8f8d843f98ba03a17e01f3d8b72ce942adbbb6761bc58ffce813"}, - {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:578e6051f6d5e6200c259b47a103cf06bb875ab5814d17333fc0b5c290b22f4c"}, - {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbf1b8bb2695415b347f3727da1addca2acb82c9b97ac86bebf8b1bead1eb12d"}, - {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4a8f5cc84c7ad6bffa0e9947b33eb343ad66e6b53e94fe54378a5508c5ed53"}, - {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c6d85283629646fa87acc22c66b30ea9d4de7f6fdf887daa2e30fa041829b5"}, - {file = "rapidfuzz-3.14.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:dfef96543ced67d9513a422755db422ae1dc34dade0a1485e0b43e7342ed3ebf"}, - {file = "rapidfuzz-3.14.5.tar.gz", hash = "sha256:ba10ac57884ce82112f7ed910b67e7fb6072d8ef2c06e30dc63c0f604a112e0e"}, -] - -[package.extras] -all = ["numpy"] - -[[package]] -name = "ray" -version = "2.56.0" -description = "Ray provides a simple, universal API for building distributed applications." -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"multiprocessing\"" -files = [ - {file = "ray-2.56.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:f34b2345a47ad144292c1b34eeba2ed8d556078f7bd118d1adf2090d5199c843"}, - {file = "ray-2.56.0-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:15ea7ac36bfa3961c1eb2b2a099ed7dcf892f001f462920b6ec379ccafb038b0"}, - {file = "ray-2.56.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:58be75df2d4a6a85b5e514e4d3261760fe21cc09ba974ce22cc08a8e4e07c449"}, - {file = "ray-2.56.0-cp310-cp310-win_amd64.whl", hash = "sha256:b837c5a905647c9b6a4f55061c2782437da24009e4eb4781db1c9c4badc84d0b"}, - {file = "ray-2.56.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:a9ad4e26941eb2f8dbd494ad07f9f2227143164c6114132b26b23ad4f20b1c6f"}, - {file = "ray-2.56.0-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:aea655831d25084cb343002a8e67a77b6aa552ddb776a65461d49f62884f096a"}, - {file = "ray-2.56.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:c5bf1a4384c0e2aa4420c95474b734d064cac354b0f00760be02d886afe96ca4"}, - {file = "ray-2.56.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e57781685bb4332edf8a7cfb1135ff53d50002b6a0504006e68365bcd26aa7c"}, - {file = "ray-2.56.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:684a427c50745989e92a332343f0812c93b8506f71c768b95b1eefc113492699"}, - {file = "ray-2.56.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:e1fd03c6ecc5fe4c31466569e41ce0a4faf26fb930798c9d1f1eb1f405a687c8"}, - {file = "ray-2.56.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:78ef34a71383c1fcf335e531e0e590867857fce9069f06ed351be6ce7a58fc50"}, - {file = "ray-2.56.0-cp312-cp312-win_amd64.whl", hash = "sha256:a8fc809dab6fc07cf05d45ea93a776c852c990512eb1fac0e3d15819fe6df10a"}, - {file = "ray-2.56.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:992047f50473b5bfea74c8f528f999968e0b4bc735af23ad476a0f4e04741aea"}, - {file = "ray-2.56.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:a0e9cfe92c88ab74abca23923c15a592f49bc7617ffdda4190daca7785a7c4f6"}, - {file = "ray-2.56.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:54d2725f8b65d9615c933fec5ec62e54a67b8f2e14286026fb014606253670ef"}, - {file = "ray-2.56.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f38e03b77c53e3d94091aedb84b14efe7ee5b581d85b7d13925066bcd48c44a2"}, - {file = "ray-2.56.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:c3a16d43d75283a3d64fa1d904a3adaf3f526f3f508f447505b8bb8dc70bad6c"}, - {file = "ray-2.56.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:73edb6fb5fd05481b1f358ac2e8a4c7f6a031d89f9b823d25a587ddb7529070f"}, -] - -[package.dependencies] -click = ">=7.0" -filelock = "*" -fsspec = {version = "*", optional = true, markers = "extra == \"tune\""} -jsonschema = "*" -msgpack = ">=1.0.0,<2.0.0" -packaging = ">=24.2" -pandas = {version = "*", optional = true, markers = "extra == \"tune\""} -protobuf = ">=3.20.3" -pyarrow = {version = ">=17.0.0", optional = true, markers = "extra == \"tune\""} -pydantic = {version = ">=2.5.0,<3", optional = true, markers = "python_version < \"3.14\" and extra == \"tune\""} -pyyaml = "*" -requests = "*" -tensorboardX = {version = ">=1.9", optional = true, markers = "extra == \"tune\""} - -[package.extras] -adag = ["cupy-cuda12x ; sys_platform != \"darwin\""] -air = ["aiohttp (>=3.13.3)", "aiohttp_cors", "colorful", "fastapi (>=0.133.0)", "fsspec", "grpcio (>=1.42.0)", "mmh3", "numpy (>=1.20)", "opencensus", "opentelemetry-exporter-prometheus", "opentelemetry-proto", "opentelemetry-sdk (>=1.30.0)", "pandas", "pandas (>=2.2.3)", "prometheus_client (>=0.7.1)", "py-spy (>=0.2.0) ; python_version < \"3.12\"", "py-spy (>=0.4.0) ; python_version >= \"3.12\"", "pyarrow (>=17.0.0)", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "requests", "smart_open", "starlette (>=1.0.1)", "tensorboardX (>=1.9)", "uvicorn[standard]", "virtualenv (>=20.0.24,!=20.21.1)", "watchfiles"] -all = ["aiohttp (>=3.13.3)", "aiohttp_cors", "celery", "colorful", "cupy-cuda12x ; sys_platform != \"darwin\"", "dm_tree", "fastapi (>=0.133.0)", "fsspec", "grpcio", "grpcio (!=1.56.0) ; sys_platform == \"darwin\"", "grpcio (>=1.42.0)", "gymnasium (==1.2.2)", "lz4", "memray ; sys_platform != \"win32\"", "mmh3", "numpy (>=1.20)", "opencensus", "opentelemetry-exporter-prometheus", "opentelemetry-proto", "opentelemetry-sdk (>=1.30.0)", "ormsgpack (>=1.7.0)", "pandas", "pandas (>=2.2.3)", "prometheus_client (>=0.7.1)", "py-spy (>=0.2.0) ; python_version < \"3.12\"", "py-spy (>=0.4.0) ; python_version >= \"3.12\"", "pyOpenSSL", "pyarrow (>=17.0.0)", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "pyyaml", "requests", "scipy", "smart_open", "starlette (>=1.0.1)", "taskiq", "tensorboardX (>=1.9)", "uvicorn[standard]", "virtualenv (>=20.0.24,!=20.21.1)", "watchfiles"] -all-cpp = ["aiohttp (>=3.13.3)", "aiohttp_cors", "celery", "colorful", "cupy-cuda12x ; sys_platform != \"darwin\"", "dm_tree", "fastapi (>=0.133.0)", "fsspec", "grpcio", "grpcio (!=1.56.0) ; sys_platform == \"darwin\"", "grpcio (>=1.42.0)", "gymnasium (==1.2.2)", "lz4", "memray ; sys_platform != \"win32\"", "mmh3", "numpy (>=1.20)", "opencensus", "opentelemetry-exporter-prometheus", "opentelemetry-proto", "opentelemetry-sdk (>=1.30.0)", "ormsgpack (>=1.7.0)", "pandas", "pandas (>=2.2.3)", "prometheus_client (>=0.7.1)", "py-spy (>=0.2.0) ; python_version < \"3.12\"", "py-spy (>=0.4.0) ; python_version >= \"3.12\"", "pyOpenSSL", "pyarrow (>=17.0.0)", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "pyyaml", "ray-cpp (==2.56.0)", "requests", "scipy", "smart_open", "starlette (>=1.0.1)", "taskiq", "tensorboardX (>=1.9)", "uvicorn[standard]", "virtualenv (>=20.0.24,!=20.21.1)", "watchfiles"] -cgraph = ["cupy-cuda12x ; sys_platform != \"darwin\""] -client = ["grpcio", "grpcio (!=1.56.0) ; sys_platform == \"darwin\""] -cpp = ["ray-cpp (==2.56.0)"] -data = ["fsspec", "numpy (>=1.20)", "pandas (>=2.2.3)", "pyarrow (>=17.0.0)"] -default = ["aiohttp (>=3.13.3)", "aiohttp_cors", "colorful", "grpcio (>=1.42.0)", "opencensus", "opentelemetry-exporter-prometheus", "opentelemetry-proto", "opentelemetry-sdk (>=1.30.0)", "prometheus_client (>=0.7.1)", "py-spy (>=0.2.0) ; python_version < \"3.12\"", "py-spy (>=0.4.0) ; python_version >= \"3.12\"", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "requests", "smart_open", "virtualenv (>=20.0.24,!=20.21.1)"] -llm = ["aiohttp (>=3.13.3)", "aiohttp_cors", "async-timeout ; python_version < \"3.11\"", "colorful", "fastapi (>=0.133.0)", "fsspec", "grpcio (>=1.42.0)", "hf_transfer", "jsonref (>=1.1.0)", "jsonschema", "meson", "mmh3", "ninja", "nixl (==1.1.0)", "nixl-cu13 (==1.1.0)", "numpy (>=1.20)", "opencensus", "opentelemetry-exporter-prometheus", "opentelemetry-proto", "opentelemetry-sdk (>=1.30.0)", "pandas (>=2.2.3)", "prometheus_client (>=0.7.1)", "py-spy (>=0.2.0) ; python_version < \"3.12\"", "py-spy (>=0.4.0) ; python_version >= \"3.12\"", "pyarrow (>=17.0.0)", "pybind11", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "requests", "smart_open", "starlette (>=1.0.1)", "typer", "uvicorn[standard]", "virtualenv (>=20.0.24,!=20.21.1)", "vllm[audio] (==0.22.0)", "watchfiles"] -observability = ["memray ; sys_platform != \"win32\""] -rllib = ["dm_tree", "fsspec", "gymnasium (==1.2.2)", "lz4", "ormsgpack (>=1.7.0)", "pandas", "pyarrow (>=17.0.0)", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "pyyaml", "requests", "scipy", "tensorboardX (>=1.9)"] -serve = ["aiohttp (>=3.13.3)", "aiohttp_cors", "colorful", "fastapi (>=0.133.0)", "grpcio (>=1.42.0)", "mmh3", "opencensus", "opentelemetry-exporter-prometheus", "opentelemetry-proto", "opentelemetry-sdk (>=1.30.0)", "prometheus_client (>=0.7.1)", "py-spy (>=0.2.0) ; python_version < \"3.12\"", "py-spy (>=0.4.0) ; python_version >= \"3.12\"", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "requests", "smart_open", "starlette (>=1.0.1)", "uvicorn[standard]", "virtualenv (>=20.0.24,!=20.21.1)", "watchfiles"] -serve-async-inference = ["aiohttp (>=3.13.3)", "aiohttp_cors", "celery", "colorful", "fastapi (>=0.133.0)", "grpcio (>=1.42.0)", "mmh3", "opencensus", "opentelemetry-exporter-prometheus", "opentelemetry-proto", "opentelemetry-sdk (>=1.30.0)", "prometheus_client (>=0.7.1)", "py-spy (>=0.2.0) ; python_version < \"3.12\"", "py-spy (>=0.4.0) ; python_version >= \"3.12\"", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "requests", "smart_open", "starlette (>=1.0.1)", "taskiq", "uvicorn[standard]", "virtualenv (>=20.0.24,!=20.21.1)", "watchfiles"] -serve-grpc = ["aiohttp (>=3.13.3)", "aiohttp_cors", "colorful", "fastapi (>=0.133.0)", "grpcio (>=1.42.0)", "mmh3", "opencensus", "opentelemetry-exporter-prometheus", "opentelemetry-proto", "opentelemetry-sdk (>=1.30.0)", "prometheus_client (>=0.7.1)", "py-spy (>=0.2.0) ; python_version < \"3.12\"", "py-spy (>=0.4.0) ; python_version >= \"3.12\"", "pyOpenSSL", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "requests", "smart_open", "starlette (>=1.0.1)", "uvicorn[standard]", "virtualenv (>=20.0.24,!=20.21.1)", "watchfiles"] -train = ["fsspec", "pandas", "pyarrow (>=17.0.0)", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "requests", "tensorboardX (>=1.9)"] -tune = ["fsspec", "pandas", "pyarrow (>=17.0.0)", "pydantic (>=2.13.0,<3) ; python_version >= \"3.14\"", "pydantic (>=2.5.0,<3) ; python_version < \"3.14\"", "requests", "tensorboardX (>=1.9)"] - -[[package]] -name = "referencing" -version = "0.37.0" -description = "JSON Referencing + Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "extra == \"multiprocessing\"" -files = [ - {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, - {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -rpds-py = ">=0.7.0" -typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} - -[[package]] -name = "requests" -version = "2.34.2" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.10" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, - {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, -] - -[package.dependencies] -certifi = ">=2023.5.7" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.26,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] - -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -description = "A utility belt for advanced users of python-requests" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main"] -files = [ - {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, - {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, -] - -[package.dependencies] -requests = ">=2.0.1,<3.0.0" - -[[package]] -name = "restructuredtext-lint" -version = "2.0.2" -description = "reStructuredText linter" -optional = false -python-versions = "*" -groups = ["development"] -files = [ - {file = "restructuredtext_lint-2.0.2-py3-none-any.whl", hash = "sha256:374c0d3e7e0867b2335146a145343ac619400623716b211b9a010c94426bbed7"}, - {file = "restructuredtext_lint-2.0.2.tar.gz", hash = "sha256:dd25209b9e0b726929d8306339faf723734a3137db382bcf27294fa18a6bc52b"}, -] - -[package.dependencies] -docutils = ">=0.11,<1.0" - -[[package]] -name = "rich" -version = "15.0.0" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.9.0" -groups = ["main", "development"] -files = [ - {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, - {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - -[[package]] -name = "roman-numerals" -version = "4.1.0" -description = "Manipulate well-formed Roman numerals" -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7"}, - {file = "roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2"}, -] - -[[package]] -name = "roman-numerals-py" -version = "4.1.0" -description = "This package is deprecated, switch to roman-numerals." -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780"}, - {file = "roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9"}, -] - -[package.dependencies] -roman-numerals = "4.1.0" - -[[package]] -name = "rpds-py" -version = "2026.6.3" -description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false -python-versions = ">=3.11" -groups = ["main"] -markers = "extra == \"multiprocessing\"" -files = [ - {file = "rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7"}, - {file = "rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da"}, - {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4"}, - {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6"}, - {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93"}, - {file = "rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a"}, - {file = "rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127"}, - {file = "rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804"}, - {file = "rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0"}, - {file = "rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4"}, - {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa"}, - {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc"}, - {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822"}, - {file = "rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed"}, - {file = "rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f"}, - {file = "rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96"}, - {file = "rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223"}, - {file = "rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4"}, - {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7"}, - {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d"}, - {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97"}, - {file = "rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0"}, - {file = "rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80"}, - {file = "rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb"}, - {file = "rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e"}, - {file = "rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77"}, - {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698"}, - {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd"}, - {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d"}, - {file = "rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8"}, - {file = "rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5"}, - {file = "rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2"}, - {file = "rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13"}, - {file = "rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868"}, - {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187"}, - {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107"}, - {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba"}, - {file = "rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369"}, - {file = "rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146"}, - {file = "rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826"}, - {file = "rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4"}, -] - -[[package]] -name = "ruamel-yaml" -version = "0.19.1" -description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93"}, - {file = "ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993"}, -] - -[package.extras] -docs = ["mercurial (>5.7)", "ryd"] -jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] -libyaml = ["ruamel.yaml.clibz (>=0.3.7) ; platform_python_implementation == \"CPython\""] -oldlibyaml = ["ruamel.yaml.clib ; platform_python_implementation == \"CPython\""] - -[[package]] -name = "ruff" -version = "0.15.20" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078"}, - {file = "ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b"}, - {file = "ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632"}, - {file = "ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd"}, - {file = "ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b"}, - {file = "ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267"}, - {file = "ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c"}, - {file = "ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae"}, - {file = "ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b"}, - {file = "ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487"}, - {file = "ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3"}, - {file = "ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053"}, - {file = "ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4"}, - {file = "ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460"}, - {file = "ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21"}, - {file = "ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415"}, - {file = "ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca"}, - {file = "ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566"}, -] - -[[package]] -name = "scikit-learn" -version = "1.9.0" -description = "A set of python modules for machine learning and data mining" -optional = false -python-versions = ">=3.11" -groups = ["main"] -files = [ - {file = "scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b"}, - {file = "scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c"}, - {file = "scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa"}, - {file = "scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8"}, - {file = "scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759"}, - {file = "scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28"}, - {file = "scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac"}, - {file = "scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1"}, - {file = "scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f"}, - {file = "scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8"}, - {file = "scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283"}, - {file = "scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60"}, - {file = "scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119"}, - {file = "scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713"}, - {file = "scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05"}, - {file = "scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714"}, - {file = "scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277"}, - {file = "scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e"}, - {file = "scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162"}, - {file = "scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2"}, - {file = "scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913"}, - {file = "scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb"}, - {file = "scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673"}, - {file = "scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42"}, - {file = "scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949"}, - {file = "scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96"}, - {file = "scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b"}, - {file = "scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a"}, - {file = "scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666"}, - {file = "scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa"}, - {file = "scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557"}, -] - -[package.dependencies] -joblib = ">=1.4.0" -narwhals = ">=2.0.1" -numpy = ">=1.24.1" -scipy = ">=1.10.0" -threadpoolctl = ">=3.5.0" - -[package.extras] -benchmark = ["matplotlib (>=3.6.1)", "memory_profiler (>=0.57.0)", "pandas (>=1.5.0)"] -build = ["cython (>=3.1.2)", "meson-python (>=0.17.1)", "numpy (>=1.24.1)", "scipy (>=1.10.0)"] -docs = ["Pillow (>=12.1.1)", "matplotlib (>=3.6.1)", "memory_profiler (>=0.57.0)", "numpydoc (>=1.2.0)", "pandas (>=1.5.0)", "plotly (>=5.22.0)", "polars (>=0.20.30)", "pooch (>=1.8.0)", "pydata-sphinx-theme (>=0.15.3)", "rich (>=14.1.0)", "scikit-image (>=0.22.0)", "seaborn (>=0.13.0)", "sphinx (>=7.3.7)", "sphinx-copybutton (>=0.5.2)", "sphinx-design (>=0.6.0)", "sphinx-gallery (>=0.17.1)", "sphinx-prompt (>=1.4.0)", "sphinx-remove-toctrees (>=1.0.0.post1)", "sphinxcontrib-sass (>=0.3.4)", "sphinxext-opengraph (>=0.9.1)", "towncrier (>=24.8.0)"] -examples = ["matplotlib (>=3.6.1)", "pandas (>=1.5.0)", "plotly (>=5.22.0)", "pooch (>=1.8.0)", "rich (>=14.1.0)", "scikit-image (>=0.22.0)", "seaborn (>=0.13.0)"] -install = ["joblib (>=1.4.0)", "narwhals (>=2.0.1)", "numpy (>=1.24.1)", "scipy (>=1.10.0)", "threadpoolctl (>=3.5.0)"] -maintenance = ["conda-lock (==3.0.1)"] -tests = ["matplotlib (>=3.6.1)", "mypy (>=1.15)", "numpydoc (>=1.2.0)", "pandas (>=1.5.0)", "polars (>=0.20.30)", "pooch (>=1.8.0)", "pyamg (>=5.0.0)", "pyarrow (>=13.0.0)", "pytest (>=7.1.2)", "pytest-cov (>=2.9.0)", "rich (>=14.1.0)", "ruff (>=0.12.2)"] - -[[package]] -name = "scikit-posthocs" -version = "0.14.0" -description = "Statistical post-hoc analysis and outlier detection algorithms" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "scikit_posthocs-0.14.0-py3-none-any.whl", hash = "sha256:9e2ecd5828cb783c42c1923e223eab202f529b03ab97de587647ab30f6e88333"}, - {file = "scikit_posthocs-0.14.0.tar.gz", hash = "sha256:3b9f9273fc9037ee967d11b6a15aef1c6c0ed33a1936ff8879d6f5fb0a181227"}, -] - -[package.dependencies] -matplotlib = "*" -numpy = "*" -pandas = ">=0.20.0" -scipy = ">=1.9.0" -seaborn = "*" -statsmodels = "*" - -[package.extras] -test = ["coverage", "pytest"] - -[[package]] -name = "scipy" -version = "1.17.1" -description = "Fundamental algorithms for scientific computing in Python" -optional = false -python-versions = ">=3.11" -groups = ["main"] -markers = "python_version == \"3.11\"" -files = [ - {file = "scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec"}, - {file = "scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696"}, - {file = "scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee"}, - {file = "scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd"}, - {file = "scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c"}, - {file = "scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4"}, - {file = "scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444"}, - {file = "scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082"}, - {file = "scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff"}, - {file = "scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d"}, - {file = "scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8"}, - {file = "scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76"}, - {file = "scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086"}, - {file = "scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b"}, - {file = "scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21"}, - {file = "scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458"}, - {file = "scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb"}, - {file = "scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea"}, - {file = "scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87"}, - {file = "scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3"}, - {file = "scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c"}, - {file = "scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f"}, - {file = "scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d"}, - {file = "scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b"}, - {file = "scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6"}, - {file = "scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464"}, - {file = "scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950"}, - {file = "scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369"}, - {file = "scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448"}, - {file = "scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87"}, - {file = "scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a"}, - {file = "scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0"}, - {file = "scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce"}, - {file = "scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6"}, - {file = "scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e"}, - {file = "scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475"}, - {file = "scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50"}, - {file = "scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca"}, - {file = "scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c"}, - {file = "scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49"}, - {file = "scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717"}, - {file = "scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9"}, - {file = "scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b"}, - {file = "scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866"}, - {file = "scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350"}, - {file = "scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118"}, - {file = "scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068"}, - {file = "scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118"}, - {file = "scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19"}, - {file = "scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293"}, - {file = "scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6"}, - {file = "scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1"}, - {file = "scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39"}, - {file = "scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca"}, - {file = "scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad"}, - {file = "scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a"}, - {file = "scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4"}, - {file = "scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2"}, - {file = "scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484"}, - {file = "scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21"}, - {file = "scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0"}, -] - -[package.dependencies] -numpy = ">=1.26.4,<2.7" - -[package.extras] -dev = ["click (<8.3.0)", "cython-lint (>=0.12.2)", "mypy (==1.10.0)", "pycodestyle", "ruff (>=0.12.0)", "spin", "types-psutil", "typing_extensions"] -doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)", "tabulate"] -test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest (>=8.0.0)", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] - -[[package]] -name = "scipy" -version = "1.18.0" -description = "Fundamental algorithms for scientific computing in Python" -optional = false -python-versions = ">=3.12" -groups = ["main"] -markers = "python_version >= \"3.12\"" -files = [ - {file = "scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a"}, - {file = "scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b"}, - {file = "scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9"}, - {file = "scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8"}, - {file = "scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab"}, - {file = "scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2"}, - {file = "scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11"}, - {file = "scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de"}, - {file = "scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132"}, - {file = "scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76"}, - {file = "scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446"}, - {file = "scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520"}, - {file = "scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197"}, - {file = "scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b"}, - {file = "scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468"}, - {file = "scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f"}, - {file = "scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f"}, - {file = "scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4"}, - {file = "scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7"}, - {file = "scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b"}, - {file = "scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578"}, - {file = "scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8"}, - {file = "scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d"}, - {file = "scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6"}, - {file = "scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690"}, - {file = "scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0"}, - {file = "scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867"}, - {file = "scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709"}, - {file = "scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61"}, - {file = "scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b"}, - {file = "scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707"}, - {file = "scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677"}, - {file = "scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4"}, - {file = "scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce"}, - {file = "scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0"}, - {file = "scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58"}, - {file = "scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553"}, - {file = "scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76"}, - {file = "scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f"}, - {file = "scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8"}, - {file = "scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378"}, -] - -[package.dependencies] -numpy = ">=2.0.0,<2.8" - -[package.extras] -dev = ["click (<8.3.0)", "cython-lint (>=0.12.2)", "mypy (==1.19.1)", "pycodestyle", "pyrefly (==0.63.0)", "ruff (>=0.12.0)", "spin", "types-psutil", "typing_extensions"] -doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "linkify-it-py", "matplotlib (>=3.5)", "myst-nb (>=1.2.0)", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.2.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)", "tabulate"] -test = ["Cython", "array-api-strict (>=2.3.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest (>=8.0.0)", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "scipy-doctest (>=2.0.0)", "threadpoolctl"] - -[[package]] -name = "seaborn" -version = "0.13.2" -description = "Statistical data visualization" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987"}, - {file = "seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7"}, -] - -[package.dependencies] -matplotlib = ">=3.4,<3.6.1 || >3.6.1" -numpy = ">=1.20,<1.24.0 || >1.24.0" -pandas = ">=1.2" - -[package.extras] -dev = ["flake8", "flit", "mypy", "pandas-stubs", "pre-commit", "pytest", "pytest-cov", "pytest-xdist"] -docs = ["ipykernel", "nbconvert", "numpydoc", "pydata_sphinx_theme (==0.10.0rc2)", "pyyaml", "sphinx (<6.0.0)", "sphinx-copybutton", "sphinx-design", "sphinx-issues"] -stats = ["scipy (>=1.7)", "statsmodels (>=0.12)"] - -[[package]] -name = "secretstorage" -version = "3.5.0" -description = "Python bindings to FreeDesktop.org Secret Service API" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "sys_platform == \"linux\"" -files = [ - {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"}, - {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"}, -] - -[package.dependencies] -cryptography = ">=2.0" -jeepney = ">=0.6" - -[[package]] -name = "sentry-sdk" -version = "2.64.0" -description = "Python client for Sentry (https://sentry.io)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1"}, - {file = "sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55"}, -] - -[package.dependencies] -certifi = "*" -urllib3 = ">=1.26.11" - -[package.extras] -aiohttp = ["aiohttp (>=3.5)"] -anthropic = ["anthropic (>=0.16)"] -arq = ["arq (>=0.23)"] -asyncio = ["httpcore[asyncio] (==1.*)"] -asyncpg = ["asyncpg (>=0.23)"] -beam = ["apache-beam (>=2.12)"] -bottle = ["bottle (>=0.12.13)"] -celery = ["celery (>=3)"] -celery-redbeat = ["celery-redbeat (>=2)"] -chalice = ["chalice (>=1.16.0)"] -clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] -django = ["django (>=1.8)"] -falcon = ["falcon (>=1.4)"] -fastapi = ["fastapi (>=0.79.0)"] -flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] -google-genai = ["google-genai (>=1.29.0)"] -grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] -http2 = ["httpcore[http2] (==1.*)"] -httpx = ["httpx (>=0.16.0)"] -huey = ["huey (>=2)"] -huggingface-hub = ["huggingface_hub (>=0.22)"] -langchain = ["langchain (>=0.0.210)"] -langgraph = ["langgraph (>=0.6.6)"] -launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] -litellm = ["litellm (>=1.77.5,!=1.82.7,!=1.82.8)"] -litestar = ["litestar (>=2.0.0)"] -loguru = ["loguru (>=0.5)"] -mcp = ["mcp (>=1.15.0)"] -openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] -openfeature = ["openfeature-sdk (>=0.7.1)"] -opentelemetry = ["opentelemetry-distro (>=0.35b0)"] -opentelemetry-experimental = ["opentelemetry-distro"] -opentelemetry-otlp = ["opentelemetry-distro[otlp] (>=0.35b0)"] -pure-eval = ["asttokens", "executing", "pure_eval"] -pydantic-ai = ["pydantic-ai (>=1.0.0)"] -pymongo = ["pymongo (>=3.1)"] -pyspark = ["pyspark (>=2.4.4)"] -quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] -rq = ["rq (>=0.6)"] -sanic = ["sanic (>=0.8)"] -sqlalchemy = ["sqlalchemy (>=1.2)"] -starlette = ["starlette (>=0.19.1)"] -starlite = ["starlite (>=1.48)"] -statsig = ["statsig (>=0.55.3)"] -tornado = ["tornado (>=6)"] -unleash = ["UnleashClient (>=6.0.1)"] - -[[package]] -name = "setuptools" -version = "81.0.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.9" -groups = ["main", "torch-cuda"] -files = [ - {file = "setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6"}, - {file = "setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.0) ; sys_platform != \"cygwin\""] -core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] - -[[package]] -name = "shellingham" -version = "1.5.4" -description = "Tool to Detect Surrounding Shell" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, - {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, -] - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "smmap" -version = "5.0.3" -description = "A pure Python implementation of a sliding window memory map manager" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f"}, - {file = "smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c"}, -] - -[[package]] -name = "snowballstemmer" -version = "3.1.1" -description = "This package provides 36 stemmers for 34 languages generated from Snowball algorithms." -optional = false -python-versions = ">=3.3" -groups = ["development"] -files = [ - {file = "snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752"}, - {file = "snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260"}, -] - -[[package]] -name = "sphinx" -version = "8.2.3" -description = "Python documentation generator" -optional = false -python-versions = ">=3.11" -groups = ["development"] -files = [ - {file = "sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3"}, - {file = "sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348"}, -] - -[package.dependencies] -alabaster = ">=0.7.14" -babel = ">=2.13" -colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""} -docutils = ">=0.20,<0.22" -imagesize = ">=1.3" -Jinja2 = ">=3.1" -packaging = ">=23.0" -Pygments = ">=2.17" -requests = ">=2.30.0" -roman-numerals-py = ">=1.0.0" -snowballstemmer = ">=2.2" -sphinxcontrib-applehelp = ">=1.0.7" -sphinxcontrib-devhelp = ">=1.0.6" -sphinxcontrib-htmlhelp = ">=2.0.6" -sphinxcontrib-jsmath = ">=1.0.1" -sphinxcontrib-qthelp = ">=1.0.6" -sphinxcontrib-serializinghtml = ">=1.1.9" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -lint = ["betterproto (==2.0.0b6)", "mypy (==1.15.0)", "pypi-attestations (==0.0.21)", "pyright (==1.1.395)", "pytest (>=8.0)", "ruff (==0.9.9)", "sphinx-lint (>=0.9)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.19.0.20250219)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241128)", "types-requests (==2.32.0.20241016)", "types-urllib3 (==1.26.25.14)"] -test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "pytest-xdist[psutil] (>=3.4)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"] - -[[package]] -name = "sphinx-autobuild" -version = "2025.8.25" -description = "Rebuild Sphinx documentation on changes, with hot reloading in the browser." -optional = false -python-versions = ">=3.11" -groups = ["development"] -files = [ - {file = "sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a"}, - {file = "sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213"}, -] - -[package.dependencies] -colorama = ">=0.4.6" -Sphinx = "*" -starlette = ">=0.35" -uvicorn = ">=0.25" -watchfiles = ">=0.20" -websockets = ">=11" - -[package.extras] -test = ["httpx", "pytest (>=6)"] - -[[package]] -name = "sphinx-autodoc-typehints" -version = "3.5.2" -description = "Type hints (PEP 484) support for the Sphinx autodoc extension" -optional = false -python-versions = ">=3.11" -groups = ["development"] -files = [ - {file = "sphinx_autodoc_typehints-3.5.2-py3-none-any.whl", hash = "sha256:0accd043619f53c86705958e323b419e41667917045ac9215d7be1b493648d8c"}, - {file = "sphinx_autodoc_typehints-3.5.2.tar.gz", hash = "sha256:5fcd4a3eb7aa89424c1e2e32bedca66edc38367569c9169a80f4b3e934171fdb"}, -] - -[package.dependencies] -sphinx = ">=8.2.3" - -[package.extras] -docs = ["furo (>=2025.9.25)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.10.7)", "defusedxml (>=0.7.1)", "diff-cover (>=9.7.1)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "sphobjinv (>=2.3.1.3)", "typing-extensions (>=4.15)"] - -[[package]] -name = "sphinx-click" -version = "6.2.0" -description = "Sphinx extension that automatically documents click applications" -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "sphinx_click-6.2.0-py3-none-any.whl", hash = "sha256:1fb1851cb4f2c286d43cbcd57f55db6ef5a8d208bfc3370f19adde232e5803d7"}, - {file = "sphinx_click-6.2.0.tar.gz", hash = "sha256:fc78b4154a4e5159462e36de55b8643747da6cda86b3b52a8bb62289e603776c"}, -] - -[package.dependencies] -click = ">=8.0" -docutils = "*" -sphinx = ">=4.0" - -[package.extras] -docs = ["reno"] -test = ["pytest", "pytest-cov"] - -[[package]] -name = "sphinx-rtd-theme" -version = "3.0.2" -description = "Read the Docs theme for Sphinx" -optional = false -python-versions = ">=3.8" -groups = ["development"] -files = [ - {file = "sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13"}, - {file = "sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85"}, -] - -[package.dependencies] -docutils = ">0.18,<0.22" -sphinx = ">=6,<9" -sphinxcontrib-jquery = ">=4,<5" - -[package.extras] -dev = ["bump2version", "transifex-client", "twine", "wheel"] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "2.0.0" -description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"}, - {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "2.0.0" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"}, - {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.1.0" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"}, - {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["html5lib", "pytest"] - -[[package]] -name = "sphinxcontrib-jquery" -version = "4.1" -description = "Extension to include jQuery on newer Sphinx releases" -optional = false -python-versions = ">=2.7" -groups = ["development"] -files = [ - {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, - {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, -] - -[package.dependencies] -Sphinx = ">=1.8" - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -description = "A sphinx extension which renders display math in HTML via JavaScript" -optional = false -python-versions = ">=3.5" -groups = ["development"] -files = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] - -[package.extras] -test = ["flake8", "mypy", "pytest"] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "2.0.0" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"}, - {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["defusedxml (>=0.7.1)", "pytest"] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "2.0.0" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"}, - {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"}, -] - -[package.extras] -lint = ["mypy", "ruff (==0.5.5)", "types-docutils"] -standalone = ["Sphinx (>=5)"] -test = ["pytest"] - -[[package]] -name = "starlette" -version = "1.3.1" -description = "The little ASGI library that shines." -optional = false -python-versions = ">=3.10" -groups = ["main", "development"] -files = [ - {file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"}, - {file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"}, -] - -[package.dependencies] -anyio = ">=3.6.2,<5" -typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} - -[package.extras] -full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] - -[[package]] -name = "statsmodels" -version = "0.14.6" -description = "Statistical computations and models for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "statsmodels-0.14.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4ff0649a2df674c7ffb6fa1a06bffdb82a6adf09a48e90e000a15a6aaa734b0"}, - {file = "statsmodels-0.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:109012088b3e370080846ab053c76d125268631410142daad2f8c10770e8e8d9"}, - {file = "statsmodels-0.14.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e93bd5d220f3cb6fc5fc1bffd5b094966cab8ee99f6c57c02e95710513d6ac3f"}, - {file = "statsmodels-0.14.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06eec42d682fdb09fe5d70a05930857efb141754ec5a5056a03304c1b5e32fd9"}, - {file = "statsmodels-0.14.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0444e88557df735eda7db330806fe09d51c9f888bb1f5906cb3a61fb1a3ed4a8"}, - {file = "statsmodels-0.14.6-cp310-cp310-win_amd64.whl", hash = "sha256:e83a9abe653835da3b37fb6ae04b45480c1de11b3134bd40b09717192a1456ea"}, - {file = "statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4"}, - {file = "statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6"}, - {file = "statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb"}, - {file = "statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca"}, - {file = "statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d"}, - {file = "statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7"}, - {file = "statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5"}, - {file = "statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c"}, - {file = "statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368"}, - {file = "statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d"}, - {file = "statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37"}, - {file = "statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f"}, - {file = "statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e"}, - {file = "statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71"}, - {file = "statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4"}, - {file = "statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589"}, - {file = "statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c"}, - {file = "statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233"}, - {file = "statsmodels-0.14.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:00781869991f8f02ad3610da6627fd26ebe262210287beb59761982a8fa88cae"}, - {file = "statsmodels-0.14.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:73f305fbf31607b35ce919fae636ab8b80d175328ed38fdc6f354e813b86ee37"}, - {file = "statsmodels-0.14.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e443e7077a6e2d3faeea72f5a92c9f12c63722686eb80bb40a0f04e4a7e267ad"}, - {file = "statsmodels-0.14.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3414e40c073d725007a6603a18247ab7af3467e1af4a5e5a24e4c27bc26673b4"}, - {file = "statsmodels-0.14.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a518d3f9889ef920116f9fa56d0338069e110f823926356946dae83bc9e33e19"}, - {file = "statsmodels-0.14.6-cp314-cp314-win_amd64.whl", hash = "sha256:151b73e29f01fe619dbce7f66d61a356e9d1fe5e906529b78807df9189c37721"}, - {file = "statsmodels-0.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4d0c1b0f9f6915619e2a0d3853e5763d4d66876892ad352e7d7b93a737556978"}, - {file = "statsmodels-0.14.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9e0fc891d6358bf376cc0ae1fee10a650478172ae9ba359daba1785fc496cd1a"}, - {file = "statsmodels-0.14.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f52ef0f0b63b8fd11e1ef1c2a1e73a410720b8715c9a83a26d733b6815597fe"}, - {file = "statsmodels-0.14.6-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b328eafa86a2a67303fdb1d25677d15b70cd2a5229aabec7670ec5ea840f1375"}, - {file = "statsmodels-0.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:3bef39f8587754f2d644b2e831e102fa08ace9a5a1af4b583b122e6fd3e083ab"}, - {file = "statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a"}, -] - -[package.dependencies] -numpy = ">=1.22.3,<3" -packaging = ">=21.3" -pandas = ">=1.4,<2.1.0 || >2.1.0" -patsy = ">=0.5.6" -scipy = ">=1.8,<1.9.2 || >1.9.2" - -[package.extras] -build = ["cython (>=3.0.10)"] -develop = ["colorama", "cython (>=3.0.10)", "cython (>=3.0.10,<4)", "flake8", "isort", "jinja2", "joblib", "matplotlib (>=3)", "pytest (>=7.3.0,<8)", "pytest-cov", "pytest-randomly", "pytest-xdist", "pywinpty ; os_name == \"nt\"", "setuptools_scm[toml] (>=8.0,<9.0)"] -docs = ["ipykernel", "jupyter_client", "matplotlib", "nbconvert", "nbformat", "numpydoc", "pandas-datareader", "sphinx"] - -[[package]] -name = "stevedore" -version = "5.8.0" -description = "Manage dynamic plugins for Python applications" -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "stevedore-5.8.0-py3-none-any.whl", hash = "sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b"}, - {file = "stevedore-5.8.0.tar.gz", hash = "sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715"}, -] - -[[package]] -name = "subword-nmt" -version = "0.3.8" -description = "Unsupervised Word Segmentation for Neural Machine Translation and Text Generation" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "subword_nmt-0.3.8-py3-none-any.whl", hash = "sha256:d22526b557752f35ac15e8ea384ea7773e50a51d966b8752d023d16cb87eac36"}, - {file = "subword_nmt-0.3.8.tar.gz", hash = "sha256:3964c66b37712ca1d9fb9a1a6ff7e57c9ab72d838813da3e9a1d4d4997f4fb75"}, -] - -[package.dependencies] -mock = "*" -tqdm = "*" - -[[package]] -name = "sympy" -version = "1.14.0" -description = "Computer algebra system (CAS) in Python" -optional = false -python-versions = ">=3.9" -groups = ["main", "torch-cuda"] -files = [ - {file = "sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"}, - {file = "sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517"}, -] - -[package.dependencies] -mpmath = ">=1.1.0,<1.4" - -[package.extras] -dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] - -[[package]] -name = "tensorboardx" -version = "2.6.5" -description = "TensorBoardX lets you watch Tensors Flow without Tensorflow" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "extra == \"multiprocessing\"" -files = [ - {file = "tensorboardx-2.6.5-py3-none-any.whl", hash = "sha256:c10b891d00af306537cb8b58a039b2ba41571f0da06f433a41c4ca8d6abe1373"}, - {file = "tensorboardx-2.6.5.tar.gz", hash = "sha256:ca176db3997ee8c07d2eb77381225956a3fd1c10c91beafab1f17069adc47017"}, -] - -[package.dependencies] -numpy = "*" -packaging = "*" -protobuf = ">=3.20" - -[[package]] -name = "threadpoolctl" -version = "3.6.0" -description = "threadpoolctl" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb"}, - {file = "threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e"}, -] - -[[package]] -name = "tokenize-rt" -version = "6.2.0" -description = "A wrapper around the stdlib `tokenize` which roundtrips." -optional = false -python-versions = ">=3.9" -groups = ["development"] -files = [ - {file = "tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44"}, - {file = "tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6"}, -] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["main"] -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - -[[package]] -name = "tomlkit" -version = "0.15.0" -description = "Style preserving TOML library" -optional = false -python-versions = ">=3.9" -groups = ["main", "development"] -files = [ - {file = "tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738"}, - {file = "tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3"}, -] - -[[package]] -name = "torch" -version = "2.12.1" -description = "Tensors and Dynamic neural networks in Python with strong GPU acceleration" -optional = false -python-versions = ">=3.10" -groups = ["main", "torch-cuda"] -files = [ - {file = "torch-2.12.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ec56e82be6a8b0c036771a77f7d32ad3c299770571af9815b3dafe61434389d5"}, - {file = "torch-2.12.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:42cd7339bf266f14944710e8274be63e7e012bb937834a8d85a8327a9860eba6"}, - {file = "torch-2.12.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a7817f0f89a796d9de239d06f69faf5d7e19a6a5db6710a5ead777c912f9f50a"}, - {file = "torch-2.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:2af3d9cc866e0a15ae7635ff0a9c61d6624a353ad657f5bcd8d86c26cdc64693"}, - {file = "torch-2.12.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ef81f503912effea2ce3d9b12a2e3a6ed488943e91271c90c7a829f60baf6aa2"}, - {file = "torch-2.12.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:107df6888624bdea41508f9aeb6149d9333c737a5530ceecb56c904e811369ae"}, - {file = "torch-2.12.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6e29e7e74d05bda7d955c75e99459f878ebd970ef851b4057edbd3b34a5eb4a3"}, - {file = "torch-2.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:a513506cfda3c1c78dabeb6574c1597538c0254b3d39af174dde35d8177f4ce3"}, - {file = "torch-2.12.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:d2dd0f2c5f7ccbddaf34cade0deaf476808368f902b9cdb7f36a2ab42301bc0e"}, - {file = "torch-2.12.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:2de4e19b88a481482c6c75291f2d6a52eda3ce51f311b29aa9b68499c830c07c"}, - {file = "torch-2.12.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:649e4ced014ba646f76f8cb9c9726735a6323eb321b7919f942790a923f90921"}, - {file = "torch-2.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:e86550597877fb272ddc52db2f85b82cb601ea7bd932576a0340152cae2200b3"}, - {file = "torch-2.12.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c75e93173c700bccd6bfcc4a9d19ce242ab6dacd1f1781483027a16239b9e650"}, - {file = "torch-2.12.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fcb61ccd20784b62bdd78ec84238a5cfb383b4994902e03bac95505ab360884c"}, - {file = "torch-2.12.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:f4afc8083dff08719edbea346644476e3cec0cf40ebe256be0ee5d5b7c7e8c0d"}, - {file = "torch-2.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:f92609e3b3ce72f25e2eb780d043ced2480c1a86c47c852604fc7a9108648386"}, - {file = "torch-2.12.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e9b6f7d2dd66ea87a3ae620069d31335d594c06effb1a383bdd21cfe61e44ece"}, - {file = "torch-2.12.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:7973ccd3d2cd35c74449213f7bded199bec6c6247e705cbeda7407af79703d91"}, - {file = "torch-2.12.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:c64ac4aac16be5e296dcd912305605804b203333c690bf98c55bc09494ee92ad"}, - {file = "torch-2.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:f6dc4caf7eb4adb38a2d9f536b51db56310fdd1254e69a2d96767e1367c892b3"}, - {file = "torch-2.12.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:2afbb2bdaa8a95040e733f05492ddf133c3967c9b7ce0abd218d704b6cab437d"}, - {file = "torch-2.12.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:97eba061fcb042fed191400b15568990073d67eaacaa6ee9b7ca01dd8b790fe9"}, - {file = "torch-2.12.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:3867b861391701012adb2df93360efb88494dca245a185e3bb7624495cfe3f33"}, - {file = "torch-2.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dd15595f8fc764cffde8c6361a3beb6ef69a028c851b1b3e70e077f615980d4e"}, -] - -[package.dependencies] -cuda-bindings = {version = ">=13.0.3,<14", markers = "platform_system == \"Linux\""} -cuda-toolkit = {version = "13.0.2", extras = ["cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], markers = "platform_system == \"Linux\""} -filelock = "*" -fsspec = ">=0.8.5" -jinja2 = "*" -networkx = ">=2.5.1" -nvidia-cublas = {version = ">=13.1.0.3,<=13.1.1.3", markers = "platform_system == \"Linux\""} -nvidia-cudnn-cu13 = {version = "9.20.0.48", markers = "platform_system == \"Linux\""} -nvidia-cusparselt-cu13 = {version = "0.8.1", markers = "platform_system == \"Linux\""} -nvidia-nccl-cu13 = {version = "2.29.7", markers = "platform_system == \"Linux\""} -nvidia-nvshmem-cu13 = {version = "3.4.5", markers = "platform_system == \"Linux\""} -setuptools = "<82" -sympy = ">=1.13.3" -triton = {version = "3.7.1", markers = "platform_system == \"Linux\""} -typing-extensions = ">=4.10.0" - -[package.extras] -opt-einsum = ["opt-einsum (>=3.3)"] -optree = ["optree (>=0.13.0)"] -pyyaml = ["pyyaml"] - -[[package]] -name = "torch-geometric" -version = "2.8.0" -description = "Graph Neural Network Library for PyTorch" -optional = false -python-versions = ">=3.10" -groups = ["main", "torch-cuda"] -files = [ - {file = "torch_geometric-2.8.0-py3-none-any.whl", hash = "sha256:1f62e415a2e9ee69d34617d1b0b230e9d9040f51809b96e801e742770fd4dada"}, -] - -[package.dependencies] -aiohttp = "*" -fsspec = "*" -jinja2 = "*" -numpy = "*" -psutil = ">=5.8.0" -pyparsing = "*" -requests = "*" -tqdm = "*" -xxhash = "*" - -[package.extras] -benchmark = ["matplotlib", "networkx", "pandas", "protobuf (<4.21)", "wandb"] -dev = ["ipython", "matplotlib-inline", "pre-commit", "torch_geometric[test]"] -full = ["ase", "captum (<0.7.0)", "graphviz", "h5py", "matplotlib", "networkx", "numba (<0.60.0)", "opt_einsum", "pandas", "pynndescent", "pytorch-memlab", "rdflib", "rdkit", "scikit-image", "scikit-learn", "scipy", "statsmodels", "sympy", "tabulate", "torch_geometric[graphgym,modelhub]", "torchmetrics", "trimesh"] -graphgym = ["protobuf (<4.21)", "pytorch-lightning", "yacs"] -modelhub = ["huggingface_hub"] -rag = ["PyYAML", "accelerate", "datasets", "faiss-cpu", "json-repair", "langgraph", "openai", "pandas", "pcst_fast", "peft", "sentencepiece", "torchmetrics", "transformers"] -test = ["onnx", "onnxruntime", "onnxscript", "pytest", "pytest-cov"] - -[[package]] -name = "torchmetrics" -version = "1.9.0" -description = "PyTorch native Metrics" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "torchmetrics-1.9.0-py3-none-any.whl", hash = "sha256:bfdcbff3dd1d96b3374bb2496eb39f23c4b28b8a845b6a18c313688e0d2d9ca1"}, - {file = "torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa"}, -] - -[package.dependencies] -lightning-utilities = ">=0.15.3" -numpy = ">1.20.0" -packaging = ">17.1" -torch = ">=2.0.0" - -[package.extras] -all = ["SciencePlots (>=2.0.0)", "einops (>=0.7.0)", "einops (>=0.7.0)", "gammatone (>=1.0.0)", "ipadic (>=1.0.0)", "librosa (>=0.10.0)", "matplotlib (>=3.6.0)", "mecab-python3 (>=1.0.6)", "mypy (==1.17.1)", "nltk (>3.8.1)", "onnxruntime (>=1.12.0)", "pesq (>=0.0.4)", "piq (<=0.8.0)", "pycocotools (>2.0.0)", "pystoi (>=0.4.0)", "regex (>=2021.9.24)", "requests (>=2.22.0)", "scipy (>1.0.0)", "sentencepiece (>=0.2.0)", "timm (>=0.9.0)", "torch (==2.8.0)", "torch-fidelity (<=0.4.0)", "torch_linear_assignment (>=0.0.2)", "torchaudio (>=2.0.1)", "torchvision (>=0.15.1)", "torchvision (>=0.15.1)", "tqdm (<4.68.0)", "transformers (>=4.43.0)", "transformers (>=4.43.0)", "types-PyYAML", "types-emoji", "types-protobuf", "types-requests", "types-setuptools", "types-six", "types-tabulate", "vmaf-torch (>=1.1.0)"] -audio = ["gammatone (>=1.0.0)", "librosa (>=0.10.0)", "onnxruntime (>=1.12.0)", "pesq (>=0.0.4)", "pystoi (>=0.4.0)", "requests (>=2.22.0)", "torchaudio (>=2.0.1)"] -clustering = ["torch_linear_assignment (>=0.0.2)"] -detection = ["pycocotools (>2.0.0)", "torchvision (>=0.15.1)"] -dev = ["PyTDC (==0.4.1) ; platform_system == \"Windows\" and python_version < \"3.12\"", "SciencePlots (>=2.0.0)", "aeon (>=1.0.0) ; python_version > \"3.10\"", "bert_score (==0.3.13)", "dists-pytorch (==0.1)", "dython (==0.7.9)", "einops (>=0.7.0)", "einops (>=0.7.0)", "fairlearn", "fast-bss-eval (>=0.1.0)", "faster-coco-eval (>=1.6.3)", "gammatone (>=1.0.0)", "huggingface-hub (<0.35)", "ipadic (>=1.0.0)", "jiwer (>=2.3.0)", "kornia (>=0.6.7)", "librosa (>=0.10.0)", "lpips (<=0.1.4)", "matplotlib (>=3.6.0)", "mecab-ko (>=1.0.0,<1.1.0) ; python_version < \"3.12\"", "mecab-ko-dic (>=1.0.0) ; python_version < \"3.12\"", "mecab-python3 (>=1.0.6)", "mir-eval (>=0.6)", "monai (==1.4.0)", "mypy (==1.17.1)", "netcal (>1.0.0)", "nltk (>3.8.1)", "numpy (<2.4.0)", "onnxruntime (>=1.12.0)", "pandas (>1.4.0)", "permetrics (==2.0.0)", "pesq (>=0.0.4)", "piq (<=0.8.0)", "properscoring (==0.1)", "pycocotools (>2.0.0)", "pystoi (>=0.4.0)", "pytorch-msssim (==1.0.0)", "regex (>=2021.9.24)", "requests (>=2.22.0)", "rouge-score (>0.1.0)", "sacrebleu (>=2.3.0)", "scikit-image (>=0.19.0)", "scipy (>1.0.0)", "scipy (>1.0.0)", "sentencepiece (>=0.2.0)", "setuptools (<82.0.0)", "sewar (>=0.4.4)", "statsmodels (>0.13.5)", "timm (>=0.9.0)", "torch (==2.8.0)", "torch-fidelity (<=0.4.0)", "torch_complex (<0.5.0)", "torch_linear_assignment (>=0.0.2)", "torchaudio (>=2.0.1)", "torchvision (>=0.15.1)", "torchvision (>=0.15.1)", "tqdm (<4.68.0)", "transformers (>=4.43.0)", "transformers (>=4.43.0)", "types-PyYAML", "types-emoji", "types-protobuf", "types-requests", "types-setuptools", "types-six", "types-tabulate", "vmaf-torch (>=1.1.0)"] -image = ["scipy (>1.0.0)", "torch-fidelity (<=0.4.0)", "torchvision (>=0.15.1)"] -multimodal = ["einops (>=0.7.0)", "piq (<=0.8.0)", "timm (>=0.9.0)", "transformers (>=4.43.0)"] -text = ["ipadic (>=1.0.0)", "mecab-python3 (>=1.0.6)", "nltk (>3.8.1)", "regex (>=2021.9.24)", "sentencepiece (>=0.2.0)", "tqdm (<4.68.0)", "transformers (>=4.43.0)"] -typing = ["mypy (==1.17.1)", "torch (==2.8.0)", "types-PyYAML", "types-emoji", "types-protobuf", "types-requests", "types-setuptools", "types-six", "types-tabulate"] -video = ["einops (>=0.7.0)", "vmaf-torch (>=1.1.0)"] -visual = ["SciencePlots (>=2.0.0)", "matplotlib (>=3.6.0)"] - -[[package]] -name = "tornado" -version = "6.5.7" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "sys_platform != \"emscripten\"" -files = [ - {file = "tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163"}, - {file = "tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100"}, - {file = "tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972"}, - {file = "tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b"}, - {file = "tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92"}, - {file = "tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5"}, - {file = "tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4"}, - {file = "tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4"}, - {file = "tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796"}, - {file = "tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2"}, -] - -[[package]] -name = "tqdm" -version = "4.68.3" -description = "Fast, Extensible Progress Meter" -optional = false -python-versions = ">=3.8" -groups = ["main", "torch-cuda"] -files = [ - {file = "tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03"}, - {file = "tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -discord = ["envwrap", "requests"] -notebook = ["ipywidgets (>=6)"] -slack = ["envwrap", "slack-sdk"] -telegram = ["envwrap", "requests"] - -[[package]] -name = "triton" -version = "3.7.1" -description = "A language and compiler for custom Deep Learning operations" -optional = false -python-versions = "<3.15,>=3.10" -groups = ["main", "torch-cuda"] -markers = "platform_system == \"Linux\"" -files = [ - {file = "triton-3.7.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3daf64305d6cea88d3334c65ebc9bcd0c64c9564a977084366aa768d57cbcf64"}, - {file = "triton-3.7.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee89fbf782ec2ad50391dd1cf26cbea4f4467154c37f4773026da8fc31c0f58e"}, - {file = "triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6"}, - {file = "triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5"}, - {file = "triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1"}, - {file = "triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728"}, - {file = "triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a"}, - {file = "triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb"}, - {file = "triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa"}, - {file = "triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2"}, - {file = "triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7"}, - {file = "triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68"}, -] - -[package.extras] -build = ["cmake (>=3.20,<4.0)", "lit"] -tests = ["autopep8", "isort", "llnl-hatchet", "numpy", "pytest", "pytest-forked", "pytest-xdist", "scipy (>=1.7.1)"] -tutorials = ["matplotlib", "pandas", "tabulate"] - -[[package]] -name = "trove-classifiers" -version = "2026.6.1.19" -description = "Canonical source for classifiers on PyPI (pypi.org)." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3"}, - {file = "trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745"}, -] - -[[package]] -name = "typer" -version = "0.26.8" -description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c"}, - {file = "typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e"}, -] - -[package.dependencies] -annotated-doc = ">=0.0.2" -colorama = {version = "*", markers = "platform_system == \"Windows\""} -rich = ">=13.8.0" -shellingham = ">=1.3.0" - -[[package]] -name = "typing-extensions" -version = "4.15.0" -description = "Backported and Experimental Type Hints for Python 3.9+" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev", "development", "torch-cuda"] -files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, -] -markers = {development = "python_version < \"3.13\""} - -[[package]] -name = "typing-inspection" -version = "0.4.2" -description = "Runtime typing introspection tools" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, -] - -[package.dependencies] -typing-extensions = ">=4.12.0" - -[[package]] -name = "tzdata" -version = "2026.2" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -groups = ["main"] -files = [ - {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, - {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, -] - -[[package]] -name = "urllib3" -version = "2.7.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.10" -groups = ["main", "development", "torch-cuda"] -files = [ - {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, - {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, -] - -[package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "uvicorn" -version = "0.49.0" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f"}, - {file = "uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"}, -] - -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" - -[package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.8.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"] - -[[package]] -name = "virtualenv" -version = "21.5.1" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.9" -groups = ["main", "development"] -files = [ - {file = "virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783"}, - {file = "virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} -platformdirs = ">=3.9.1,<5" -python-discovery = ">=1.4.2" - -[[package]] -name = "wandb" -version = "0.28.0" -description = "A CLI and library for interacting with the Weights & Biases API." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "wandb-0.28.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:c3dab1205a5aca4abbad1eca08902cdba86add0edfa83d8d61b4429d0e79fa87"}, - {file = "wandb-0.28.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:ae255da18726ee8e731ef82cbc85035b901a28ae14cf91604c361b44b8d44ce0"}, - {file = "wandb-0.28.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6dbcba12ab168aa37561f2f32dcdef8713495fc25fa7d30fdc9bfb37989694dd"}, - {file = "wandb-0.28.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:325b2d0bd88be6eda5db10542499bad3710927f2569c81a84dc5eeaffc76825c"}, - {file = "wandb-0.28.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8954bc1c62ae43914dce2bebfd1d9957f72350f8fbb78e5cdfe2ca9b6be8a7b8"}, - {file = "wandb-0.28.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9fec6c908554c2dad33110c1312bc3028cc2e430f0679f16b84f82c8ea801e3b"}, - {file = "wandb-0.28.0-py3-none-win32.whl", hash = "sha256:8834ef3a7c8c43b701654162783caa7ad37af48a0ff06fc35d0d65a411f76ccd"}, - {file = "wandb-0.28.0-py3-none-win_amd64.whl", hash = "sha256:ac1f82292e2da4f98297b78c3a46726b3a6c5734ecb75fc39b8db2c8a4989159"}, - {file = "wandb-0.28.0-py3-none-win_arm64.whl", hash = "sha256:c5b0faf1b84cf79ebabed77538c1940a4c6053e815f767a4004e877a1354bed1"}, - {file = "wandb-0.28.0.tar.gz", hash = "sha256:b20e5af0fe80e2e2a466b0466a1d60cedcc578dce0f036eca04f4a0adcad95b6"}, -] - -[package.dependencies] -click = ">=8.2.0" -gitpython = ">=1.0.0,<3.1.29 || >3.1.29" -packaging = "*" -platformdirs = "*" -protobuf = ">4.21.0,<5.28.0 || >5.28.0,<5.29.0 || >5.29.0,<8" -pydantic = ">=2.6,<3" -pyyaml = "*" -requests = ">=2.0.0,<3" -sentry-sdk = ">=2.0.0" -typing-extensions = ">=4.8,<5" - -[package.extras] -aws = ["boto3", "botocore (>=1.5.76)"] -azure = ["azure-identity", "azure-storage-blob"] -eval-table = ["weave (>=0.52.41)"] -eval-table-video-support = ["weave[video-support] (>=0.52.41)"] -gcp = ["google-cloud-storage"] -kubeflow = ["google-cloud-storage", "kubernetes", "minio", "sh"] -launch = ["awscli", "azure-containerregistry", "azure-identity", "azure-storage-blob", "boto3", "botocore (>=1.5.76)", "chardet", "google-auth", "google-cloud-aiplatform", "google-cloud-artifact-registry", "google-cloud-compute", "google-cloud-storage", "iso8601", "jsonschema", "kubernetes", "kubernetes-asyncio", "nbconvert", "nbformat", "optuna", "pydantic", "pyyaml (>=6.0.0)", "tomli", "tornado (>=6.5.0)", "typing-extensions"] -media = ["bokeh", "imageio (>=2.28.1)", "moviepy (>=1.0.0)", "numpy", "pillow", "plotly (>=5.18.0)", "rdkit", "soundfile"] -models = ["cloudpickle"] -sandbox = ["cwsandbox[cli] (>=0.20.0)"] -sweeps = ["sweeps (>=0.2.0)"] -workspaces = ["wandb-workspaces"] - -[[package]] -name = "watchfiles" -version = "1.2.0" -description = "Simple, modern and high performance file watching and code reload in python." -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9"}, - {file = "watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4"}, - {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631"}, - {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994"}, - {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e"}, - {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19"}, - {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8"}, - {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07"}, - {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551"}, - {file = "watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310"}, - {file = "watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df"}, - {file = "watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1"}, - {file = "watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d"}, - {file = "watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201"}, - {file = "watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5"}, - {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a"}, - {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1"}, - {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717"}, - {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b"}, - {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5"}, - {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e"}, - {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165"}, - {file = "watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6"}, - {file = "watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5"}, - {file = "watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8"}, - {file = "watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22"}, - {file = "watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7"}, - {file = "watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26"}, - {file = "watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c"}, - {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc"}, - {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0"}, - {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c"}, - {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01"}, - {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8"}, - {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5"}, - {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d"}, - {file = "watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c"}, - {file = "watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906"}, - {file = "watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898"}, - {file = "watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379"}, - {file = "watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5"}, - {file = "watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98"}, - {file = "watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44"}, - {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658"}, - {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb"}, - {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f"}, - {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0"}, - {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5"}, - {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71"}, - {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3"}, - {file = "watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0"}, - {file = "watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427"}, - {file = "watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799"}, - {file = "watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9"}, - {file = "watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077"}, - {file = "watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08"}, - {file = "watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9"}, - {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4"}, - {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55"}, - {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925"}, - {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4"}, - {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2"}, - {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9"}, - {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa"}, - {file = "watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44"}, - {file = "watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72"}, - {file = "watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4"}, - {file = "watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281"}, - {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d"}, - {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e"}, - {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242"}, - {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add"}, - {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f"}, - {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7"}, - {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e"}, - {file = "watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06"}, - {file = "watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba"}, - {file = "watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7"}, - {file = "watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103"}, - {file = "watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3"}, - {file = "watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2"}, - {file = "watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28"}, - {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831"}, - {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33"}, - {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4"}, - {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b"}, - {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666"}, - {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925"}, - {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b"}, - {file = "watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30"}, - {file = "watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5"}, - {file = "watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374"}, - {file = "watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65"}, - {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69"}, - {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579"}, - {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7"}, - {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2"}, - {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6"}, - {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4"}, - {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488"}, - {file = "watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb"}, - {file = "watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377"}, - {file = "watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2"}, - {file = "watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db"}, - {file = "watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7"}, - {file = "watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0"}, - {file = "watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838"}, -] - -[package.dependencies] -anyio = ">=3.0.0" - -[[package]] -name = "websockets" -version = "16.0" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.10" -groups = ["development"] -files = [ - {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, - {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, - {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, - {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, - {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, - {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, - {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, - {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, - {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, - {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, - {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, - {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, - {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, - {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, - {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, - {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, - {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, - {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, - {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, - {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, - {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, - {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, - {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, - {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, - {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, - {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, - {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, - {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, - {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, - {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, - {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, - {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, - {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, - {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, - {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, - {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, - {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, - {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, - {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, - {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, - {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, - {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, - {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, - {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, - {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, - {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, - {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, - {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, - {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, - {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, - {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, - {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, - {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, - {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, - {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, - {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, - {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, - {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, - {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, - {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, - {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, -] - -[[package]] -name = "xattr" -version = "1.3.0" -description = "Python wrapper for extended filesystem attributes" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "sys_platform == \"darwin\"" -files = [ - {file = "xattr-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a80c4617e08670cdc3ba71f1dbb275c1627744c5c3641280879cb3bc95a07237"}, - {file = "xattr-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51cdaa359f5cd2861178ae01ea3647b56dbdfd98e724a8aa3c04f77123b78217"}, - {file = "xattr-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2fea070768d7d2d25797817bea93bf0a6fda6449e88cfee8bb3d75de9ed11c7b"}, - {file = "xattr-1.3.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69bca34be2d7a928389aff4e32f27857e1c62d04c91ec7c1519b1636870bd58f"}, - {file = "xattr-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05f8e068409742d246babba60cff8310b2c577745491f498b08bf068e0c867a3"}, - {file = "xattr-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bbd06987102bc11f5cbd08b15d1029832b862cf5bc61780573fc0828812f01ca"}, - {file = "xattr-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b8589744116d2c37928b771c50383cb281675cd6dcfd740abfab6883e3d4af85"}, - {file = "xattr-1.3.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:331a51bf8f20c27822f44054b0d760588462d3ed472d5e52ba135cf0bea510e8"}, - {file = "xattr-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:196360f068b74fa0132a8c6001ce1333f095364b8f43b6fd8cdaf2f18741ef89"}, - {file = "xattr-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:405d2e4911d37f2b9400fa501acd920fe0c97fe2b2ec252cb23df4b59c000811"}, - {file = "xattr-1.3.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ae3a66ae1effd40994f64defeeaa97da369406485e60bfb421f2d781be3b75d"}, - {file = "xattr-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:69cd3bfe779f7ba87abe6473fdfa428460cf9e78aeb7e390cfd737b784edf1b5"}, - {file = "xattr-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c5742ca61761a99ae0c522f90a39d5fb8139280f27b254e3128482296d1df2db"}, - {file = "xattr-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a04ada131e9bdfd32db3ab1efa9f852646f4f7c9d6fde0596c3825c67161be3"}, - {file = "xattr-1.3.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dd4e63614722d183e81842cb237fd1cc978d43384166f9fe22368bfcb187ebe5"}, - {file = "xattr-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:995843ef374af73e3370b0c107319611f3cdcdb6d151d629449efecad36be4c4"}, - {file = "xattr-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa23a25220e29d956cedf75746e3df6cc824cc1553326d6516479967c540e386"}, - {file = "xattr-1.3.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b4345387087fffcd28f709eb45aae113d911e1a1f4f0f70d46b43ba81e69ccdd"}, - {file = "xattr-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe92bb05eb849ab468fe13e942be0f8d7123f15d074f3aba5223fad0c4b484de"}, - {file = "xattr-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c42ef5bdac3febbe28d3db14d3a8a159d84ba5daca2b13deae6f9f1fc0d4092"}, - {file = "xattr-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2aaa5d66af6523332189108f34e966ca120ff816dfa077ca34b31e6263f8a236"}, - {file = "xattr-1.3.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:937d8c91f6f372788aff8cc0984c4be3f0928584839aaa15ff1c95d64562071c"}, - {file = "xattr-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e470b3f15e9c3e263662506ff26e73b3027e1c9beac2cbe9ab89cad9c70c0495"}, - {file = "xattr-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f2238b2a973fcbf5fefa1137db97c296d27f4721f7b7243a1fac51514565e9ec"}, - {file = "xattr-1.3.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f32bb00395371f4a3bed87080ae315b19171ba114e8a5aa403a2c8508998ce78"}, - {file = "xattr-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:78df56bfe3dd4912548561ed880225437d6d49ef082fe6ccd45670810fa53cfe"}, - {file = "xattr-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:864c34c14728f21c3ef89a9f276d75ae5e31dd34f48064e0d37e4bf0f671fc6e"}, - {file = "xattr-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1fd185b3f01121bd172c98b943f9341ca3b9ea6c6d3eb7fe7074723614d959ff"}, - {file = "xattr-1.3.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:630c85020282bd0bcb72c3d031491c4e91d7f29bb4c094ebdfb9db51375c5b07"}, - {file = "xattr-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:95f1e14a4d9ca160b4b78c527bf2bac6addbeb0fd9882c405fc0b5e3073a8752"}, - {file = "xattr-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:88557c0769f64b1d014aada916c9630cfefa38b0be6c247eae20740d2d8f7b47"}, - {file = "xattr-1.3.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6992eb5da32c0a1375a9eeacfab15c66eebc8bd34be63ebd1eae80cc2f8bf03"}, - {file = "xattr-1.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da5954424099ca9d402933eaf6112c29ddde26e6da59b32f0bf5a4e35eec0b28"}, - {file = "xattr-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:726b4d0b66724759132cacdcd84a5b19e00b0cdf704f4c2cf96d0c08dc5eaeb5"}, - {file = "xattr-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:928c49ceb0c70fc04732e46fa236d7c8281bfc3db1b40875e5f548bb14d2668c"}, - {file = "xattr-1.3.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f3bef26fd2d5d7b17488f4cc4424a69894c5a8ed71dd5f657fbbf69f77f68a51"}, - {file = "xattr-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:64f1fb511f8463851e0d97294eb0e0fde54b059150da90582327fb43baa1bb92"}, - {file = "xattr-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1e6c216927b16fd4b72df655d5124b69b2a406cb3132b5231179021182f0f0d1"}, - {file = "xattr-1.3.0-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c0d9ab346cdd20539afddf2f9e123efee0fe8d54254d9fc580b4e2b4e6d77351"}, - {file = "xattr-1.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2c5e7ba0e893042deef4e8638db7a497680f587ac7bd6d68925f29af633dfa6b"}, - {file = "xattr-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e0dabb39596d8d7b83d6f9f7fa30be68cf15bfb135cb633e2aad9887d308a32"}, - {file = "xattr-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5eeaa944516b7507ec51456751334b4880e421de169bbd067c4f32242670d606"}, - {file = "xattr-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:03712f84e056dcd23c36db03a1f45417a26eef2c73d47c2c7d425bf932601587"}, - {file = "xattr-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45f85233a51c71659969ce364abe6bd0c9048a302b7fcdbea675dc63071e47ff"}, - {file = "xattr-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fefcf20d040e79ec3bf6e7dc0fdcfd972f70f740d5a69ed67b20c699bb9cea"}, - {file = "xattr-1.3.0-cp39-cp39-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9e68a02adde8a5f8675be5e8edc837eb6fdbe214a6ee089956fae11d633c0e51"}, - {file = "xattr-1.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:50c12d92f5214b0416cf4b4fafcd02dca5434166657553b74b8ba6abc66cb4b4"}, - {file = "xattr-1.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c69999ed70411ac2859f1f8c918eb48a6fd2a71ef41dc03ee846f69e2200bb2"}, - {file = "xattr-1.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b3cf29da6840eb94b881eab692ae83b1421c9c15a0cd92ffb97a0696ceac8cac"}, - {file = "xattr-1.3.0.tar.gz", hash = "sha256:30439fabd7de0787b27e9a6e1d569c5959854cb322f64ce7380fedbfa5035036"}, -] - -[package.dependencies] -cffi = ">=1.16.0" - -[package.extras] -test = ["pytest"] - -[[package]] -name = "xgboost" -version = "3.2.0" -description = "XGBoost Python Package" -optional = true -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version == \"3.11\" and extra == \"xgboost\"" -files = [ - {file = "xgboost-3.2.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:2f661966d3e322536d9c448090a870fcba1e32ee5760c10b7c46bac7a342079a"}, - {file = "xgboost-3.2.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:eabbd40d474b8dbf6cb3536325f9150b9e6f0db32d18de9914fb3227d0bef5b7"}, - {file = "xgboost-3.2.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:852eabc6d3b3702a59bf78dbfdcd1cb9c4d3a3b6e5ed1f8781d8b9512354fdd2"}, - {file = "xgboost-3.2.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:99b4a6bbcb47212fec5cf5fbe12347215f073c08967431b0122cfbd1ee70312c"}, - {file = "xgboost-3.2.0-py3-none-win_amd64.whl", hash = "sha256:0d169736fd836fc13646c7ab787167b3a8110351c2c6bc770c755ee1618f0442"}, - {file = "xgboost-3.2.0.tar.gz", hash = "sha256:99b0e9a2a64896cdaf509c5e46372d336c692406646d20f2af505003c0c5d70d"}, -] - -[package.dependencies] -numpy = "*" -nvidia-nccl-cu12 = {version = "*", markers = "platform_system == \"Linux\""} -scipy = "*" - -[package.extras] -dask = ["dask", "distributed", "pandas"] -pandas = ["pandas (>=1.2)"] -plotting = ["graphviz", "matplotlib"] -pyspark = ["cloudpickle", "pyspark (>=3.4)", "scikit-learn"] -scikit-learn = ["scikit-learn"] - -[[package]] -name = "xgboost" -version = "3.3.0" -description = "XGBoost Python Package" -optional = true -python-versions = ">=3.12" -groups = ["main"] -markers = "python_version >= \"3.12\" and extra == \"xgboost\"" -files = [ - {file = "xgboost-3.3.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:07688a377046b8640897b62421150bf73c6cc7101823474ec6ad08b93290f587"}, - {file = "xgboost-3.3.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:af7cea10f418b7c251ddc8da440f57bdab2990b5fc9f74a35a92b0f150ea287d"}, - {file = "xgboost-3.3.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:624a83aeb1e7ba081719795db179f4ce6fff12e79de05cd9baf15ee48fd22f0e"}, - {file = "xgboost-3.3.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f59edaf28eccd1c519788607c72ed907ee6cedfa933d706620bc1612d24b354e"}, - {file = "xgboost-3.3.0-py3-none-win_amd64.whl", hash = "sha256:b06057f6a018fc04e6b3e0c15568ca636b8151a5b5f333478e500fcaf4fc7594"}, - {file = "xgboost-3.3.0.tar.gz", hash = "sha256:58bcb8a4cace648cdab7b94fa4f16d2c9ff26d90dd4d26907168106fa06d8746"}, -] - -[package.dependencies] -numpy = "*" -nvidia-nccl-cu12 = {version = "*", markers = "platform_system == \"Linux\""} -scipy = "*" - -[package.extras] -dask = ["dask", "distributed", "pandas"] -pandas = ["pandas (>=1.2)"] -plotting = ["graphviz", "matplotlib"] -pyspark = ["cloudpickle", "pyspark (>=4.0)", "scikit-learn"] -scikit-learn = ["scikit-learn"] - -[[package]] -name = "xxhash" -version = "3.8.0" -description = "Python binding for xxHash" -optional = false -python-versions = ">=3.8" -groups = ["main", "torch-cuda"] -files = [ - {file = "xxhash-3.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2289857ab90ebb2408d4ac2b7cf7e9ff29bba9d2cb21020c9d11fbbaef78eea"}, - {file = "xxhash-3.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d211cfa927a107df09359d1f31070883a11121ddc88fd6dd27eda3a497a88f3d"}, - {file = "xxhash-3.8.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba02f4cc4e71e1315ecac0468189b49bf3970da05ddf0b6965b4a9b1fe147e62"}, - {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:342d1a6f161741f8612dc38d940ec0019ae3362c0ede2d16554c1b4e3f1d5444"}, - {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75feec84a48cafd3b2446cb41910bebaf9a8150e2313c1f42887435818fb7b4c"}, - {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e8f6cc0cc24283d98e9c742a0f0a5ded7a810abc4038b9e885e419fcd44e43"}, - {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:73d04a4520cc7313acf4ff2122f783056d0592c71fc3a59e90fe0baeb499d124"}, - {file = "xxhash-3.8.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5a7fdfde5022f5000c8e6565db954580d19a8aa497ef80875f461e4546ed182"}, - {file = "xxhash-3.8.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a667f0dd160ec0ff6dddf42f2d75ad82660074285855f6037d6ecb57d40d0f8"}, - {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aaaf53eb633205f01bb5fb807f6244bd34af121bfb1e21eedc925374aff5723e"}, - {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:71b2e99a02fd5275b7ecab0b01130395beed4c6f027b6ce9f0730025634e7091"}, - {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b25437ffd781d4cb98acef87f4bc32e27682f603ffd27ed5962948b516e777ff"}, - {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0ee773fd6c211b3b0134ee5d6fd6348411bd7bd79cdb4151d0aaf732179571"}, - {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:06c74e537f45c2f71010738d4d20741186cac29a035ec5c1c621c723d656c2fd"}, - {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:718162a608eb85a22470725f95d63d834b1d7db98a2008b10309cd5a552d91ad"}, - {file = "xxhash-3.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:934cd5008d86e201818ca4416a4202039ea29edd89047166fea5c49999677bea"}, - {file = "xxhash-3.8.0-cp310-cp310-win32.whl", hash = "sha256:1f2c243a385e2c2ce72f5b7d68f3a621cc7d2ee2d0f35e0ca6bf5427ef1922a4"}, - {file = "xxhash-3.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb4996d43a42d825e2aa6f2b6a978b2a7779397b6a28e4fab5eb9505457023e4"}, - {file = "xxhash-3.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:b3a79d694adcfd70d118c73d244eaece7f5f5ab424feb44573bd1d377e1bf0ea"}, - {file = "xxhash-3.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:08c34553cd7ceb3bfcfca344dc70305a45430429b5d58a67750f2a58364f638f"}, - {file = "xxhash-3.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:842d147983110e5a4f533f98f4f5bc851a08c7ca00aaa30649e8d5f9a6d4e47a"}, - {file = "xxhash-3.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:37c9943e18f569f76a8b7d5d01bfe0716f7762c396096ceb42a47eb3d5ecf641"}, - {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21f6797afdc7abb0ffae059a0d1619c84a5368115bc0abd48f9803ab56a5d35e"}, - {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5875d99d3540367d43779551dd22c813420b84a103e418d791095b9808fdca57"}, - {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1a54ad5a2a96cdf1ee7a935d38bc63daa6095530095a916f644f1ab76604ced5"}, - {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b32e50dd85f0b67b2b95eb59cd3242052f6b27b70e9e73b27629686c592e3ea3"}, - {file = "xxhash-3.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4208fb85c950ddf7118b040bca15179c3bf9b7eb8bebe5e6ef067fc8af16a7"}, - {file = "xxhash-3.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9f17e09b035f2a0139536da53deb392b62ee259dc2a2189be12b06a7dd50489b"}, - {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7d6dbb976d6e3b3be51bad16b13de7f4980e6aebd0aa51c5a14dfcc0fedd495e"}, - {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:281897e5c516769694c999f5c50fd1e9acb27acbff187282a8ac77c38b6a9be5"}, - {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8fba3d08c246201a1a0a6cece53a0b3b0890fc16adbe1edb245fcfcbf4eb0ce2"}, - {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:14ebc1559e8a9a481d0d5506b87678942fcdfa794d4aa55cdd2a0fb175d4245a"}, - {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5e7a3e3bbe3a56bff70acc9b72576670e793b0184de3d1b9cda2bf697d17f630"}, - {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c71e3755a8320d29c351126d550930349be22b44bac1a559caf12ab78b53e9f"}, - {file = "xxhash-3.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:715c611582004e75010517b919776c5dbc00aae03054dc9fd72484a23fd1862f"}, - {file = "xxhash-3.8.0-cp311-cp311-win32.whl", hash = "sha256:41a30a1d0ba978238742a374875c15979e0faed0a65294f3ff4d9410057ee8b6"}, - {file = "xxhash-3.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:43705f917b8b817d6994851bf3725b98b4c95e64186404d9a6dbc1acf12fd140"}, - {file = "xxhash-3.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:35c5d843bb7ac1dfdb125ef4181fe4c2e01c2275856e6b699de89e9eb5c69c8d"}, - {file = "xxhash-3.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fc4bd14f873cd0b420f6f1ff5b5cd0dbfeb05b044a11bb9345bcbbf9749636e3"}, - {file = "xxhash-3.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31904979198e913239cb61b49f5b849696aeb3b03340da815d1491ec74dcc602"}, - {file = "xxhash-3.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7338ad13f2b273a1ef0ea97b2db0a059fdb3a1a29298bfa145937c0e4152d341"}, - {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54e80e803cb34c8a1d278b491e543af40a588d288589c3e6becc991d5328b46b"}, - {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:353953ea18f5c3fbdd13936fb536aacfb47d5bc06eef0919b1a355df61f7cc31"}, - {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d761f983a315630eff18c2fec7360c6b6946f82748026e779336eb8141ef3eba"}, - {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3786a9beb9a3b76241cb7db5f5388b460682c12204236389e3221963fc626a6"}, - {file = "xxhash-3.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c94f5a9a775f36cc522fa2a7e8e2cec512e252d2ac056759f753dc68a79ffc"}, - {file = "xxhash-3.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:55ce59f9af37ac861947b43ea3ce7b294b5de77a1234b558d0f07ffad0197624"}, - {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3afa1422a32c7c8e79ad5121dc21eaa5cee9e9e67bffca3f15d15d220d371908"}, - {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:551fda694938be910529452a89175137c58b4739e41fadff3c047e24b1d74a3b"}, - {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512eb937c9457e6057e230e005c4709dd2ab63a5989f854d69f31db905750a62"}, - {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4931ea93840f750a908efebaf23c71004feacc1a4649ef601b96d400a505c9a9"}, - {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2fd4b60e8d9fc3923f39079f185b3425e6d76636fcb66d82a33dd7eba7c30f2f"}, - {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1da00075f1605794298878cb587f7533329693e2a0c45bbd25d6353644add675"}, - {file = "xxhash-3.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba73801c87d44fa37b2a5feab3004f0a654506027bf032ceb154d94bb74ea772"}, - {file = "xxhash-3.8.0-cp312-cp312-win32.whl", hash = "sha256:0b0836dee6022e22ba516ebfa8f76c6e4bda08d6c166c553e40867bac89e4a54"}, - {file = "xxhash-3.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3bc2a09b98b8f85c75208cd2b2d2aecf40c77ecb2d72f6bf9757db51a98d3499"}, - {file = "xxhash-3.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:208e6a8b93426896d803224e9fabe26f8b9c651e8381a80b1fa31812faa091e3"}, - {file = "xxhash-3.8.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:36434c1d1b0a4729df1fa26ab11bffed1ba52666c0beb605c98a995b470cd143"}, - {file = "xxhash-3.8.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:a5e6497cefcb2d67f1745c66df9718a99112583af6cc2b70da0312a2eb939f1e"}, - {file = "xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5b00b82f1be708da9404fefd658cf5cf3be5ee3be2aae4bfe3b874255badd342"}, - {file = "xxhash-3.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38b0cb0ab7f283413b7cace2bf710d7cf8f702ea82cbc683908691d52028a89b"}, - {file = "xxhash-3.8.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:084312171a9798dea85e924b2674f5e1a44933050a1ea1cb1c6b1364e004c66c"}, - {file = "xxhash-3.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6a1a9e845bd3bbc57d9356819e0d198fe23282e0576b398a6282a0f8fdc75aef"}, - {file = "xxhash-3.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ffbde09743ebaf8957b8426948fbe85eab5e5de0d29eec407fcff5a2812a3cc"}, - {file = "xxhash-3.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a6dee3952c2b6e82e7f1dbc5dbc6167f9c84126851def7926e32827c2816169c"}, - {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf8ff8e12416c9fa05b43c7509b9332d6ffc4090413c4e7a1dee8599763b6d59"}, - {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cebbb322df4d97d8ef2704f49ed2f6f21f6702fafa0dc0c2a6ae70e904205689"}, - {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a8d08707b4100ebce598fc59fadf04b42d79b855818d6994f8f0fffd1df8edb"}, - {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cf5427602dda15d8ce3c6d870d29bf07d43975f59c9d6d3f7f6f93a901b28b12"}, - {file = "xxhash-3.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97d7bd715ea5050b6c9638b52c62adf3055b648ef6eee6892a4cd9697b530191"}, - {file = "xxhash-3.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cd25bbbab37d898f6e5a90905ce6ae2c1f8bd6668c07cef406fb3e8c8c570dd"}, - {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3e30e5c057f483c3c53a11b53eba091a737cb19dfead36c8b23bf5beb4a169cd"}, - {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:07dd44d992ebd456752bc25b1c42cd172d94bd8cb24049300449ad0716081c3a"}, - {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3118600a3102d4707dc1c485dbc3acbbbf37819069ad3e7854e77b923745d76b"}, - {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ed37b0c95d8fb3fbaad5e13cc0a9727eb8739d1d54b2adef28108c250cada3a"}, - {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bb043da412e478e7b1db3407051124b85b133803794d3809ad6d92870b304fc7"}, - {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:196fc132683d9311a0bdce8388ee52bfa07fdc1987cc428a27956e47ccd7b50d"}, - {file = "xxhash-3.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfb5411af3b77c75e99db100aa15c5ba623c85d72c565e4d7a0ed1a986ff766e"}, - {file = "xxhash-3.8.0-cp313-cp313-win32.whl", hash = "sha256:6d1d6179e26830c6690fac63f76d372f69714b977e12ca9c42188a60f51c59f5"}, - {file = "xxhash-3.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7c92427a56a12f4d5c7bb26dbb9e9a4658c313ecb6c2f1dca349902e3822df07"}, - {file = "xxhash-3.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:9fc8453642c1c6d38b4fbac8901c2452ce1fa88b27f003bfee6703cbfae9bd63"}, - {file = "xxhash-3.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:efcacb644a915f010dc477447b045e5dcde1afaa40d16b2f0f8e7cd99c9e1635"}, - {file = "xxhash-3.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d1e0dbc510cff94c5efbcc2b82c28b41519fad09b5b1f9f3d99c63e3940e49a0"}, - {file = "xxhash-3.8.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ff19d016a41c90d1f519005887191896b6da1274e1d5d48b347e17eb798ffc5a"}, - {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aafc3eab99c50508852e34307e9565933bf128cad084cac7d2471b7ab1743de0"}, - {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e521368ed79ae6c4d31e1e417726643c49d7d6e286f4fdabf9a8330ed8a8ff7"}, - {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a0127688d116ec0c225e7e1f744e3f206de2b8822ffeb31a9ab5cc6384f92c5"}, - {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:22c0b17da2f9fea0f8836538512249871b359141616bad44c58d238b5f011f40"}, - {file = "xxhash-3.8.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d49465646b1a5e3b1729c5f636e05676a2fb52e203e3b22a5411c416c4c5302"}, - {file = "xxhash-3.8.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2853dea1e30ed00ca87dd87d76da5da063d302b823b3fb80ccd18421de0f251"}, - {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:82f0102a2a3760287b7cd7f9e0a30edd4c3b18762ed1a242208d43c8e2bcf30b"}, - {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b8414a66a7524596d841cad5dc1adab6ce76848db5ab2b83db911fbdab1417af"}, - {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0dbaa73df10414ea1e41b98691a9d8241d4c47ad8d02c726587a3cda05278e53"}, - {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:43fc9aaba10ab4267c90793601f60d35c3c9caa1544eceb483618a71ad9ce7da"}, - {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ec5eb3d28fbb9802c6d2526f772133a06c91d6f03756fcc67c834b642ffdd51d"}, - {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:2b77c301b644cd9b4d0749a3291081ec2048a6bef7fe0487c993bbba3efb9ce0"}, - {file = "xxhash-3.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d7ece11a132325353890a144c30119073617a1299c593ca29b96c315b07e1edd"}, - {file = "xxhash-3.8.0-cp313-cp313t-win32.whl", hash = "sha256:b21db84df7b9d54d9e4195a964243c1b32d745c6fbc0cfcfffee1d4bd297196a"}, - {file = "xxhash-3.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0643b7d9f598f6da6f1f6b899f4358250d0fb853242e2d712cbde27bf5a99d29"}, - {file = "xxhash-3.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4bbacf2e938526969f8ab3334d4ac3da14ea059e1dfd1339a92f9091467e750f"}, - {file = "xxhash-3.8.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:557e2a7cc0b6a634cf9c8e5c975d96b7da796fdeb1824569d760cf0f25b6f33f"}, - {file = "xxhash-3.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:dad744d1613cbfddb844dad93adbffbd51c3e9f53ceea9568f7c3b94bedc19a4"}, - {file = "xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:953f29b22c04b123cf3cd2e08bccde3a73184aeda5a1038e0054cb3355644120"}, - {file = "xxhash-3.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:aa699e0253ceffecf41cae858d0a11f2439d6874a0890b556387bffe11dc1c08"}, - {file = "xxhash-3.8.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e232c82466babc13e956d53aa84d0149660ed6886bc195248bb4d03bf2eca301"}, - {file = "xxhash-3.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7f75fd1c6a5028f345cd4a8c52f4774d2e5b7809fa58111c60a5502b528914a4"}, - {file = "xxhash-3.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b49d7e09b211a1ad658dbe2dbf6561eb92f2e6926bd1101e2d023178371f2d6f"}, - {file = "xxhash-3.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ceb702bc8e56b7f1f1413d42aa294045b9a0e4c9888e07edc5cd153e8c4c948f"}, - {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f3c96e06bdb122e8cc84f5c7088579f3102b828efd62e9dc964a9d17c7b89e"}, - {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:415a8d06ac9bea36b1e06b603a347e0f62401042a97d7bfccec8ae2da12ad784"}, - {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f5ccdd2deb5dce31201cc0eec94388cce97e681429073db50903fab0a0a8a0d"}, - {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a6cf81bc699d3a5ebfcf2fdb2a7bd2e096708d7de193f6f322944a02ba00953"}, - {file = "xxhash-3.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4d12a04d7ffc0359f0eadc4535a53cab113044c8d2f262c7e9a56950a5ed50e"}, - {file = "xxhash-3.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d209373fcb66138c652cf843385ee60866e50158a7869bbbf8b322d9a822b765"}, - {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b88a3fe28277811e599efa6e1c96abce8a77d60dd79c94da7a9b5c377c172b7b"}, - {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5d5a888a5ef997cb35f1aad346eb861cd87ecfe24f5e25d5aa4c9fd1bd3950c2"}, - {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:de2836e0329c01555957a603dcd113c337c577081153d691c12a51c5be3282b0"}, - {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4bc74eedb0dd5827b3be748bacf9fdb50004037a3e16c7ddb5defae2682cef71"}, - {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c571b03d59e339b010dc84f15a6f1cff80212f3a3116c2a71e2303c95065b1f6"}, - {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:87626acdd6e2d762c588a4ffe94258c5ef34fb6049a4a3b25019bdb7f9267a9b"}, - {file = "xxhash-3.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:076d8a4fb290af952826922aa42a46bfc64caa31662ce4e2925a445d0e6ce57f"}, - {file = "xxhash-3.8.0-cp314-cp314-win32.whl", hash = "sha256:52f8c7c9833d947e60df830671f6eca810d7c667051243985a561c79f1a3d545"}, - {file = "xxhash-3.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:4fbfcb7dd307e23189a71050f6e27746926590330f37d5fd2ffcb8ea78de1f42"}, - {file = "xxhash-3.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:ecef1e65b4715c7326002073763fe94cc44c756a0698508abb915ab3d6be6e3d"}, - {file = "xxhash-3.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:02ed856a765cb6e006168595d9455ac8c3c4d60cc04cd47a158a1ac677d68f0f"}, - {file = "xxhash-3.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eec30461a7b457611098ba7ab09363e36c8b2645b4687fb6f3d405bb646e3410"}, - {file = "xxhash-3.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b471744912d1ce5dd6d3975b7525e77518359ebf3aa1bd7d501e199f5ae488ea"}, - {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3748d71202bf3f279e77cb8b273b6d0f29d1bcaefb6ce6cb03b95f358863ba37"}, - {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3b3bf59ea94b2a23b0f992769804ab9401d5cdcd9df0062fe2cd78a491ae8851"}, - {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f061aa5379eba249e9367b179515571e632be6d1b6f55ac139e6fe3d08463c"}, - {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:680d70896a61fc920cc717a0a8fe8a9fb5858c563184666e31874caa54a16d9e"}, - {file = "xxhash-3.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14973fbdee136588e57447401b521f466a42faca41eecdf35123c73103512ca8"}, - {file = "xxhash-3.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:96c6bca2486cdc58b125966817a92a6abe6ef1fab86b2f8798a7e93488782540"}, - {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0b1109ae238e932d8482f9cb568b56a405cc73bc7a36b837844087f1298dd218"}, - {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1da5db0863400eade7c5a31969754d1392189f26b4105f6631da2c6c7ea3bccc"}, - {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c61b5a0f21ace5e886f177cce43826d85a7c84e35a9e17cb6d1b4ac0b7a7d833"}, - {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1db4f27835a450c7e729bc9330c6e702113711cea1f873d646e3a31fe96a9732"}, - {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4788a470f946df34383abc6cd345088c13f897a5ee580c4cdd12b1d32ad218ef"}, - {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3b6dfa83096cb1e54d082acebaf67f0c42667c56dc48ba536a76cac08d46391e"}, - {file = "xxhash-3.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:57ec0ba5299a9a7df376063c139f5826ff0c89b438703939af3d252c31ca96a4"}, - {file = "xxhash-3.8.0-cp314-cp314t-win32.whl", hash = "sha256:d9a61f23b999baeb84102aba767b1b3e94958eab94e6c11b08927e7dc4200795"}, - {file = "xxhash-3.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:61069b260fff84116235bb93845f319284dc6b42527c215af59264f4c2ee3468"}, - {file = "xxhash-3.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:73cecd431b4f572d38fcf1a7fe85b30eb987778ef9e7a70bc9ffcf2d64810e6f"}, - {file = "xxhash-3.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fbcbbfa24a474793da405731d123ae17f4e9a1c38fb4e67acc4dfc5fd0b7022a"}, - {file = "xxhash-3.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:84101596d32201b35339ba788eb39574690a7516fb5b1b9165f09de9fee7ad1e"}, - {file = "xxhash-3.8.0-cp38-cp38-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:564a85f94635da981a1087314ef57fce6e0d27aef22390b60319b01a99fe7991"}, - {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d011de14fe14b6c31ab1d4dc400bd5ef187c44446e68da15a5039fae46a8494"}, - {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b263e609d9be516f3df9b1619617376d0335111f013cfa00d7fc4b2e1d5a3d"}, - {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b60d8e3c3340b679851ee268288deef9cdc20d8b1a77c0c0d2d2f3849a16ee1"}, - {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:96fba2ec05a43e5b9b8070f68b42d0a91f7aeefe4dc425775197ec1c5f8c2c63"}, - {file = "xxhash-3.8.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a0325359942e9cbc2c04529f462bf637e73e0657a277b7d64d61986d24d539b"}, - {file = "xxhash-3.8.0-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c381537f7a918c8b93f236e7df495c6cd0b4eeffa747c74b568caef4e073ce1f"}, - {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:5f7f9ba111d3b4a8d7c6edfd7e9ab7c67efb98234907520f4c6411a2b2039cb3"}, - {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:ecdbbe1c5de579435d3879d53d35d681d626836bdde333577a9491000723bb6a"}, - {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1e9fb591e5df85a787c0303b1d01487ab04820f6607d7ba148491005226fd968"}, - {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c7c24094d990d8bdf78132b22891c6aa116656dfe68f77b1424e7e2ae0d4c0bb"}, - {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:33c6f8adf3cc1745ae17e8c1053e7c61f9640cb3de35721e706dcbf1fecd3f51"}, - {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:be48b4db998927daa182c3b3b18101faa24d6e283ca2192e40b686e3ad0b905b"}, - {file = "xxhash-3.8.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bf57b05eaf75b567259ff6f2c9045f26621f0658d18b123eee53b1fa81318f78"}, - {file = "xxhash-3.8.0-cp38-cp38-win32.whl", hash = "sha256:a484492df0b094a3bba94f6aff7324e30aec95a2a3e9caac6806838cf8d6dd3c"}, - {file = "xxhash-3.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:5685977fa6bcf18aa408492ca3960325f42a23a1df3df5bf3ffc3840b67f61d3"}, - {file = "xxhash-3.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:84b8abc80329921e85cd02debd15a5247ebd8de38f8952bec27f241628ef6714"}, - {file = "xxhash-3.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c02c23fe543bacc471b62b4c8af150bb53f72cb2c5878267d589182d36b4669b"}, - {file = "xxhash-3.8.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c1e0f01748df88c0c470a6f49a00c60fcbe6624ce8fb101285a94b5e0a11e942"}, - {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04faeb2290c0596b25f8509dbabe8b821461f04eb9097b74cc8b486d571087fd"}, - {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0e976ef1387cde8c1d0c81450ee6ab5c8c75b637c8fb4a3791769cdd301dddd"}, - {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed377642dbc001d2197b0742f31fe3b72fc4fef2b29ee1916075745a423e48b8"}, - {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c10f98df550135978530ae87d0ffc4421e1580a353bb0d7c815f05ca06ab15f3"}, - {file = "xxhash-3.8.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9eaed16c1581478ffbf66f31f9d6db86346e8eb36fecb96ef742aea4a05c876"}, - {file = "xxhash-3.8.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:654541773668385e162f18f001955bb5cddc9490beee69684061ecb3004bb46b"}, - {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:369ed0efff8216f9f3502eb73694c22e4e8870ad24b454b5d0e7df01a3b29320"}, - {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:56c28a1f244432af91eb89d4170b97ece5e81286009638f50a99dfd098bd8c21"}, - {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2dcd8e626d5d130163064a64e7825b978a5c66febb55fe16018d09ce8bd7a0d"}, - {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:61d99283533b7aeb6cd159590fb1c3df9bdb59f639ff358a6b6e74088838b967"}, - {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:48536e880c8c63fb7021c33172e776afb086817a5a4a9cf3ca12c1a4f0e748cb"}, - {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f75a8e7714075f5e16c8be1c08a0716b12e04846aea0403440302f354a6bd49b"}, - {file = "xxhash-3.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f01e382b0d2ebdff863948e59560c98e7a8c02ba1e04c21f8afcc7df37f07626"}, - {file = "xxhash-3.8.0-cp39-cp39-win32.whl", hash = "sha256:3193f7c98b9d563d1986bf4dec9a584230bbe230b711e399c1547e852cbedfee"}, - {file = "xxhash-3.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:ae7152fa6ce8e8304d6865a2b9e6b49cff27dcb8ca28967a4362a71870354403"}, - {file = "xxhash-3.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:c35c889d9e58cb72fead4f14717faf6528c95c5aa77d469f724dba28242b976f"}, - {file = "xxhash-3.8.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ba14843f20df2dce6ff6684411a56ae53da44336546c55f8947e70aebb8cdd21"}, - {file = "xxhash-3.8.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ec6666a5311beae3f6cb5f2fd28c2b77e2df32702c8206f45c786a6ef81b3751"}, - {file = "xxhash-3.8.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1ec9afdd53ac5f4fd1d8918807ba6c35ba62269086af794884b9f168a73331ea"}, - {file = "xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68594a54be2eb5992d9b0d0a0ec7c32a7a8e930f06d6cb951d69708055680994"}, - {file = "xxhash-3.8.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:591d5eb256abf59438800ace2730ac33f77bc6ab8c3623fab1ea24d9d8b28f3a"}, - {file = "xxhash-3.8.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f4eecf800275e62b6bcb41e65f361f2277cc886c2bff4e299959d701e5fcf93"}, - {file = "xxhash-3.8.0.tar.gz", hash = "sha256:d72b2204f37840b0f16f34192c09b994b97bd25823d723d47a1eddfacf06eb43"}, -] - -[[package]] -name = "xyzservices" -version = "2026.3.0" -description = "Source of XYZ tiles providers" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "xyzservices-2026.3.0-py3-none-any.whl", hash = "sha256:503183d4b322bfebc3c50cdd21192aa3e81e36c5efbf9133d54ae82143e0576b"}, - {file = "xyzservices-2026.3.0.tar.gz", hash = "sha256:d226866a5d8e9fef337034d8da37a8298f0a1d9d1489b4018e69579eb321fea4"}, -] - -[[package]] -name = "yarl" -version = "1.24.2" -description = "Yet another URL library" -optional = false -python-versions = ">=3.10" -groups = ["main", "torch-cuda"] -files = [ - {file = "yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12"}, - {file = "yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0"}, - {file = "yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90"}, - {file = "yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53"}, - {file = "yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342"}, - {file = "yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4"}, - {file = "yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39"}, - {file = "yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1"}, - {file = "yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c"}, - {file = "yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003"}, - {file = "yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b"}, - {file = "yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5"}, - {file = "yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45"}, - {file = "yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122"}, - {file = "yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8"}, - {file = "yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2"}, - {file = "yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c"}, - {file = "yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c"}, - {file = "yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1"}, - {file = "yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad"}, - {file = "yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30"}, - {file = "yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536"}, - {file = "yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607"}, - {file = "yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617"}, - {file = "yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056"}, - {file = "yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992"}, - {file = "yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656"}, - {file = "yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461"}, - {file = "yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2"}, - {file = "yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630"}, - {file = "yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208"}, - {file = "yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b"}, - {file = "yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8"}, - {file = "yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0"}, - {file = "yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57"}, - {file = "yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b"}, - {file = "yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761"}, - {file = "yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf"}, - {file = "yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe"}, - {file = "yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd"}, - {file = "yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215"}, - {file = "yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d"}, - {file = "yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9"}, - {file = "yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8"}, -] - -[package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" -propcache = ">=0.2.1" - -[[package]] -name = "zipp" -version = "4.1.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.10" -groups = ["main"] -markers = "python_version == \"3.11\"" -files = [ - {file = "zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f"}, - {file = "zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=3.4)"] -test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] - -[extras] -multiprocessing = ["pydantic", "ray"] -precily = ["gseapy"] -sparsego = ["mygene", "obonet"] -xgboost = ["xgboost"] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.11,<3.14" -content-hash = "8b4114488e9751e2bfa258bd9d4bf2fdf1964ebac8b25ce3b7520d68382f6cb4" diff --git a/pyproject.toml b/pyproject.toml index c97942527..5bb271476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,128 +1,325 @@ -[tool.poetry] +[project] name = "drevalpy" version = "1.5.1" description = "Drug response evaluation of cancer cell line drug response models in a fair setting" -authors = ["DrEvalPy development team"] +authors = [{ name = "DrEvalPy development team" }] license = "GPL-3.0" readme = "README.md" +requires-python = ">=3.11,<3.14" +dependencies = [ + "numpy>=1.20", + "scipy", + "scikit-learn>=1.4", + "pandas", + "pyarrow", # pandas.read_parquet engine for `drevalpy.cli.curate` + "anndata", + "joblib", + "networkx", + "pyyaml", + "pytorch-lightning>=2.5", + "torch-geometric", + "plotly", + "matplotlib", + "scikit-posthocs", + "curve-curator", + "subword-nmt>=0.3.8", + "pydantic>=2.5", + "pyparsing>=3", + "wandb>=0.24.0", + "xgboost>=3.2.0", + "lightgbm>=4.0.0", + "typer>=0.26,<0.27", + "rich>=15.0.0", + "gseapy>=1.1.0", + "optuna", + "platformdirs>=4.0", + # Named backends, not `full`: shipped code only reaches s3://, http(s):// and + # file://, and `full` pulled 18 packages to serve protocols nothing can reach. + # Registering another protocol with `register_source` means installing its backend. + "fsspec[s3,http]", + "universal-pathlib", + "filelock>=3.15.2", + "mudata>=0.3.10", + "rdkit>=2026.3.4", + "transformers>=5.14.0", + "gensim>=4.4.0", + "multiqc>=1.35", + # Imported by no drevalpy module: it is what the `dev-mode-exact` editable + # install's generated finder imports, so without it `import drevalpy` fails. + # It must sit here, not in the dev group - uv reads this file's static metadata + # for path dependencies and never sees the editable wheel's Requires-Dist, so + # repos that path-install drevalpy editable (both plugin repos) broke on import. + "editables>=0.3", +] -[tool.poetry.scripts] -drevalpy = "drevalpy.cli.main:cli_main" -drevalpy-report = "drevalpy.cli.legacy:main" -drevalpy-viability-preprocess = "drevalpy.cli.legacy:preprocess_raw_viability" -drevalpy-viability-postprocess = "drevalpy.cli.legacy:postprocess_viability" -drevalpy-load-response = "drevalpy.cli.legacy:load_response" -drevalpy-make-cv-pkls = "drevalpy.cli.legacy:cv_split" -drevalpy-make-hpam-yamls = "drevalpy.cli.legacy:hpam_split" -drevalpy-train-cv = "drevalpy.cli.legacy:train_and_predict_cv" -drevalpy-evaluate-hpams = "drevalpy.cli.legacy:evaluate_and_find_max" -drevalpy-test-cv = "drevalpy.cli.legacy:train_and_predict_final" -drevalpy-make-randomization-yamls = "drevalpy.cli.legacy:randomization_split" -drevalpy-make-final-split-pkls = "drevalpy.cli.legacy:final_split" -drevalpy-tune-final-model = "drevalpy.cli.legacy:tune_final_model" -drevalpy-train-final-model = "drevalpy.cli.legacy:train_final_model" -drevalpy-consolidate-single-drug = "drevalpy.cli.legacy:consolidate_results" -drevalpy-evaluate-test = "drevalpy.cli.legacy:evaluate_test_results" -drevalpy-collect-results = "drevalpy.cli.legacy:collect_results" -drevalpy-make-pipeline-report = "drevalpy.cli.legacy:pipeline_report" - -[tool.poetry.dependencies] -python = ">=3.11,<3.14" -numpy = ">=1.20" -scipy = "*" -scikit-learn = ">=1.4" -pandas = "*" -networkx = "*" -pyyaml = "*" -pytorch-lightning = ">=2.5" -torch = ">=2.1" -torch-geometric = "*" -flaky = "*" -requests = "*" -plotly = "*" -matplotlib = "*" -importlib-resources = "*" -scikit-posthocs = "*" -curve-curator = "*" -subword-nmt = ">=0.3.8" -toml = {version = ">=0.10.2"} -poetry = ">=2.0.1" -starlette = ">=0.49.1" -pydantic = { version = ">=2.5", optional = true } -wandb = ">=0.24.0" -xgboost = { version = ">=3.2.0", optional = true } -typer = ">=0.26,<0.27" -rich = ">=15.0.0" -gseapy = { version = ">=1.1.0", optional = true } -mygene = { version = "*", optional = true } -obonet = { version = "*", optional = true } - -[tool.poetry.requires-plugins] -poetry-plugin-export = ">=1.8" - -[tool.poetry.extras] -multiprocessing = ["ray", "pydantic"] -xgboost = ["xgboost"] -precily = ["gseapy"] -sparsego = ["mygene", "obonet"] - -[tool.poetry.dependencies.ray] -extras = ["tune"] -version = "*" - -[tool.poetry.group.development.dependencies] -sphinx-autodoc-typehints = "*" -sphinx = ">=4.0.2" -sphinx-autobuild = ">=2021.3.14" -sphinx-rtd-theme = ">=1.0.0,<3.0.3" -sphinx-click = ">=3.0.0" -pytest = "*" -nox = "*" -nox-poetry = "*" -black = "*" -isort = "*" -flake8 = "*" -flake8-bandit = "*" -flake8-bugbear = "*" -flake8-docstrings = "*" -flake8-rst-docstrings = "*" -darglint = "*" -pre-commit = "*" -pre-commit-hooks = "*" -pyupgrade = "*" -pep8-naming = "*" - -[tool.poetry.group.torch-cuda] -optional = true -dependencies = { torch = ">=2.1", torch-geometric = "*" } - -[tool.poetry.group.dev.dependencies] -mypy = "^1.19.1" -ruff = "^0.15.8" - -[tool.black] +[project.optional-dependencies] +cpu = [ + "torch>=2.1", +] +cu126 = [ + "torch>=2.1", +] +cu130 = [ + "torch>=2.1", +] + +[project.scripts] +drevalpy = "drevalpy.cli.main:cli_main" + +[dependency-groups] +dev = [ + "ruff>=0.15.8", + "ty", + "prek", + "pytest", + "pytest-cov", + "coverage", + "complexipy", + "flaky", + "xdoctest", + "types-PyYAML", + "jupyter>=1.1.1", + "vulture>=2.16", +] +docs = [ + "sphinx>=4.0.2", + "sphinx-autobuild>=2021.3.14", + "sphinx-autodoc-typehints", + "sphinx-rtd-theme>=1.0.0,<3.0.3", + "sphinx-design>=0.7.0", + "sphinxcontrib-mermaid>=2.1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# The default editable install puts the bare project root on sys.path, so a +# consumer's `import tests.conftest` resolved to drevalpy's tests. `dev-mode-exact` +# maps only the `drevalpy` package. Costs: some IDEs cannot follow the import hook, +# and the generated finder needs `editables` at run time. A `src/` layout would fix +# the leak with neither cost, but moving the package is a repo-wide change. +[tool.hatch.build] +dev-mode-exact = true + +[tool.hatch.build.targets.wheel] +packages = ["drevalpy"] +# Not a .py file, so PEP 561 needs it named explicitly; tests/plugin/test_init.py +# asserts it ships. +artifacts = ["drevalpy/py.typed"] + +[tool.uv] +exclude-newer = "3 weeks" +# nvidia-nccl-cu12 (via xgboost) and nvidia-nccl-cu13 (via the cu13 torch wheel) +# ship the same libnccl.so.2, so whichever unpacks last owns it. Pinning cu12 to +# the NCCL release torch's cu13 build expects keeps the library compatible either +# way; below it, `import torch` failed on an undefined ncclCommResume symbol and +# every torch-based component silently dropped out of the registries. +override-dependencies = ["nvidia-nccl-cu12>=2.30.7; sys_platform == 'linux'"] +conflicts = [ + [ + { extra = "cpu" }, + { extra = "cu126" }, + { extra = "cu130" }, + ], +] + +[tool.uv.sources] +torch = [ + { index = "pytorch-cpu", extra = "cpu" }, + { index = "pytorch-cu126", extra = "cu126" }, + { index = "pytorch-cu130", extra = "cu130" }, +] +curve-curator = { git = "https://github.com/nictru/curve_curator.git" } + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + +[[tool.uv.index]] +name = "pytorch-cu126" +url = "https://download.pytorch.org/whl/cu126" +explicit = true + +[[tool.uv.index]] +name = "pytorch-cu130" +url = "https://download.pytorch.org/whl/cu130" +explicit = true + +[tool.ruff] +target-version = "py311" line-length = 120 +[tool.ruff.lint] +select = [ + "E", "F", "W", + "I", + "UP", + "B", + "S", + "D", + "N", + "C90", +] +ignore = [] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "D101", "D102", "D103"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.format] +docstring-code-format = true + +[tool.ty] + +[tool.ty.environment] +python-version = "3.11" + +[tool.ty.rules] +# tests/docs/ imports the docs/ generators (`_model_zoo`, `_cli_click`, +# `_generated_io`), which live outside the package and go on sys.path at runtime. +# Narrow to a per-file ignore once ty supports it. +unresolved-import = "ignore" +# Incomplete third-party stubs (pandas, matplotlib, torch) +invalid-argument-type = "ignore" +no-matching-overload = "ignore" +# scikit-learn TransformerMixin stub does not declare .fit/.transform +unresolved-attribute = "ignore" +# Standard PyTorch Dataset.__getitem__ override pattern +invalid-method-override = "ignore" +# Tests violate types deliberately, each with its own `# type: ignore` +invalid-assignment = "ignore" +missing-argument = "ignore" +unknown-argument = "ignore" +# ty beta limitations: type[Any] in return annotations (the featurizer registry +# pattern), pytest.skip(reason), and return-type narrowing +invalid-type-form = "ignore" +too-many-positional-arguments = "ignore" +invalid-return-type = "ignore" +# Informational only +redundant-cast = "ignore" +deprecated = "ignore" + [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["."] +# Default run is the fast tier. A command-line `-m` replaces this one rather than +# adding to it, so CI's `-m "not network"` still selects the whole suite. +addopts = ["--import-mode=importlib", "-m", "not slow and not network"] +markers = [ + "network: requires downloading pretrained weights or remote annotations", + "slow: extended tier - spawns interpreters, fits curves, or trains models; CI only", +] -[tool.mypy] -strict = false -pretty = true -show_column_numbers = true -show_error_codes = true -show_error_context = true -ignore_missing_imports = true - -[tool.isort] -multi_line_output=3 -include_trailing_comma=true -balanced_wrapping=true -line_length=120 -profile = "black" -known_first_party = ["drevalpy"] -known_third_party = ["wandb"] +[tool.coverage.run] +source = ["drevalpy"] +relative_files = true +omit = ["*/gene_lists/_make_gene_lists.py"] # maintenance script, not shipped logic -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[tool.coverage.report] +# Keep about a point of headroom below the measured figure so an unrelated +# refactor does not redden CI, and no more: this number only ever moves up. +fail_under = 89 +show_missing = true +skip_covered = true +exclude_also = [ + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", + "raise NotImplementedError", + "@(abc\\.)?abstractmethod", + "@overload", +] + +# Per-module floor, enforced by tools/coverage_gate.py because coverage.py can only +# fail on the aggregate, which lets one untested module hide behind a tested package. +[tool.drevalpy.coverage_gate] +min_file_coverage = 60 + +# Each entry is a module that cannot currently reach min_file_coverage, with the +# reason. An exemption is debt, not a policy decision: delete it (do not lower it) +# once tests bring the module up. The gate prints redundant entries on every run. +# +# The network-gated artifact featurizers are deliberately *not* exempted: their +# download paths are deselected in the measured run, but their offline logic clears +# 60% anyway (chemberta 66%, smilesvec 81%, molgnet 84%, bionic 88%). +[tool.drevalpy.coverage_gate.exemptions] +# In EXCLUDED_MODELS: the ontology featurizer always raises because +# attach_sparsego_ontology_metadata has no production caller, so the predictor +# cannot be trained end to end until that source defect is fixed. `predictor.py` +# used to be exempt at 53 and no longer needs it - direct unit tests of its +# offline paths put it at 62%. `algorithm.py` (34%) and `utils.py` (51%) have +# under a point of margin over their floors, so measure the full `not network` +# suite before touching them; a partial run reads several points high. +"drevalpy/components/predictors/literature/sparsego/algorithm.py" = 33 +"drevalpy/components/predictors/literature/sparsego/utils.py" = 51 + +# Resolving a registered dataset downloads a multi-GB .h5mu from a credentialed S3 +# bucket, so only the not-found and cache-hit branches are reachable offline. These +# floors are reproducible on a clean checkout - the cache-eviction helpers are +# unit-tested against synthetic .h5mu files in tmp_path, needing neither +# credentials nor a warm cache. What remains uncovered is the download itself; do +# not chase the last points, it would mean pulling hundreds of megabytes in CI. +"drevalpy/data/datasets/__init__.py" = 44 +"drevalpy/data/datasets/_load.py" = 55 + +# Defensive resolution fallbacks for hand-rolled DRPModel subclasses. Every model +# construct_model() produces sets _base_model_config, so the zoo-lookup and +# model_config()-RuntimeError branches are dead for all shipped models. +"drevalpy/models/mixins/_hyperparameters.py" = 57 + +# Per-module statement ceiling, enforced by tools/size_gate.py. This is the ratchet +# that keeps the mixin split in models/mixins/ and components/featurizers/ from +# growing back: complexipy already caps per-function complexity, but nothing stopped +# a module from accumulating a fourth unrelated concern's worth of small methods. +# +# Statements, not lines, so the count does not move with formatting or with the +# Google-style docstrings this codebase requires. +# +# Deliberately not a repowise hotspot-health floor, which is what the health plan +# asked for. Measured on this repo: `repowise health` reads 10.0 on the +# `fetch-depth: 1` checkout actions/checkout does by default (every commit-history +# marker silently vanishes), 6.13 on a full clone with no coverage ingested, and +# 6.25 in this working tree with coverage ingested - three different numbers for one +# tree. 68% of its finding impact is derived from commit history, so the score also +# drifts as unrelated commits land. A CI floor on it would fire on innocent PRs and +# pass vacuously on the default checkout. Run `repowise health --trend` locally +# instead; see AGENTS.md. +[tool.drevalpy.size_gate] +max_module_statements = 150 + +# Each entry is a module that is currently above the ceiling, with the reason. An +# exemption is debt: split the module and delete the entry. A recorded ceiling may +# be lowered as a module shrinks, never raised to let a regression through. Values +# are the counts measured by `uv run python tools/size_gate.py`. +[tool.drevalpy.size_gate.exemptions] +# Vendored third-party network definition: one architecture transcribed from the +# reference implementation. Splitting it would make it harder to diff against +# upstream, not easier to read. +"drevalpy/components/featurizers/drug/_molgnet_network.py" = 255 + +# The remaining literature predictors still carry the torch plumbing that +# _omics_loaders.py and _pair_predict.py pulled out of their siblings. These are the +# next candidates for the same treatment, in this order. +"drevalpy/components/predictors/literature/sparsego/algorithm.py" = 188 +"drevalpy/components/predictors/literature/superfeltr/predictor.py" = 187 +"drevalpy/components/predictors/literature/dipk/predictor.py" = 186 +"drevalpy/components/predictors/literature/srmf/predictor.py" = 180 + +# Single cohesive units whose statements are one flat sequence rather than several +# concerns: a declarative registry of builtin components, one dataset facade, one +# component-stack builder, one plot each. +"drevalpy/registry/_builtins.py" = 199 +"drevalpy/models/component_stack.py" = 240 +"drevalpy/types/data/dataset.py" = 218 +"drevalpy/visualization/plots/leaderboard.py" = 177 +"drevalpy/types/results/experiment.py" = 156 +"drevalpy/components/predictors/neural_network/predictor.py" = 165 + +[tool.complexipy] +max-complexity-allowed = 15 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 851377c45..000000000 --- a/requirements.txt +++ /dev/null @@ -1,148 +0,0 @@ -aiohappyeyeballs==2.6.2 ; python_version >= "3.11" and python_version < "3.14" -aiohttp==3.14.1 ; python_version >= "3.11" and python_version < "3.14" -aiosignal==1.4.0 ; python_version >= "3.11" and python_version < "3.14" -annotated-doc==0.0.4 ; python_version >= "3.11" and python_version < "3.14" -annotated-types==0.7.0 ; python_version >= "3.11" and python_version < "3.14" -anyio==4.13.0 ; python_version >= "3.11" and python_version < "3.14" -attrs==26.1.0 ; python_version >= "3.11" and python_version < "3.14" -backports-tarfile==1.2.0 ; python_version == "3.11" -backports-zstd==1.5.0 ; python_version >= "3.11" and python_version < "3.14" -bokeh==3.7.3 ; python_version >= "3.11" and python_version < "3.14" -build==1.5.0 ; python_version >= "3.11" and python_version < "3.14" -cachecontrol==0.14.4 ; python_version >= "3.11" and python_version < "3.14" -certifi==2026.5.20 ; python_version >= "3.11" and python_version < "3.14" -cffi==2.0.0 ; python_version >= "3.11" and python_version < "3.14" and (sys_platform == "linux" or sys_platform == "darwin") and (platform_python_implementation != "PyPy" or sys_platform == "darwin") -charset-normalizer==3.4.7 ; python_version >= "3.11" and python_version < "3.14" -cleo==2.1.0 ; python_version >= "3.11" and python_version < "3.14" -click==8.4.1 ; python_version >= "3.11" and python_version < "3.14" -colorama==0.4.6 ; python_version >= "3.11" and python_version < "3.14" and (platform_system == "Windows" or sys_platform == "win32" or os_name == "nt") -contourpy==1.3.3 ; python_version >= "3.11" and python_version < "3.14" -crashtest==0.4.1 ; python_version >= "3.11" and python_version < "3.14" -cryptography==48.0.1 ; python_version >= "3.11" and python_version < "3.14" and sys_platform == "linux" -cuda-bindings==13.3.1 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -cuda-pathfinder==1.5.5 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -cuda-toolkit==13.0.2 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -curve-curator==0.6.0 ; python_version >= "3.11" and python_version < "3.14" -cycler==0.12.1 ; python_version >= "3.11" and python_version < "3.14" -distlib==0.4.3 ; python_version >= "3.11" and python_version < "3.14" -dulwich==1.2.6 ; python_version >= "3.11" and python_version < "3.14" -fastjsonschema==2.21.2 ; python_version >= "3.11" and python_version < "3.14" -filelock==3.29.3 ; python_version >= "3.11" and python_version < "3.14" -findpython==0.8.0 ; python_version >= "3.11" and python_version < "3.14" -flaky==3.8.1 ; python_version >= "3.11" and python_version < "3.14" -fonttools==4.63.0 ; python_version >= "3.11" and python_version < "3.14" -frozenlist==1.8.0 ; python_version >= "3.11" and python_version < "3.14" -fsspec==2026.4.0 ; python_version >= "3.11" and python_version < "3.14" -gitdb==4.0.12 ; python_version >= "3.11" and python_version < "3.14" -gitpython==3.1.50 ; python_version >= "3.11" and python_version < "3.14" -h11==0.16.0 ; python_version >= "3.11" and python_version < "3.14" -httpcore==1.0.9 ; python_version >= "3.11" and python_version < "3.14" -httpx==0.28.1 ; python_version >= "3.11" and python_version < "3.14" -idna==3.18 ; python_version >= "3.11" and python_version < "3.14" -importlib-metadata==9.0.0 ; python_version == "3.11" -importlib-resources==7.1.0 ; python_version >= "3.11" and python_version < "3.14" -iniconfig==2.3.0 ; python_version >= "3.11" and python_version < "3.14" -installer==1.0.1 ; python_version >= "3.11" and python_version < "3.14" -jaraco-classes==3.4.0 ; python_version >= "3.11" and python_version < "3.14" -jaraco-context==6.1.2 ; python_version >= "3.11" and python_version < "3.14" -jaraco-functools==4.5.0 ; python_version >= "3.11" and python_version < "3.14" -jeepney==0.9.0 ; python_version >= "3.11" and python_version < "3.14" and sys_platform == "linux" -jinja2==3.1.6 ; python_version >= "3.11" and python_version < "3.14" -joblib==1.5.3 ; python_version >= "3.11" and python_version < "3.14" -keyring==25.7.0 ; python_version >= "3.11" and python_version < "3.14" -kiwisolver==1.5.0 ; python_version >= "3.11" and python_version < "3.14" -lightning-utilities==0.15.3 ; python_version >= "3.11" and python_version < "3.14" -markdown-it-py==4.2.0 ; python_version >= "3.11" and python_version < "3.14" -markupsafe==3.0.3 ; python_version >= "3.11" and python_version < "3.14" -matplotlib==3.11.0 ; python_version >= "3.11" and python_version < "3.14" -mdurl==0.1.2 ; python_version >= "3.11" and python_version < "3.14" -mock==5.2.0 ; python_version >= "3.11" and python_version < "3.14" -more-itertools==11.1.0 ; python_version >= "3.11" and python_version < "3.14" -mpmath==1.3.0 ; python_version >= "3.11" and python_version < "3.14" -msgpack==1.2.0 ; python_version >= "3.11" and python_version < "3.14" -multidict==6.7.1 ; python_version >= "3.11" and python_version < "3.14" -narwhals==2.22.1 ; python_version >= "3.11" and python_version < "3.14" -networkx==3.6.1 ; python_version >= "3.11" and python_version < "3.14" -numpy==2.4.6 ; python_version >= "3.11" and python_version < "3.14" -nvidia-cublas==13.1.1.3 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -nvidia-cuda-cupti==13.0.85 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" and (sys_platform == "linux" or sys_platform == "win32") -nvidia-cuda-nvrtc==13.0.88 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -nvidia-cuda-runtime==13.0.96 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" and (sys_platform == "linux" or sys_platform == "win32") -nvidia-cudnn-cu13==9.20.0.48 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -nvidia-cufft==12.0.0.61 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" and (sys_platform == "linux" or sys_platform == "win32") -nvidia-cufile==1.15.1.6 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" and sys_platform == "linux" -nvidia-curand==10.4.0.35 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" and (sys_platform == "linux" or sys_platform == "win32") -nvidia-cusolver==12.0.4.66 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" and (sys_platform == "linux" or sys_platform == "win32") -nvidia-cusparse==12.6.3.3 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" and (sys_platform == "linux" or sys_platform == "win32") -nvidia-cusparselt-cu13==0.8.1 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -nvidia-nccl-cu13==2.29.7 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -nvidia-nvjitlink==13.0.88 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" and (sys_platform == "linux" or sys_platform == "win32") -nvidia-nvshmem-cu13==3.4.5 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -nvidia-nvtx==13.0.85 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" and (sys_platform == "linux" or sys_platform == "win32") -packaging==26.2 ; python_version >= "3.11" and python_version < "3.14" -pandas==2.3.3 ; python_version >= "3.11" and python_version < "3.14" -patsy==1.0.2 ; python_version >= "3.11" and python_version < "3.14" -pbs-installer==2026.6.10 ; python_version >= "3.11" and python_version < "3.14" -pillow==12.2.0 ; python_version >= "3.11" and python_version < "3.14" -pkginfo==1.12.1.2 ; python_version >= "3.11" and python_version < "3.14" -platformdirs==4.10.0 ; python_version >= "3.11" and python_version < "3.14" -plotly==6.8.0 ; python_version >= "3.11" and python_version < "3.14" -pluggy==1.6.0 ; python_version >= "3.11" and python_version < "3.14" -poetry-core==2.4.0 ; python_version >= "3.11" and python_version < "3.14" -poetry==2.4.1 ; python_version >= "3.11" and python_version < "3.14" -propcache==0.5.2 ; python_version >= "3.11" and python_version < "3.14" -protobuf==7.35.1 ; python_version >= "3.11" and python_version < "3.14" -psutil==7.2.2 ; python_version >= "3.11" and python_version < "3.14" -pycparser==3.0 ; python_version >= "3.11" and python_version < "3.14" and (sys_platform == "linux" or sys_platform == "darwin") and implementation_name != "PyPy" and (platform_python_implementation != "PyPy" or sys_platform == "darwin") -pydantic-core==2.46.4 ; python_version >= "3.11" and python_version < "3.14" -pydantic==2.13.4 ; python_version >= "3.11" and python_version < "3.14" -pygments==2.20.0 ; python_version >= "3.11" and python_version < "3.14" -pyparsing==3.3.2 ; python_version >= "3.11" and python_version < "3.14" -pyproject-hooks==1.2.0 ; python_version >= "3.11" and python_version < "3.14" -pytest==7.4.4 ; python_version >= "3.11" and python_version < "3.14" -python-dateutil==2.9.0.post0 ; python_version >= "3.11" and python_version < "3.14" -python-discovery==1.4.2 ; python_version >= "3.11" and python_version < "3.14" -pytorch-lightning==2.6.5 ; python_version >= "3.11" and python_version < "3.14" -pytz==2026.2 ; python_version >= "3.11" and python_version < "3.14" -pywin32-ctypes==0.2.3 ; python_version >= "3.11" and python_version < "3.14" and sys_platform == "win32" -pyyaml==6.0.3 ; python_version >= "3.11" and python_version < "3.14" -rapidfuzz==3.14.5 ; python_version >= "3.11" and python_version < "3.14" -requests-toolbelt==1.0.0 ; python_version >= "3.11" and python_version < "3.14" -requests==2.34.2 ; python_version >= "3.11" and python_version < "3.14" -rich==15.0.0 ; python_version >= "3.11" and python_version < "3.14" -scikit-learn==1.9.0 ; python_version >= "3.11" and python_version < "3.14" -scikit-posthocs==0.14.0 ; python_version >= "3.11" and python_version < "3.14" -scipy==1.17.1 ; python_version >= "3.11" and python_version < "3.14" -seaborn==0.13.2 ; python_version >= "3.11" and python_version < "3.14" -secretstorage==3.5.0 ; python_version >= "3.11" and python_version < "3.14" and sys_platform == "linux" -sentry-sdk==2.62.0 ; python_version >= "3.11" and python_version < "3.14" -setuptools==81.0.0 ; python_version >= "3.11" and python_version < "3.14" -shellingham==1.5.4 ; python_version >= "3.11" and python_version < "3.14" -six==1.17.0 ; python_version >= "3.11" and python_version < "3.14" -smmap==5.0.3 ; python_version >= "3.11" and python_version < "3.14" -starlette==1.3.0 ; python_version >= "3.11" and python_version < "3.14" -statsmodels==0.14.6 ; python_version >= "3.11" and python_version < "3.14" -subword-nmt==0.3.8 ; python_version >= "3.11" and python_version < "3.14" -sympy==1.14.0 ; python_version >= "3.11" and python_version < "3.14" -threadpoolctl==3.6.0 ; python_version >= "3.11" and python_version < "3.14" -toml==0.10.2 ; python_version >= "3.11" and python_version < "3.14" -tomlkit==0.15.0 ; python_version >= "3.11" and python_version < "3.14" -torch-geometric==2.8.0 ; python_version >= "3.11" and python_version < "3.14" -torch==2.12.0 ; python_version >= "3.11" and python_version < "3.14" -torchmetrics==1.9.0 ; python_version >= "3.11" and python_version < "3.14" -tornado==6.5.7 ; python_version >= "3.11" and python_version < "3.14" and sys_platform != "emscripten" -tqdm==4.68.2 ; python_version >= "3.11" and python_version < "3.14" -triton==3.7.0 ; python_version >= "3.11" and python_version < "3.14" and platform_system == "Linux" -trove-classifiers==2026.6.1.19 ; python_version >= "3.11" and python_version < "3.14" -typer==0.26.7 ; python_version >= "3.11" and python_version < "3.14" -typing-extensions==4.15.0 ; python_version >= "3.11" and python_version < "3.14" -typing-inspection==0.4.2 ; python_version >= "3.11" and python_version < "3.14" -tzdata==2026.2 ; python_version >= "3.11" and python_version < "3.14" -urllib3==2.7.0 ; python_version >= "3.11" and python_version < "3.14" -virtualenv==21.4.3 ; python_version >= "3.11" and python_version < "3.14" -wandb==0.27.2 ; python_version >= "3.11" and python_version < "3.14" -xattr==1.3.0 ; python_version >= "3.11" and python_version < "3.14" and sys_platform == "darwin" -xxhash==3.7.0 ; python_version >= "3.11" and python_version < "3.14" -xyzservices==2026.3.0 ; python_version >= "3.11" and python_version < "3.14" -yarl==1.24.2 ; python_version >= "3.11" and python_version < "3.14" -zipp==4.1.0 ; python_version == "3.11" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b8a8c9ac9..000000000 --- a/setup.cfg +++ /dev/null @@ -1,24 +0,0 @@ -[metadata] -license_files = LICENSE.txt -[tool.black] -line-length = 120 -[tool.isort] -multi_line_output=3 -include_trailing_comma=true -balanced_wrapping=true -line_length=120 -profile = "black" -[flake8] -max-line-length = 120 -max-complexity = 10 -docstring-convention = google -per-file-ignores = - tests/*:S101,S301,S403 - tests/cli/*:S101,D103,DAR101,DAR103 - drevalpy/cli/*:D103,DAR101,DAR103,DAR201 - drevalpy/cli_run_cv.py:DAR101,DAR401 - drevalpy/cli_preprocess_custom.py:DAR101 - drevalpy/cli_model_testing.py:DAR101,DAR201,DAR401 - drevalpy/visualization/create_report.py:DAR101 - drevalpy/datasets/curvecurator.py:S404,S603 -docstring_style = sphinx diff --git a/tests/_barrel_surface.py b/tests/_barrel_surface.py new file mode 100644 index 000000000..956f79e83 --- /dev/null +++ b/tests/_barrel_surface.py @@ -0,0 +1,128 @@ +"""Shared assertions for the ``test_init.py`` package-surface tests. + +Every re-export barrel in ``drevalpy`` is pinned the same four ways: ``__all__`` +is sorted and duplicate-free, it matches a surface recorded by hand in the test +file, every promised name resolves, and each re-export ``is`` the very object its +defining module holds. Seven ``test_init.py`` files spelled that idiom out one +assertion at a time; it lives here once instead, and each barrel test contributes +only its data plus whatever is genuinely specific to it. + +What deliberately stays in the barrel test files is the *recorded surface* - the +``name -> defining module`` table. Deriving it from ``__all__`` at runtime would +make :meth:`DeclaredSurface.test_all_matches_the_recorded_surface` unfalsifiable, +and the reason for recording it by hand is that a reviewer reading a diff sees +exactly which public name changed. + +Origins belong against the module the barrel does *not* import from - typically +the leaf that defines the object, sometimes a second re-export of it. Comparing a +re-export with the module it was imported from directly cannot fail. + +Each assertion reports every offending name rather than stopping at the first, +which is what the parametrised originals bought with one test item per name. +""" + +from __future__ import annotations + +import importlib +from collections.abc import Mapping +from types import ModuleType +from typing import ClassVar + +#: Tells an absent attribute apart from one that is legitimately ``None``. +_MISSING = object() + + +def _resolve(module: ModuleType, name: str) -> object: + """Return the attribute, or :data:`_MISSING` when the module does not have it.""" + return getattr(module, name, _MISSING) + + +def _is_defining_object(barrel: ModuleType, name: str, origin: str) -> bool: + """Return whether ``barrel.name`` is the object ``origin`` holds under that name.""" + exported = _resolve(barrel, name) + return exported is not _MISSING and exported is _resolve(importlib.import_module(origin), name) + + +class ReExportSurface: + """Pin a barrel against the surface its subclass records. + + Subclasses set :attr:`barrel` and whichever tables apply. This base makes no + assumption that the barrel publishes ``__all__`` - the top-level ``drevalpy`` + package marks its re-exports with ``import x as x`` instead. + """ + + #: The already-imported package whose surface is under test. + barrel: ClassVar[ModuleType] + + #: ``exported name -> import path of the module that defines it``. + origins: ClassVar[Mapping[str, str]] = {} + + #: Surface names with no recorded origin, either because the barrel defines + #: them itself or because the defining module is deliberately not pinned. + unpinned_names: ClassVar[tuple[str, ...]] = () + + #: Surface names the barrel promises are callable. + callable_names: ClassVar[tuple[str, ...]] = () + + @classmethod + def recorded_surface(cls) -> list[str]: + """Return the recorded names: those with an origin plus the unpinned ones.""" + return sorted({*cls.origins, *cls.unpinned_names}) + + @classmethod + def _names_that_must_resolve(cls) -> list[str]: + return cls.recorded_surface() + + def test_every_promised_name_resolves(self) -> None: + unresolved = [name for name in self._names_that_must_resolve() if _resolve(self.barrel, name) is _MISSING] + assert not unresolved, f"{self.barrel.__name__} promises names that do not resolve: {unresolved}" + + def test_export_is_the_object_its_defining_module_holds(self) -> None: + drifted = [ + name for name, origin in sorted(self.origins.items()) if not _is_defining_object(self.barrel, name, origin) + ] + assert not drifted, f"{self.barrel.__name__} re-exports that are not the defining object: {drifted}" + + def test_promised_callables_are_callable(self) -> None: + uncallable = [name for name in self.callable_names if not callable(_resolve(self.barrel, name))] + assert not uncallable, f"{self.barrel.__name__} promises callables that are not callable: {uncallable}" + + +class DeclaredSurface(ReExportSurface): + """A barrel that publishes ``__all__``, so the list itself is pinned too.""" + + @classmethod + def _names_that_must_resolve(cls) -> list[str]: + """Include ``__all__``: an entry left behind after its symbol moved is a broken import.""" + return sorted({*super()._names_that_must_resolve(), *cls.barrel.__all__}) + + def test_all_is_sorted_and_unique(self) -> None: + declared = list(self.barrel.__all__) + assert declared == sorted(set(declared)) + + def test_all_matches_the_recorded_surface(self) -> None: + """Catch drift in both directions: a stale entry and an unrecorded addition.""" + assert sorted(self.barrel.__all__) == self.recorded_surface() + + +class SingletonFacadeSurface(DeclaredSurface): + """A barrel whose module-level ``list``/``get``/``metadata`` wrap one registry singleton. + + The forwarding check is read-only: it never calls ``register``, so the + process-global registry is left exactly as it was found. + """ + + #: The process-global registry the facade functions delegate to. + singleton: ClassVar[object] + + #: Singleton attribute listing the registered keys - ``modes`` or ``names``. + keys_attribute: ClassVar[str] + + def test_facade_reads_forward_to_the_singleton(self) -> None: + """A facade that stopped delegating would silently serve an empty registry.""" + keys = self.barrel.list() + assert keys == getattr(self.singleton, self.keys_attribute) + assert keys, "the built-in components are registered before every test" + for key in keys: + assert self.barrel.get(key) is self.singleton.get(key) + assert self.barrel.metadata(key) == self.singleton.get_metadata(key) diff --git a/tests/_import_shims.py b/tests/_import_shims.py new file mode 100644 index 000000000..ea0b91120 --- /dev/null +++ b/tests/_import_shims.py @@ -0,0 +1,42 @@ +"""Make an optional third-party import fail, to exercise the guidance it raises. + +Every featurizer that reaches for an optional dependency inside a method turns an +``ImportError`` into an actionable message naming the extra to install. Pinning +that message means faking the missing package, and the only lever that reaches an +import statement inside an already-imported module is ``builtins.__import__``. +Nine tests had each written out the same shim. + +Sibling of :mod:`tests._barrel_surface` and :mod:`tests._trusted_subprocess`: an +``_``-prefixed module at the ``tests/`` root, imported as +``from tests._import_shims import block_imports``. The underscore keeps it out of +collection, and the mirror policy walks ``drevalpy/`` only, so no mirrored test is +demanded for it. +""" + +from __future__ import annotations + +import builtins + +import pytest + + +def block_imports(monkeypatch: pytest.MonkeyPatch, *prefixes: str) -> None: + """Raise ``ImportError`` for any module whose name starts with a given prefix. + + Patches ``builtins.__import__`` rather than deleting from ``sys.modules``, + because the module under test imports the optional dependency inside the + function being called - by then a ``sys.modules`` edit is too late, and an + already-imported package would be found again. + + :param monkeypatch: Pytest fixture; the patch is undone at teardown. + :param prefixes: Module-name prefixes to reject. ``"torch"`` rejects + ``torch_geometric`` too, which is what every caller wants. + """ + real_import = builtins.__import__ + + def _guarded_import(name, *args, **kwargs): + if name.startswith(prefixes): + raise ImportError(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _guarded_import) diff --git a/tests/_trusted_subprocess.py b/tests/_trusted_subprocess.py new file mode 100644 index 000000000..974f6c900 --- /dev/null +++ b/tests/_trusted_subprocess.py @@ -0,0 +1,36 @@ +"""Trusted subprocess boundary for test isolation checks.""" + +from __future__ import annotations + +import subprocess # noqa: S404 +import sys +from collections.abc import Sequence +from typing import Any + + +def run_trusted_python( + script: str, + *, + cwd: str | None = None, + extra_args: Sequence[str] | None = None, + **kwargs: Any, +) -> subprocess.CompletedProcess[str]: + """Run an inline Python script in a fresh interpreter for test isolation. + + :param script: Python source executed via ``python -c``. + :param cwd: Optional working directory for the child process. + :param extra_args: Additional argv entries appended after ``-c`` and *script*. + :param kwargs: Forwarded to ``subprocess.run`` (except ``check``). + :returns: Completed process with captured stdout/stderr. + """ + command = [sys.executable, "-c", script] + if extra_args: + command.extend(extra_args) + return subprocess.run( # noqa: S603 + command, + check=False, + capture_output=True, + text=True, + cwd=cwd, + **kwargs, + ) diff --git a/tests/cli/_helpers.py b/tests/cli/_helpers.py new file mode 100644 index 000000000..8f7ff402f --- /dev/null +++ b/tests/cli/_helpers.py @@ -0,0 +1,145 @@ +"""Shared fixtures-free helpers for the ``drevalpy.cli`` test modules. + +Every CLI command body imports its heavy dependencies lazily, so the testing +lever throughout ``tests/cli`` is to monkeypatch the worker in its *source* +module (``drevalpy._run.run``, ``drevalpy.data.split``, ...) and assert on the +kwargs the command forwarded. The stubs below stand in for the objects those +workers return. + +Note that typer >= 0.26 follows click 8.2, where ``mix_stderr`` is gone and +stderr is merged into ``result.output``; assertions on error text therefore use +``result.output`` rather than ``result.stderr``. +""" + +from __future__ import annotations + +import importlib +import re +from typing import Any + +import pytest +from typer.testing import CliRunner +from upath import UPath + +#: Rich renders help output with colour whenever a TTY-ish env is detected. +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") + +#: A wide terminal keeps rich from wrapping option names mid-token. +HELP_ENV = {"COLUMNS": "200", "NO_COLOR": "1", "TERM": "dumb"} + + +def make_runner() -> CliRunner: + """Build a fresh ``CliRunner``. + + Returns: + A runner with click's default exception catching enabled, so failures + surface as ``result.exit_code`` plus ``result.exception``. + """ + return CliRunner() + + +def plain(text: str) -> str: + """Strip terminal escape codes so help-text assertions are colour-agnostic. + + Args: + text: Captured CLI output. + + Returns: + ``text`` without ANSI escape sequences. + """ + return _ANSI_ESCAPE.sub("", text) + + +def patch_worker(monkeypatch: pytest.MonkeyPatch, module_name: str, attribute: str, value: Any) -> None: + """Replace ``module_name.attribute`` given the module object, not a dotted string. + + The worker modules are private (``drevalpy._run``, ``drevalpy._single``, + ``drevalpy.experiment._randomization``, ...) precisely so that the public + ``drevalpy.run`` / ``drevalpy.single`` names can be the re-exported + *functions* without shadowing a module. Resolving the private module through + :func:`importlib.import_module` patches the object that a command body's + ``from drevalpy._run import run`` actually reads. + + Args: + monkeypatch: Fixture performing the (reverted) assignment. + module_name: Dotted name of the module owning the worker. + attribute: Name of the worker within that module. + value: Replacement callable. + """ + monkeypatch.setattr(importlib.import_module(module_name), attribute, value) + + +class FakeMuData: + """Minimal ``mdata`` stand-in that records the paths it was asked to write.""" + + def __init__(self) -> None: + """Start with an empty write log.""" + self.written: list[str] = [] + + def write(self, path: str) -> None: + """Record ``path`` and create a placeholder file there. + + Args: + path: Destination the command chose for this dataset. + """ + self.written.append(path) + UPath(path).write_text("stub-h5mu") + + +class FakeDataset: + """Stand-in for :class:`drevalpy.types.data.dataset.Dataset`. + + Only the attributes the CLI touches are implemented: ``name`` for the echoed + summary, ``mdata`` for the write-out and ``randomization`` for the filename + that ``experiments randomization`` derives. + """ + + def __init__( + self, + name: str = "StubDataset", + randomization: tuple[str, str] | None = None, + ) -> None: + """Create a dataset stub. + + Args: + name: Value exposed as ``Dataset.name``. + randomization: Value exposed as ``Dataset.randomization``. + """ + self.name = name + self.randomization = randomization + self.mdata = FakeMuData() + + +class Recorder: + """Callable that records every call's positional and keyword arguments.""" + + def __init__(self, return_value: Any = None) -> None: + """Create a recorder. + + Args: + return_value: Value handed back to the caller on every invocation. + """ + self.return_value = return_value + self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + """Record the call and return the configured value.""" + self.calls.append((args, kwargs)) + return self.return_value + + @property + def call_count(self) -> int: + """Number of times the recorder was invoked.""" + return len(self.calls) + + @property + def args(self) -> tuple[Any, ...]: + """Positional arguments of the single recorded call.""" + assert self.call_count == 1, f"expected exactly one call, got {self.call_count}" + return self.calls[0][0] + + @property + def kwargs(self) -> dict[str, Any]: + """Keyword arguments of the single recorded call.""" + assert self.call_count == 1, f"expected exactly one call, got {self.call_count}" + return self.calls[0][1] diff --git a/tests/cli/catalog/test_init.py b/tests/cli/catalog/test_init.py new file mode 100644 index 000000000..ff7e5d918 --- /dev/null +++ b/tests/cli/catalog/test_init.py @@ -0,0 +1,83 @@ +"""Tests for the :mod:`drevalpy.cli.catalog` command group surface.""" + +from __future__ import annotations + +import pytest + +from drevalpy.cli.catalog import list_app +from drevalpy.cli.catalog import plugins as plugins_module +from drevalpy.cli.catalog import registries as registries_module +from drevalpy.cli.main import app +from tests.cli._helpers import HELP_ENV, make_runner, plain + +runner = make_runner() + +#: The command surface documented in the plan, one per registry plus plugins. +COMMAND_NAMES = [ + "predictors", + "cell-line-featurizers", + "drug-featurizers", + "splitters", + "visualizations", + "plugins", +] + +EXPECTED_CALLBACKS = { + "predictors": registries_module.list_predictors, + "cell-line-featurizers": registries_module.list_cell_line_featurizers, + "drug-featurizers": registries_module.list_drug_featurizers, + "splitters": registries_module.list_splitters, + "visualizations": registries_module.list_visualizations, + "plugins": plugins_module.list_plugins, +} + + +class TestGroup: + """The group is help-first and exposes exactly the six commands.""" + + def test_app_name(self) -> None: + assert list_app.info.name == "list" + + def test_registered_command_names(self) -> None: + assert {command.name for command in list_app.registered_commands} == set(COMMAND_NAMES) + + def test_no_nested_groups(self) -> None: + assert list_app.registered_groups == [] + + def test_commands_wrap_the_source_callbacks(self) -> None: + callbacks = {command.name: command.callback for command in list_app.registered_commands} + + assert callbacks == EXPECTED_CALLBACKS + + def test_bare_group_prints_help(self) -> None: + result = runner.invoke(app, ["list"], env=HELP_ENV) + + assert "Usage" in plain(result.output) + + def test_bare_group_exits_nonzero(self) -> None: + result = runner.invoke(app, ["list"], env=HELP_ENV) + + assert result.exit_code != 0 + + @pytest.mark.parametrize("command", COMMAND_NAMES, ids=COMMAND_NAMES) + def test_help_lists_each_command(self, command: str) -> None: + result = runner.invoke(app, ["list", "--help"], env=HELP_ENV) + + assert command in plain(result.output) + + @pytest.mark.parametrize("command", COMMAND_NAMES, ids=COMMAND_NAMES) + def test_dash_h_reaches_each_command(self, command: str) -> None: + result = runner.invoke(app, ["list", command, "-h"], env=HELP_ENV) + + assert result.exit_code == 0 + assert "Usage" in plain(result.output) + + def test_unknown_subcommand_is_a_usage_error(self) -> None: + result = runner.invoke(app, ["list", "not-a-command"], env=HELP_ENV) + + assert result.exit_code == 2 + + def test_all_lists_only_the_app(self) -> None: + from drevalpy.cli import catalog + + assert catalog.__all__ == ["list_app"] diff --git a/tests/cli/catalog/test_plugins.py b/tests/cli/catalog/test_plugins.py new file mode 100644 index 000000000..fe937067f --- /dev/null +++ b/tests/cli/catalog/test_plugins.py @@ -0,0 +1,352 @@ +"""Tests for :mod:`drevalpy.cli.catalog.plugins`, the ``drevalpy list plugins`` command. + +The command reads three process-global ledgers - +:func:`~drevalpy.registry.get_loaded_plugins`, +:func:`~drevalpy.registry.get_failed_plugins` and +:func:`~drevalpy.registry.get_skipped_builtin_modules` - plus the entry points +declared in installed distribution metadata. A real environment has no broken +plugin in it, so the interesting states are produced by patching those four +boundaries; that is also why the ledgers are patched rather than mutated, so the +rest of the suite sees them unchanged. +""" + +from __future__ import annotations + +import importlib +import importlib.metadata +from typing import Any + +import pytest + +from drevalpy.cli.catalog import plugins as plugins_module +from drevalpy.cli.main import app +from tests.cli._helpers import HELP_ENV, make_runner, plain + +runner = make_runner() + +TRACEBACK = 'Traceback (most recent call last):\n File "x.py", line 1\n boom\nRuntimeError: boom\n' + + +class StubEntryPoint: + """Minimal stand-in for :class:`importlib.metadata.EntryPoint`.""" + + def __init__(self, name: str, value: str) -> None: + """Record the declared name and target. + + Args: + name: Entry-point name, as it appears in the distribution metadata. + value: Dotted object reference the plugin declared. + """ + self.name = name + self.value = value + + +@pytest.fixture() +def declared(monkeypatch: pytest.MonkeyPatch) -> list[StubEntryPoint]: + """Replace entry-point discovery with a list the test can fill. + + Args: + monkeypatch: Fixture used to patch the ``importlib.metadata`` boundary. + + Returns: + The mutable list of entry points the command will see. + """ + entries: list[StubEntryPoint] = [] + monkeypatch.setattr(importlib.metadata, "entry_points", lambda **_: entries) + return entries + + +@pytest.fixture() +def ledgers(monkeypatch: pytest.MonkeyPatch) -> dict[str, dict[str, str]]: + """Give the command empty loaded/failed/skipped ledgers it can be handed. + + Args: + monkeypatch: Fixture used to replace the registry accessors. + + Returns: + Mapping with ``loaded``, ``failed`` and ``skipped`` dicts, each mutable + and read by the command on every invocation. + """ + from drevalpy import registry + + state = {"loaded": {}, "failed": {}, "skipped": {}} + monkeypatch.setattr(registry, "get_loaded_plugins", lambda: dict(state["loaded"])) + monkeypatch.setattr(registry, "get_failed_plugins", lambda: dict(state["failed"])) + monkeypatch.setattr(registry, "get_skipped_builtin_modules", lambda: dict(state["skipped"])) + return state + + +def invoke(*argv: str) -> Any: + """Run ``drevalpy list plugins`` and return the click result. + + Args: + *argv: Extra arguments appended after ``plugins``. + + Returns: + The ``click.testing.Result`` of the invocation. + """ + return runner.invoke(app, ["list", "plugins", *argv], env=HELP_ENV) + + +def text(*argv: str) -> str: + """Return the plain-text output of ``drevalpy list plugins``. + + Args: + *argv: Extra arguments appended after ``plugins``. + + Returns: + Output with escape codes stripped. + """ + return plain(invoke(*argv).output) + + +class TestOptions: + """The two options are documented in the command's own help. + + That the command is wired into the group at all is pinned in ``test_init.py``. + """ + + def test_help_documents_the_traceback_option(self) -> None: + assert "--traceback" in text("--help") + + def test_help_documents_the_strict_option(self) -> None: + assert "--strict" in text("--help") + + +class TestNoPlugins: + """An environment without plugins says so, and says how to check.""" + + def test_exits_cleanly(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + assert invoke().exit_code == 0 + + def test_explains_that_nothing_declares_the_entry_point( + self, declared: list[StubEntryPoint], ledgers: dict[str, Any] + ) -> None: + assert "No packages declare a drevalpy.plugins entry point" in text() + + def test_names_the_likely_cause(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + assert "installed into this interpreter" in text() + + def test_prints_no_table(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + assert "Status" not in text() + + +class TestLoadedPlugin: + """A healthy plugin is listed as loaded, with the object it points at.""" + + @pytest.fixture(autouse=True) + def healthy(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + """Declare one plugin and mark it loaded.""" + declared.append(StubEntryPoint("my_plugin", "my_plugin.register:setup")) + ledgers["loaded"]["my_plugin"] = "my_plugin.register:setup" + + def test_exits_cleanly(self) -> None: + assert invoke().exit_code == 0 + + def test_lists_the_plugin_name(self) -> None: + assert "my_plugin" in text() + + def test_reports_the_loaded_status(self) -> None: + assert plugins_module.STATUS_LOADED in text() + + def test_shows_the_entry_point_target(self) -> None: + assert "my_plugin.register:setup" in text() + + def test_reports_no_failures(self) -> None: + assert "failed to load" not in text() + + def test_strict_mode_still_succeeds(self) -> None: + assert invoke("--strict").exit_code == 0 + + +class TestFailedPlugin: + """A plugin that raised is named together with the reason it raised.""" + + @pytest.fixture(autouse=True) + def broken(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + """Declare one plugin and record an import failure for it.""" + declared.append(StubEntryPoint("broken_plugin", "broken_plugin:setup")) + ledgers["failed"]["broken_plugin"] = TRACEBACK + + def test_reports_the_failed_status(self) -> None: + assert plugins_module.STATUS_FAILED in text() + + def test_names_the_plugin_in_the_failure_report(self) -> None: + assert "broken_plugin" in text() + + def test_reports_how_many_failed(self) -> None: + assert "1 plugin(s) failed to load" in text() + + def test_surfaces_the_exception_line(self) -> None: + """The last traceback line is the actionable part; it must be visible.""" + assert "RuntimeError: boom" in text() + + def test_omits_the_stack_frames_by_default(self) -> None: + assert "Traceback (most recent call last)" not in text() + + def test_advertises_the_traceback_option(self) -> None: + assert "--traceback" in text() + + def test_traceback_option_prints_the_stack(self) -> None: + assert "Traceback (most recent call last)" in text("--traceback") + + def test_traceback_option_drops_the_advert(self) -> None: + assert "Re-run with --traceback" not in text("--traceback") + + def test_default_exit_code_is_zero(self) -> None: + """Reporting a broken plugin is the command working, not failing.""" + assert invoke().exit_code == 0 + + def test_strict_option_makes_a_failure_fatal(self) -> None: + assert invoke("--strict").exit_code == 1 + + def test_strict_option_still_prints_the_report(self) -> None: + assert "RuntimeError: boom" in text("--strict") + + +class TestNotLoadedPlugin: + """A declared entry point in neither ledger is reported as not loaded. + + This is what a plugin looks like when discovery has not run in the current + process, so it must not be silently rendered as healthy. + """ + + @pytest.fixture(autouse=True) + def undiscovered(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + """Declare a plugin without recording either outcome for it.""" + declared.append(StubEntryPoint("unseen", "unseen:setup")) + + def test_reports_the_not_loaded_status(self) -> None: + assert plugins_module.STATUS_NOT_LOADED in text() + + def test_is_not_counted_as_a_failure(self) -> None: + assert "failed to load" not in text() + + def test_strict_mode_ignores_it(self) -> None: + assert invoke("--strict").exit_code == 0 + + +class TestUndeclaredButLoaded: + """A ledger entry with no matching metadata is still listed. + + Plugin metadata and the ledgers are read from different places, so they can + disagree; dropping the difference would hide the more surprising half. + """ + + def test_a_loaded_plugin_missing_from_metadata_is_listed( + self, declared: list[StubEntryPoint], ledgers: dict[str, Any] + ) -> None: + ledgers["loaded"]["ghost"] = "ghost:setup" + + assert "ghost" in text() + + def test_a_failed_plugin_missing_from_metadata_is_listed( + self, declared: list[StubEntryPoint], ledgers: dict[str, Any] + ) -> None: + ledgers["failed"]["ghost"] = TRACEBACK + + assert "ghost" in text() + + +class TestOrdering: + """Plugins are listed in a stable, name-sorted order.""" + + def test_rows_are_sorted_by_name(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + for name in ("zeta", "alpha", "mu"): + declared.append(StubEntryPoint(name, f"{name}:setup")) + ledgers["loaded"][name] = f"{name}:setup" + printed = text() + + assert printed.index("alpha") < printed.index("mu") < printed.index("zeta") + + def test_failures_are_sorted_by_name(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + for name in ("zeta", "alpha"): + declared.append(StubEntryPoint(name, f"{name}:setup")) + ledgers["failed"][name] = f"ValueError: {name} exploded" + printed = text() + + assert printed.index("alpha exploded") < printed.index("zeta exploded") + + +class TestSkippedBuiltins: + """A skipped built-in module looks like a missing component too.""" + + def test_is_reported(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + ledgers["skipped"]["drevalpy.components.predictors.thing"] = TRACEBACK + + assert "drevalpy.components.predictors.thing" in text() + + def test_reports_how_many_were_skipped(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + ledgers["skipped"]["drevalpy.components.predictors.thing"] = TRACEBACK + + assert "1 built-in module(s) were skipped" in text() + + def test_surfaces_the_reason(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + ledgers["skipped"]["drevalpy.components.predictors.thing"] = TRACEBACK + + assert "RuntimeError: boom" in text() + + def test_is_not_counted_as_a_plugin_failure(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + ledgers["skipped"]["drevalpy.components.predictors.thing"] = TRACEBACK + + assert "failed to load" not in text() + + def test_does_not_make_strict_mode_fail(self, declared: list[StubEntryPoint], ledgers: dict[str, Any]) -> None: + """``--strict`` is about the caller's plugins, not drevalpy's own builtins.""" + ledgers["skipped"]["drevalpy.components.predictors.thing"] = TRACEBACK + + assert invoke("--strict").exit_code == 0 + + def test_nothing_is_printed_when_none_were_skipped( + self, declared: list[StubEntryPoint], ledgers: dict[str, Any] + ) -> None: + assert "skipped" not in text() + + +class TestFailureReason: + """``failure_reason`` reduces a traceback to its last, useful line.""" + + def test_returns_the_exception_line(self) -> None: + assert plugins_module.failure_reason(TRACEBACK) == "RuntimeError: boom" + + def test_ignores_trailing_blank_lines(self) -> None: + assert plugins_module.failure_reason("ValueError: nope\n\n\n") == "ValueError: nope" + + def test_handles_a_single_line(self) -> None: + assert plugins_module.failure_reason("ImportError: no module") == "ImportError: no module" + + @pytest.mark.parametrize("recorded", ["", "\n", " \n \n"], ids=["empty", "newline", "whitespace"]) + def test_empty_input_yields_a_placeholder(self, recorded: str) -> None: + assert plugins_module.failure_reason(recorded) == "unknown error" + + +class TestRegistryImportFailure: + """With strict mode on, importing the registry is itself what fails.""" + + @pytest.fixture() + def broken_import(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Make importing :mod:`drevalpy.registry` raise the way a plugin would. + + Only that one name is diverted; everything else still imports normally, + which matters because the failure path itself imports rich. + """ + real = importlib.import_module + + def fake(name: str, package: str | None = None) -> Any: + if name == "drevalpy.registry": + raise RuntimeError("plugin blew up during discovery") + return real(name, package) + + monkeypatch.setattr(importlib, "import_module", fake) + + def test_exit_code_is_one(self, broken_import: None) -> None: + assert invoke().exit_code == 1 + + def test_explains_what_failed(self, broken_import: None) -> None: + assert "Importing drevalpy.registry failed while loading plugins." in text() + + def test_prints_the_underlying_error(self, broken_import: None) -> None: + assert "plugin blew up during discovery" in text() + + def test_does_not_escape_as_an_unhandled_exception(self, broken_import: None) -> None: + assert isinstance(invoke().exception, SystemExit) diff --git a/tests/cli/catalog/test_registries.py b/tests/cli/catalog/test_registries.py new file mode 100644 index 000000000..08f8ac738 --- /dev/null +++ b/tests/cli/catalog/test_registries.py @@ -0,0 +1,176 @@ +"""Tests for :mod:`drevalpy.cli.catalog.registries`, the five per-registry commands. + +The commands run against the real registries: they are populated on +``import drevalpy.registry`` and reading them has no side effects, so a stub +would only pin the stub. What each test asserts is therefore a name that is +registered by construction (a naive baseline, an ``LPO`` split mode), never a +row count. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.cli.catalog import list_app +from drevalpy.cli.main import app +from tests.cli._helpers import HELP_ENV, make_runner, plain + +runner = make_runner() + +#: ``(command, registry attribute, a name that registry always contains)``. +REGISTRY_COMMANDS = [ + pytest.param("predictors", "predictor", "naiveMean", id="predictors"), + pytest.param("cell-line-featurizers", "cell_line_featurizer", "identity", id="cell-line-featurizers"), + pytest.param("drug-featurizers", "drug_featurizer", "fingerprints", id="drug-featurizers"), + pytest.param("splitters", "splitter", "LPO", id="splitters"), + pytest.param("visualizations", "visualization", "heatmap", id="visualizations"), +] + +COMMAND_NAMES = [param.values[0] for param in REGISTRY_COMMANDS] + + +def invoke(*argv: str) -> str: + """Run ``drevalpy list`` and return its plain-text output. + + Args: + *argv: Arguments after ``list``. + + Returns: + Output with escape codes stripped. + """ + return plain(runner.invoke(app, ["list", *argv], env=HELP_ENV).output) + + +class TestGroupWiring: + """The five registry commands reach the root app. + + The group's own surface (names, help, usage errors) is pinned in + ``test_init.py``; what matters here is that ``drevalpy list`` is reachable + from the root app at all. + """ + + def test_root_help_advertises_the_group(self) -> None: + result = runner.invoke(app, ["--help"], env=HELP_ENV) + + assert "list" in plain(result.output) + + @pytest.mark.parametrize("command", COMMAND_NAMES, ids=COMMAND_NAMES) + def test_each_command_is_reachable(self, command: str) -> None: + assert list_app.registered_commands, "the group registers its commands at import time" + assert command in invoke("--help") + + +class TestTables: + """Each command renders its registry's ``table()``.""" + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_exits_cleanly(self, command: str, attribute: str, entry: str) -> None: + result = runner.invoke(app, ["list", command], env=HELP_ENV) + + assert result.exit_code == 0, result.output + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_lists_a_known_entry(self, command: str, attribute: str, entry: str) -> None: + assert entry in invoke(command) + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_prints_the_registry_column_headers(self, command: str, attribute: str, entry: str) -> None: + from drevalpy import registry + + columns = [str(column) for column in getattr(registry, attribute).table().columns] + printed = invoke(command) + + assert all(column in printed for column in columns) + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_reports_the_entry_count(self, command: str, attribute: str, entry: str) -> None: + from drevalpy import registry + + expected = len(getattr(registry, attribute).list()) + + assert f"{expected} entries" in invoke(command) + + +class TestSingleEntry: + """A positional name switches to that entry's ``metadata()``.""" + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_exits_cleanly(self, command: str, attribute: str, entry: str) -> None: + result = runner.invoke(app, ["list", command, entry], env=HELP_ENV) + + assert result.exit_code == 0, result.output + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_prints_every_metadata_field(self, command: str, attribute: str, entry: str) -> None: + from drevalpy import registry + + metadata = getattr(registry, attribute).metadata(entry) + printed = invoke(command, entry) + + assert all(field in printed for field in metadata) + + def test_prints_the_description(self) -> None: + from drevalpy import registry + + description = registry.predictor.metadata("naiveMean")["description"] + + assert description in invoke("predictors", "naiveMean") + + def test_does_not_print_the_whole_table(self) -> None: + """Asking for one predictor must not dump the other twenty-odd.""" + printed = invoke("predictors", "naiveMean") + + assert "elasticNet" not in printed + + +class TestUnknownEntry: + """An unregistered name fails with the registry's own message.""" + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_exit_code_is_one(self, command: str, attribute: str, entry: str) -> None: + result = runner.invoke(app, ["list", command, "definitely-not-registered"], env=HELP_ENV) + + assert result.exit_code == 1 + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_names_the_missing_entry(self, command: str, attribute: str, entry: str) -> None: + assert "definitely-not-registered" in invoke(command, "definitely-not-registered") + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_message_lists_the_valid_entries(self, command: str, attribute: str, entry: str) -> None: + """The registry's ValueError enumerates what *is* registered; keep that.""" + assert entry in invoke(command, "definitely-not-registered") + + @pytest.mark.parametrize(("command", "attribute", "entry"), REGISTRY_COMMANDS) + def test_does_not_raise_out_of_the_command(self, command: str, attribute: str, entry: str) -> None: + result = runner.invoke(app, ["list", command, "definitely-not-registered"], env=HELP_ENV) + + assert isinstance(result.exception, SystemExit) + + +class TestEmptyRegistry: + """With nothing registered, the table is replaced by an explanation.""" + + def test_prints_a_hint_instead_of_an_empty_table(self, monkeypatch: pytest.MonkeyPatch) -> None: + import pandas as pd + + from drevalpy import registry + + monkeypatch.setattr( + registry.predictor, + "table", + lambda: pd.DataFrame(columns=["Name", "Description", "Tags"]), + ) + + assert "No predictors are registered." in invoke("predictors") + + def test_still_exits_cleanly(self, monkeypatch: pytest.MonkeyPatch) -> None: + """An empty registry is a diagnosis, not a CLI failure.""" + import pandas as pd + + from drevalpy import registry + + monkeypatch.setattr(registry.predictor, "table", lambda: pd.DataFrame(columns=["Name"])) + result = runner.invoke(app, ["list", "predictors"], env=HELP_ENV) + + assert result.exit_code == 0 diff --git a/tests/cli/catalog/test_render.py b/tests/cli/catalog/test_render.py new file mode 100644 index 000000000..fe867b121 --- /dev/null +++ b/tests/cli/catalog/test_render.py @@ -0,0 +1,222 @@ +"""Tests for :mod:`drevalpy.cli.catalog._render`, the rich rendering helpers. + +These exercise the helpers directly rather than through a command, because the +behaviour worth pinning is formatting: what an empty cell looks like, that a row +count is reported, and that author-supplied text containing square brackets is +not eaten as rich markup. +""" + +from __future__ import annotations + +from enum import Enum + +import pandas as pd +import pytest +from rich.text import Text + +from drevalpy.cli.catalog import _render +from tests.cli._helpers import plain + + +@pytest.fixture(autouse=True) +def wide_console(monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the console width so wrapping cannot split the asserted text. + + Args: + monkeypatch: Fixture used to set the environment rich reads. + """ + monkeypatch.setenv("COLUMNS", "300") + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setenv("TERM", "dumb") + + +class Flavour(Enum): + """Enum stand-in for the enum members registry metadata carries.""" + + VANILLA = 1 + + +def output(capsys: pytest.CaptureFixture[str]) -> str: + """Return the captured stdout with escape codes removed. + + Args: + capsys: Fixture holding the captured streams. + + Returns: + Plain-text stdout. + """ + return plain(capsys.readouterr().out) + + +class TestFormatValue: + """``format_value`` turns registry values into display text.""" + + @pytest.mark.parametrize( + ("value", "expected"), + [ + pytest.param(None, "-", id="none"), + pytest.param("", "-", id="empty-string"), + pytest.param(" ", "-", id="whitespace-only"), + pytest.param(" spaced ", "spaced", id="stripped"), + pytest.param(True, "yes", id="true"), + pytest.param(False, "no", id="false"), + pytest.param(0, "0", id="zero-is-not-empty"), + pytest.param(Flavour.VANILLA, "VANILLA", id="enum-member-name"), + pytest.param(frozenset({"b", "a"}), "a, b", id="frozenset-sorted"), + pytest.param(frozenset(), "-", id="empty-frozenset"), + pytest.param({"solo"}, "solo", id="set"), + pytest.param(["first", "second"], "first, second", id="list-keeps-order"), + pytest.param((), "-", id="empty-tuple"), + ], + ) + def test_formatting(self, value: object, expected: str) -> None: + assert _render.format_value(value) == expected + + def test_nested_enum_inside_a_frozenset(self) -> None: + assert _render.format_value(frozenset({Flavour.VANILLA})) == "VANILLA" + + +class TestRenderFrame: + """``render_frame`` prints a registry ``table()`` DataFrame.""" + + @pytest.fixture() + def frame(self) -> pd.DataFrame: + """A two-row frame shaped like a registry table.""" + return pd.DataFrame( + { + "Name": ["alpha", "beta"], + "Description": ["First one", "Second one"], + "Tags": [frozenset({"baseline"}), frozenset()], + } + ) + + def test_prints_the_title(self, frame: pd.DataFrame, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_frame(frame, title="Things", empty_hint="nothing") + + assert "Things" in output(capsys) + + def test_prints_the_column_headers(self, frame: pd.DataFrame, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_frame(frame, title="Things", empty_hint="nothing") + printed = output(capsys) + + assert "Name" in printed + assert "Description" in printed + assert "Tags" in printed + + def test_prints_every_row(self, frame: pd.DataFrame, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_frame(frame, title="Things", empty_hint="nothing") + printed = output(capsys) + + assert "alpha" in printed + assert "beta" in printed + + def test_formats_cell_values(self, frame: pd.DataFrame, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_frame(frame, title="Things", empty_hint="nothing") + printed = output(capsys) + + assert "baseline" in printed + assert _render.EMPTY_CELL in printed + + def test_reports_the_row_count(self, frame: pd.DataFrame, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_frame(frame, title="Things", empty_hint="nothing") + + assert "2 entries" in output(capsys) + + def test_row_count_is_singular_for_one_row(self, frame: pd.DataFrame, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_frame(frame.head(1), title="Things", empty_hint="nothing") + + assert "1 entry" in output(capsys) + + def test_empty_frame_prints_the_hint(self, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_frame(pd.DataFrame(columns=["Name"]), title="Things", empty_hint="Nothing registered.") + + assert "Nothing registered." in output(capsys) + + def test_empty_frame_prints_no_table(self, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_frame(pd.DataFrame(columns=["Name"]), title="Things", empty_hint="Nothing registered.") + + assert "Things" not in output(capsys) + + +class TestRenderRows: + """``render_rows`` is the shared primitive for pre-formatted tables.""" + + def test_styled_cells_render_their_text(self, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_rows( + [["plugin", Text("loaded", style="green")]], + columns=["Plugin", "Status"], + title="Plugins", + empty_hint="none", + ) + + assert "loaded" in output(capsys) + + def test_square_brackets_are_not_read_as_markup(self, capsys: pytest.CaptureFixture[str]) -> None: + """A description like ``[bold]`` must survive verbatim, not vanish.""" + _render.render_rows( + [["alpha", "features [bold] and more"]], + columns=["Name", "Description"], + title="Things", + empty_hint="none", + ) + + assert "[bold]" in output(capsys) + + def test_count_can_be_suppressed(self, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_rows( + [["a", "b"]], + columns=["Field", "Value"], + title="Thing", + empty_hint="none", + show_count=False, + ) + + assert "1 entry" not in output(capsys) + + def test_no_rows_prints_the_hint(self, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_rows([], columns=["Field"], title="Thing", empty_hint="Nothing here.") + + assert "Nothing here." in output(capsys) + + +class TestRenderMapping: + """``render_mapping`` prints a metadata dict as field/value pairs.""" + + def test_prints_the_name_as_the_title(self, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_mapping({"name": "alpha"}, title="alpha") + + assert "alpha" in output(capsys) + + def test_prints_field_and_value(self, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_mapping({"description": "Does a thing"}, title="alpha") + printed = output(capsys) + + assert "description" in printed + assert "Does a thing" in printed + + def test_does_not_report_a_field_count(self, capsys: pytest.CaptureFixture[str]) -> None: + """A field count is noise: the fields are fixed by the registry, not discovered.""" + _render.render_mapping({"name": "alpha", "description": "Does a thing"}, title="alpha") + + assert "entries" not in output(capsys) + + def test_empty_mapping_prints_the_hint(self, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_mapping({}, title="alpha") + + assert "No metadata recorded for alpha." in output(capsys) + + +class TestRenderEmpty: + """``render_empty`` is the shared not-found path.""" + + def test_prints_the_hint(self, capsys: pytest.CaptureFixture[str]) -> None: + _render.render_empty("Nothing at all.") + + assert "Nothing at all." in output(capsys) + + +class TestConsole: + """The console is built per call, not cached at import time.""" + + def test_returns_a_fresh_console_each_time(self) -> None: + assert _render.console() is not _render.console() diff --git a/tests/cli/data/test_init.py b/tests/cli/data/test_init.py new file mode 100644 index 000000000..17a2fff34 --- /dev/null +++ b/tests/cli/data/test_init.py @@ -0,0 +1,49 @@ +"""Tests for the :mod:`drevalpy.cli.data` command group.""" + +from __future__ import annotations + +import pytest + +from drevalpy.cli.data import data_app +from drevalpy.cli.data.load import load_dataset +from drevalpy.cli.data.split import split_dataset +from drevalpy.cli.main import app +from tests.cli._helpers import HELP_ENV, make_runner, plain + +runner = make_runner() + + +class TestGroup: + """The group is help-first and exposes exactly two commands.""" + + def test_app_name(self) -> None: + assert data_app.info.name == "data" + + def test_registered_command_names(self) -> None: + assert {command.name for command in data_app.registered_commands} == {"load", "split"} + + def test_commands_wrap_the_source_callbacks(self) -> None: + callbacks = {command.name: command.callback for command in data_app.registered_commands} + + assert callbacks == {"load": load_dataset, "split": split_dataset} + + def test_bare_group_prints_help(self) -> None: + result = runner.invoke(app, ["data"], env=HELP_ENV) + + assert "Usage" in plain(result.output) + + def test_bare_group_exits_nonzero(self) -> None: + result = runner.invoke(app, ["data"], env=HELP_ENV) + + assert result.exit_code != 0 + + @pytest.mark.parametrize("command", ["load", "split"], ids=["load", "split"]) + def test_help_lists_each_command(self, command: str) -> None: + result = runner.invoke(app, ["data", "--help"], env=HELP_ENV) + + assert command in plain(result.output) + + def test_unknown_subcommand_is_a_usage_error(self) -> None: + result = runner.invoke(app, ["data", "not-a-command"], env=HELP_ENV) + + assert result.exit_code == 2 diff --git a/tests/cli/data/test_load.py b/tests/cli/data/test_load.py new file mode 100644 index 000000000..7a5ba21b7 --- /dev/null +++ b/tests/cli/data/test_load.py @@ -0,0 +1,127 @@ +"""Tests for :mod:`drevalpy.cli.data.load`, the ``drevalpy data load`` command. + +:func:`drevalpy.data.load` resolves registered dataset names by +downloading them, so it is patched in every test here; the download path itself +belongs to ``tests/data/datasets``. +""" + +from __future__ import annotations + +import pytest +from upath import UPath + +from drevalpy.cli.main import app +from tests.cli._helpers import HELP_ENV, FakeDataset, Recorder, make_runner, patch_worker, plain + +runner = make_runner() + + +@pytest.fixture() +def dataset() -> FakeDataset: + """The dataset stub the patched loader returns.""" + return FakeDataset(name="TOYv1") + + +@pytest.fixture() +def loader(monkeypatch: pytest.MonkeyPatch, dataset: FakeDataset) -> Recorder: + """Patch :func:`drevalpy.data.load`. + + Args: + monkeypatch: Fixture used to replace the source-module worker. + dataset: Stub the loader hands back. + + Returns: + Recorder standing in for the dataset loader. + """ + recorder = Recorder(return_value=dataset) + patch_worker(monkeypatch, "drevalpy.data.datasets._load", "load", recorder) + patch_worker(monkeypatch, "drevalpy.data", "load", recorder) + return recorder + + +class TestArguments: + """Both positional arguments are required.""" + + @pytest.mark.parametrize( + "argv", + [ + pytest.param(["data", "load"], id="none"), + pytest.param(["data", "load", "TOYv1"], id="missing-output"), + ], + ) + def test_missing_positional_arguments_are_usage_errors(self, loader: Recorder, argv: list[str]) -> None: + result = runner.invoke(app, argv, env=HELP_ENV) + + assert result.exit_code == 2 + + def test_nothing_is_loaded_on_a_usage_error(self, loader: Recorder) -> None: + runner.invoke(app, ["data", "load", "TOYv1"], env=HELP_ENV) + + assert loader.call_count == 0 + + +class TestLoading: + """The name argument is forwarded verbatim and the mdata is written out.""" + + def test_exits_cleanly(self, loader: Recorder, tmp_path: UPath) -> None: + result = runner.invoke(app, ["data", "load", "TOYv1", str(tmp_path / "out.h5mu")]) + + assert result.exit_code == 0, result.output + + def test_forwards_the_dataset_name(self, loader: Recorder, tmp_path: UPath) -> None: + runner.invoke(app, ["data", "load", "TOYv1", str(tmp_path / "out.h5mu")]) + + assert loader.args == ("TOYv1",) + + def test_forwards_a_path_unchanged(self, loader: Recorder, tmp_path: UPath) -> None: + """A ``.h5mu`` path is a valid ``name``; the command must not rewrite it.""" + source = tmp_path / "local.h5mu" + + runner.invoke(app, ["data", "load", str(source), str(tmp_path / "out.h5mu")]) + + assert loader.args == (str(source),) + + def test_creates_the_output_parent_directory(self, loader: Recorder, tmp_path: UPath) -> None: + out = tmp_path / "nested" / "deeper" / "out.h5mu" + runner.invoke(app, ["data", "load", "TOYv1", str(out)]) + + assert out.parent.is_dir() + + def test_writes_to_the_requested_path(self, loader: Recorder, dataset: FakeDataset, tmp_path: UPath) -> None: + out = tmp_path / "out.h5mu" + runner.invoke(app, ["data", "load", "TOYv1", str(out)]) + + assert dataset.mdata.written == [str(out)] + + def test_echoes_the_dataset_name_and_destination(self, loader: Recorder, tmp_path: UPath) -> None: + out = tmp_path / "out.h5mu" + result = runner.invoke(app, ["data", "load", "TOYv1", str(out)]) + + assert f"Wrote TOYv1 to {out}" in plain(result.output) + + +class TestLoaderFailure: + """An unresolvable dataset name surfaces rather than writing a broken file.""" + + def test_loader_error_propagates(self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath) -> None: + def boom(name: str) -> FakeDataset: + raise KeyError(name) + + patch_worker(monkeypatch, "drevalpy.data.datasets._load", "load", boom) + patch_worker(monkeypatch, "drevalpy.data", "load", boom) + + result = runner.invoke(app, ["data", "load", "NoSuchDataset", str(tmp_path / "out.h5mu")]) + + assert isinstance(result.exception, KeyError) + + def test_no_output_file_is_written(self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath) -> None: + def boom(name: str) -> FakeDataset: + raise KeyError(name) + + patch_worker(monkeypatch, "drevalpy.data.datasets._load", "load", boom) + patch_worker(monkeypatch, "drevalpy.data", "load", boom) + out = tmp_path / "out.h5mu" + + runner.invoke(app, ["data", "load", "NoSuchDataset", str(out)]) + + assert not out.exists() diff --git a/tests/cli/data/test_split.py b/tests/cli/data/test_split.py new file mode 100644 index 000000000..39302797b --- /dev/null +++ b/tests/cli/data/test_split.py @@ -0,0 +1,153 @@ +"""Tests for :mod:`drevalpy.cli.data.split`, the ``drevalpy data split`` command.""" + +from __future__ import annotations + +import numpy as np +import pytest +from upath import UPath + +from drevalpy.cli.main import app +from drevalpy.types import SplitMask, SplitMasks +from tests.cli._helpers import HELP_ENV, FakeDataset, Recorder, make_runner, patch_worker, plain + +runner = make_runner() + +N_FOLDS = 3 + + +def _make_fold(fold_index: int) -> SplitMasks: + """Build a real 2x2 fold so the .npz write path is genuine.""" + train = np.zeros((2, 2), dtype=bool) + train[0, 0] = True + test = np.zeros((2, 2), dtype=bool) + test[1, 1] = True + val = np.zeros((2, 2), dtype=bool) + val[0, 1] = True + return SplitMasks( + train=SplitMask(train), + test=SplitMask(test), + val=SplitMask(val), + metadata={"fold_index": fold_index}, + ) + + +@pytest.fixture() +def splitter(monkeypatch: pytest.MonkeyPatch) -> Recorder: + """Patch :func:`drevalpy.data.split` and ``Dataset.load``. + + Args: + monkeypatch: Fixture used to replace the source-module workers. + + Returns: + Recorder standing in for the splitter, returning three real folds. + """ + recorder = Recorder(return_value=[_make_fold(i) for i in range(N_FOLDS)]) + monkeypatch.setattr("drevalpy.types.data.dataset.Dataset.load", classmethod(lambda cls, path: FakeDataset())) + patch_worker(monkeypatch, "drevalpy.data", "split", recorder) + return recorder + + +def _invoke(tmp_path: UPath, *extra: str): + return runner.invoke(app, ["data", "split", "TOYv1", str(tmp_path / "folds"), *extra]) + + +class TestArguments: + """Both positional arguments are required.""" + + @pytest.mark.parametrize( + "argv", + [ + pytest.param(["data", "split"], id="none"), + pytest.param(["data", "split", "TOYv1"], id="missing-output-dir"), + ], + ) + def test_missing_positional_arguments_are_usage_errors(self, splitter: Recorder, argv: list[str]) -> None: + result = runner.invoke(app, argv, env=HELP_ENV) + + assert result.exit_code == 2 + + +class TestForwarding: + """Options map onto :func:`drevalpy.data.split`'s keywords.""" + + def test_exits_cleanly(self, splitter: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path) + + assert result.exit_code == 0, result.output + + def test_passes_the_loaded_dataset_positionally(self, splitter: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + assert isinstance(splitter.args[0], FakeDataset) + + def test_defaults(self, splitter: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + assert splitter.kwargs == { + "mode": "LPO", + "n_splits": 5, + "validation_ratio": 0.1, + "random_state": 42, + } + + @pytest.mark.parametrize("flag", ["--mode", "-m"], ids=["long", "short"]) + def test_mode(self, splitter: Recorder, tmp_path: UPath, flag: str) -> None: + _invoke(tmp_path, flag, "LCO") + + assert splitter.kwargs["mode"] == "LCO" + + @pytest.mark.parametrize("flag", ["--n-splits", "-n"], ids=["long", "short"]) + def test_n_splits(self, splitter: Recorder, tmp_path: UPath, flag: str) -> None: + _invoke(tmp_path, flag, "2") + + assert splitter.kwargs["n_splits"] == 2 + + def test_validation_ratio(self, splitter: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "--validation-ratio", "0.25") + + assert splitter.kwargs["validation_ratio"] == pytest.approx(0.25) + + def test_random_state(self, splitter: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "--random-state", "7") + + assert splitter.kwargs["random_state"] == 7 + + def test_non_numeric_validation_ratio_is_a_usage_error(self, splitter: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path, "--validation-ratio", "a-lot") + + assert result.exit_code == 2 + + +class TestOutput: + """One ``fold_{i}.npz`` per returned fold, plus a summary line.""" + + def test_creates_the_output_directory_including_parents(self, splitter: Recorder, tmp_path: UPath) -> None: + out_dir = tmp_path / "nested" / "folds" + runner.invoke(app, ["data", "split", "TOYv1", str(out_dir)]) + + assert out_dir.is_dir() + + def test_writes_one_file_per_fold(self, splitter: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + written = sorted(p.name for p in (tmp_path / "folds").glob("*.npz")) + assert written == [f"fold_{i}.npz" for i in range(N_FOLDS)] + + def test_written_folds_are_reloadable(self, splitter: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + reloaded = SplitMasks.load(tmp_path / "folds" / "fold_1.npz") + assert reloaded.metadata["fold_index"] == 1 + + def test_echoes_the_fold_count_and_destination(self, splitter: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path) + + assert f"Wrote {N_FOLDS} folds to {tmp_path / 'folds'}" in plain(result.output) + + def test_empty_fold_list_still_reports_zero(self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath) -> None: + monkeypatch.setattr("drevalpy.types.data.dataset.Dataset.load", classmethod(lambda cls, path: FakeDataset())) + patch_worker(monkeypatch, "drevalpy.data", "split", Recorder(return_value=[])) + + result = _invoke(tmp_path) + + assert f"Wrote 0 folds to {tmp_path / 'folds'}" in plain(result.output) diff --git a/tests/cli/experiments/test_init.py b/tests/cli/experiments/test_init.py new file mode 100644 index 000000000..d49ca735c --- /dev/null +++ b/tests/cli/experiments/test_init.py @@ -0,0 +1,51 @@ +"""Tests for the :mod:`drevalpy.cli.experiments` command group.""" + +from __future__ import annotations + +import pytest + +from drevalpy.cli.experiments import experiments_app +from drevalpy.cli.experiments.randomization import randomization_cmd +from drevalpy.cli.experiments.robustness import robustness_cmd +from drevalpy.cli.main import app +from tests.cli._helpers import HELP_ENV, make_runner, plain + +runner = make_runner() + + +class TestGroup: + """The group is help-first and exposes exactly two commands.""" + + def test_app_name(self) -> None: + assert experiments_app.info.name == "experiments" + + def test_registered_command_names(self) -> None: + registered = {command.name for command in experiments_app.registered_commands} + + assert registered == {"robustness", "randomization"} + + def test_commands_wrap_the_source_callbacks(self) -> None: + callbacks = {command.name: command.callback for command in experiments_app.registered_commands} + + assert callbacks == {"robustness": robustness_cmd, "randomization": randomization_cmd} + + def test_bare_group_prints_help(self) -> None: + result = runner.invoke(app, ["experiments"], env=HELP_ENV) + + assert "Usage" in plain(result.output) + + def test_bare_group_exits_nonzero(self) -> None: + result = runner.invoke(app, ["experiments"], env=HELP_ENV) + + assert result.exit_code != 0 + + @pytest.mark.parametrize("command", ["robustness", "randomization"], ids=["robustness", "randomization"]) + def test_help_lists_each_command(self, command: str) -> None: + result = runner.invoke(app, ["experiments", "--help"], env=HELP_ENV) + + assert command in plain(result.output) + + def test_unknown_subcommand_is_a_usage_error(self) -> None: + result = runner.invoke(app, ["experiments", "not-a-command"], env=HELP_ENV) + + assert result.exit_code == 2 diff --git a/tests/cli/experiments/test_randomization.py b/tests/cli/experiments/test_randomization.py new file mode 100644 index 000000000..080bd2513 --- /dev/null +++ b/tests/cli/experiments/test_randomization.py @@ -0,0 +1,167 @@ +"""Tests for :mod:`drevalpy.cli.experiments.randomization`.""" + +from __future__ import annotations + +import pytest +from upath import UPath + +from drevalpy.cli.main import app +from tests.cli._helpers import HELP_ENV, FakeDataset, Recorder, make_runner, patch_worker, plain + +runner = make_runner() + +RANDOMIZED = (("SVRC", "gene_expression"), ("SVRC", "methylation")) + + +@pytest.fixture() +def constructed() -> list[str]: + """Collect the model names handed to ``construct_model``.""" + return [] + + +@pytest.fixture() +def worker(monkeypatch: pytest.MonkeyPatch, constructed: list[str]) -> Recorder: + """Patch every lazy import of ``randomization_cmd``. + + Args: + monkeypatch: Fixture used to replace the source-module workers. + constructed: List that receives every requested model name. + + Returns: + Recorder standing in for + :func:`drevalpy.experiment.randomization`, returning two + datasets whose ``randomization`` tags drive the output filenames. + """ + + def fake_construct_model(name: str) -> type: + constructed.append(name) + return type(f"Stub{name}", (), {}) + + recorder = Recorder(return_value=[FakeDataset(randomization=tag) for tag in RANDOMIZED]) + monkeypatch.setattr("drevalpy.models.construct_model", fake_construct_model) + monkeypatch.setattr("drevalpy.types.data.dataset.Dataset.load", classmethod(lambda cls, path: FakeDataset())) + patch_worker(monkeypatch, "drevalpy.experiment._randomization", "randomization", recorder) + return recorder + + +def _invoke(tmp_path: UPath, *extra: str): + return runner.invoke( + app, + ["experiments", "randomization", "ElasticNet", "TOYv1", str(tmp_path / "randomized"), *extra], + ) + + +class TestArguments: + """All three positional arguments are required.""" + + @pytest.mark.parametrize( + "argv", + [ + pytest.param(["experiments", "randomization"], id="none"), + pytest.param(["experiments", "randomization", "ElasticNet"], id="model-only"), + pytest.param(["experiments", "randomization", "ElasticNet", "TOYv1"], id="missing-output-dir"), + ], + ) + def test_missing_positional_arguments_are_usage_errors(self, worker: Recorder, argv: list[str]) -> None: + result = runner.invoke(app, argv, env=HELP_ENV) + + assert result.exit_code == 2 + + +class TestForwarding: + """Options map onto :func:`randomization`'s signature.""" + + def test_exits_cleanly(self, worker: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path) + + assert result.exit_code == 0, result.output + + def test_constructs_the_requested_model(self, worker: Recorder, tmp_path: UPath, constructed: list[str]) -> None: + _invoke(tmp_path) + + assert constructed == ["ElasticNet"] + + def test_passes_model_class_dataset_and_modes_positionally(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + model_class, dataset, modes = worker.args + assert model_class.__name__ == "StubElasticNet" + assert isinstance(dataset, FakeDataset) + assert modes == ["SVRC"] + + def test_modes_default_to_svrc(self, worker: Recorder, tmp_path: UPath) -> None: + """``modes=None`` is replaced by ``["SVRC"]`` rather than forwarded.""" + _invoke(tmp_path) + + assert worker.args[2] == ["SVRC"] + + def test_repeated_modes_are_forwarded_in_order(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "-m", "SVCC", "-m", "SVRD") + + assert worker.args[2] == ["SVCC", "SVRD"] + + def test_randomization_type_and_seed_defaults(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + assert worker.kwargs == {"randomization_type": "permutation", "random_state": 42} + + @pytest.mark.parametrize("flag", ["--randomization-type", "-t"], ids=["long", "short"]) + def test_randomization_type_override(self, worker: Recorder, tmp_path: UPath, flag: str) -> None: + _invoke(tmp_path, flag, "invariant") + + assert worker.kwargs["randomization_type"] == "invariant" + + def test_random_state_override(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "--random-state", "7") + + assert worker.kwargs["random_state"] == 7 + + def test_non_integer_random_state_is_a_usage_error(self, worker: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path, "--random-state", "seed") + + assert result.exit_code == 2 + + +class TestOutput: + """Filenames come from each dataset's ``randomization`` tag.""" + + def test_creates_the_output_directory_including_parents(self, worker: Recorder, tmp_path: UPath) -> None: + out_dir = tmp_path / "nested" / "randomized" + runner.invoke(app, ["experiments", "randomization", "ElasticNet", "TOYv1", str(out_dir)]) + + assert out_dir.is_dir() + + def test_writes_one_file_per_randomized_dataset(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + written = sorted(p.name for p in (tmp_path / "randomized").glob("*.h5mu")) + assert written == sorted(f"{mode}:{view}.h5mu" for mode, view in RANDOMIZED) + + def test_untagged_dataset_falls_back_to_unknown(self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath) -> None: + monkeypatch.setattr("drevalpy.models.construct_model", lambda name: type("Stub", (), {})) + monkeypatch.setattr("drevalpy.types.data.dataset.Dataset.load", classmethod(lambda cls, path: FakeDataset())) + patch_worker( + monkeypatch, + "drevalpy.experiment._randomization", + "randomization", + Recorder(return_value=[FakeDataset(randomization=None)]), + ) + + _invoke(tmp_path) + + assert (tmp_path / "randomized" / "unknown:0.h5mu").exists() + + def test_echoes_the_dataset_count_and_destination(self, worker: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path) + + expected = f"Wrote {len(RANDOMIZED)} randomized datasets to {tmp_path / 'randomized'}" + assert expected in plain(result.output) + + def test_empty_result_still_reports_zero(self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath) -> None: + monkeypatch.setattr("drevalpy.models.construct_model", lambda name: type("Stub", (), {})) + monkeypatch.setattr("drevalpy.types.data.dataset.Dataset.load", classmethod(lambda cls, path: FakeDataset())) + patch_worker(monkeypatch, "drevalpy.experiment._randomization", "randomization", Recorder(return_value=[])) + + result = _invoke(tmp_path) + + assert "Wrote 0 randomized datasets" in plain(result.output) diff --git a/tests/cli/experiments/test_robustness.py b/tests/cli/experiments/test_robustness.py new file mode 100644 index 000000000..074b6a99b --- /dev/null +++ b/tests/cli/experiments/test_robustness.py @@ -0,0 +1,181 @@ +"""Tests for :mod:`drevalpy.cli.experiments.robustness`. + +:func:`drevalpy.experiment.robustness` is pure ``SplitMasks`` +shuffling, so it runs unpatched here and only the empty-directory guard needs +special setup. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from upath import UPath + +from drevalpy.cli.main import app +from drevalpy.types import SplitMask, SplitMasks +from tests.cli._helpers import HELP_ENV, make_runner, plain + +runner = make_runner() + +N_PERMUTATIONS_DEFAULT = 5 + + +def _write_fold(path: UPath, fold_index: int) -> None: + """Write a 3x3 fold with enough pairs for shuffling to be observable.""" + rng = np.random.default_rng(fold_index) + assignment = rng.integers(0, 3, size=(3, 3)) + SplitMasks( + train=SplitMask(assignment == 0), + test=SplitMask(assignment == 1), + val=SplitMask(assignment == 2), + metadata={"fold_index": fold_index}, + ).save(path) + + +@pytest.fixture() +def splits_dir(tmp_path: UPath) -> UPath: + """A directory holding two fold .npz files.""" + path = tmp_path / "splits" + path.mkdir() + for fold_index in range(2): + _write_fold(path / f"fold_{fold_index}.npz", fold_index) + return path + + +def _invoke(splits_dir: UPath, out_dir: UPath, *extra: str): + return runner.invoke(app, ["experiments", "robustness", str(splits_dir), str(out_dir), *extra]) + + +class TestArguments: + """Both positional arguments are required.""" + + @pytest.mark.parametrize( + "argv", + [ + pytest.param(["experiments", "robustness"], id="none"), + pytest.param(["experiments", "robustness", "splits"], id="missing-output-dir"), + ], + ) + def test_missing_positional_arguments_are_usage_errors(self, argv: list[str]) -> None: + result = runner.invoke(app, argv, env=HELP_ENV) + + assert result.exit_code == 2 + + +class TestEmptyInput: + """An input directory with no folds is an explicit exit-1 error.""" + + def test_empty_directory_exits_one(self, tmp_path: UPath) -> None: + empty = tmp_path / "empty" + empty.mkdir() + + result = _invoke(empty, tmp_path / "out") + + assert result.exit_code == 1 + + def test_empty_directory_reports_the_path(self, tmp_path: UPath) -> None: + """Typer >= 0.26 merges stderr into ``result.output``.""" + empty = tmp_path / "empty" + empty.mkdir() + + result = _invoke(empty, tmp_path / "out") + + assert f"No .npz files found in {empty}" in plain(result.output) + + def test_directory_without_npz_files_exits_one(self, tmp_path: UPath) -> None: + splits = tmp_path / "splits" + splits.mkdir() + (splits / "notes.txt").write_text("not a fold") + + result = _invoke(splits, tmp_path / "out") + + assert result.exit_code == 1 + + def test_output_directory_is_still_created(self, tmp_path: UPath) -> None: + """``mkdir`` happens before the guard, so the dir exists even on failure.""" + empty = tmp_path / "empty" + empty.mkdir() + out_dir = tmp_path / "out" + + _invoke(empty, out_dir) + + assert out_dir.is_dir() + + +class TestGeneration: + """One shuffled variant per (fold, trial) pair.""" + + def test_exits_cleanly(self, splits_dir: UPath, tmp_path: UPath) -> None: + result = _invoke(splits_dir, tmp_path / "out") + + assert result.exit_code == 0, result.output + + def test_creates_the_output_directory_including_parents(self, splits_dir: UPath, tmp_path: UPath) -> None: + out_dir = tmp_path / "nested" / "out" + _invoke(splits_dir, out_dir) + + assert out_dir.is_dir() + + def test_default_permutation_count(self, splits_dir: UPath, tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + _invoke(splits_dir, out_dir) + + assert len(list(out_dir.glob("*.npz"))) == 2 * N_PERMUTATIONS_DEFAULT + + @pytest.mark.parametrize("flag", ["--n-permutations", "-n"], ids=["long", "short"]) + def test_permutation_count_option(self, splits_dir: UPath, tmp_path: UPath, flag: str) -> None: + out_dir = tmp_path / "out" + _invoke(splits_dir, out_dir, flag, "2") + + assert len(list(out_dir.glob("*.npz"))) == 4 + + def test_filenames_carry_the_fold_stem_and_trial_index(self, splits_dir: UPath, tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + _invoke(splits_dir, out_dir, "-n", "2") + + written = sorted(p.name for p in out_dir.glob("*.npz")) + assert written == [ + "fold_0_trial_0.npz", + "fold_0_trial_1.npz", + "fold_1_trial_0.npz", + "fold_1_trial_1.npz", + ] + + def test_variants_record_their_trial_index(self, splits_dir: UPath, tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + _invoke(splits_dir, out_dir, "-n", "2") + + variant = SplitMasks.load(out_dir / "fold_0_trial_1.npz") + assert variant.metadata["robustness_trial"] == 1 + + def test_variants_preserve_the_original_metadata(self, splits_dir: UPath, tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + _invoke(splits_dir, out_dir, "-n", "1") + + variant = SplitMasks.load(out_dir / "fold_1_trial_0.npz") + assert variant.metadata["fold_index"] == 1 + + def test_variants_preserve_mask_content(self, splits_dir: UPath, tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + _invoke(splits_dir, out_dir, "-n", "1") + + original = SplitMasks.load(splits_dir / "fold_0.npz") + variant = SplitMasks.load(out_dir / "fold_0_trial_0.npz") + np.testing.assert_array_equal(variant.train.mask, original.train.mask) + + def test_zero_permutations_writes_nothing(self, splits_dir: UPath, tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + _invoke(splits_dir, out_dir, "-n", "0") + + assert list(out_dir.glob("*.npz")) == [] + + def test_echoes_the_variant_count_and_destination(self, splits_dir: UPath, tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + result = _invoke(splits_dir, out_dir, "-n", "3") + + assert f"Wrote 6 robustness splits to {out_dir}" in plain(result.output) + + def test_non_integer_permutation_count_is_a_usage_error(self, splits_dir: UPath, tmp_path: UPath) -> None: + result = _invoke(splits_dir, tmp_path / "out", "-n", "several") + + assert result.exit_code == 2 diff --git a/tests/cli/test_aggregate.py b/tests/cli/test_aggregate.py new file mode 100644 index 000000000..6dce12315 --- /dev/null +++ b/tests/cli/test_aggregate.py @@ -0,0 +1,119 @@ +"""Tests for :mod:`drevalpy.cli.aggregate`, the ``drevalpy aggregate`` command. + +Nothing here is patched: ``RunResult`` .npz files are cheap to write, so the +command is exercised end to end against real serialization. +""" + +from __future__ import annotations + +import json + +import pytest +from upath import UPath + +from drevalpy.cli.main import app +from drevalpy.types.results import ExperimentResult +from tests.cli._helpers import HELP_ENV, make_runner, plain +from tests.synthetic import DEFAULT_DATASET_NAME, make_run_result + +runner = make_runner() + + +@pytest.fixture() +def run_files(tmp_path: UPath) -> list[UPath]: + """Two folds each of two models, written as RunResult .npz files.""" + paths: list[UPath] = [] + for model_name in ("ElasticNet", "RandomForest"): + for fold_index in range(2): + path = tmp_path / f"{model_name}_fold_{fold_index}.npz" + make_run_result(model_name=model_name, fold_index=fold_index).save(path) + paths.append(path) + return paths + + +def _invoke(run_files: list[UPath], out_dir: UPath): + return runner.invoke(app, ["aggregate", *[str(p) for p in run_files], "--output-dir", str(out_dir)]) + + +class TestArguments: + """At least one RunResult path is required.""" + + def test_missing_results_is_a_usage_error(self) -> None: + result = runner.invoke(app, ["aggregate"], env=HELP_ENV) + + assert result.exit_code == 2 + + def test_nonexistent_result_file_fails(self, tmp_path: UPath) -> None: + result = _invoke([tmp_path / "absent.npz"], tmp_path / "out") + + assert result.exit_code != 0 + + +class TestAggregation: + """The command groups the given runs into one saved ExperimentResult.""" + + def test_exits_cleanly(self, run_files: list[UPath], tmp_path: UPath) -> None: + result = _invoke(run_files, tmp_path / "out") + + assert result.exit_code == 0, result.output + + def test_creates_the_output_directory_including_parents(self, run_files: list[UPath], tmp_path: UPath) -> None: + out_dir = tmp_path / "nested" / "experiment" + _invoke(run_files, out_dir) + + assert out_dir.is_dir() + + def test_writes_experiment_metadata(self, run_files: list[UPath], tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + _invoke(run_files, out_dir) + + meta = json.loads((out_dir / "metadata.json").read_text()) + assert sorted(meta["models"]) == ["ElasticNet", "RandomForest"] + + def test_records_the_shared_dataset_name(self, run_files: list[UPath], tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + _invoke(run_files, out_dir) + + meta = json.loads((out_dir / "metadata.json").read_text()) + assert meta["dataset_name"] == DEFAULT_DATASET_NAME + + def test_result_is_reloadable(self, run_files: list[UPath], tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + _invoke(run_files, out_dir) + + experiment = ExperimentResult.load(out_dir) + assert experiment.n_models == 2 + assert experiment.max_folds == 2 + + def test_echoes_the_run_count_and_destination(self, run_files: list[UPath], tmp_path: UPath) -> None: + out_dir = tmp_path / "out" + result = _invoke(run_files, out_dir) + + assert f"Aggregated 4 runs into ExperimentResult at {out_dir}" in plain(result.output) + + def test_echoes_the_experiment_repr(self, run_files: list[UPath], tmp_path: UPath) -> None: + result = _invoke(run_files, tmp_path / "out") + + assert "ExperimentResult" in plain(result.output) + + def test_accepts_a_single_run(self, tmp_path: UPath) -> None: + path = tmp_path / "one.npz" + make_run_result().save(path) + + result = _invoke([path], tmp_path / "out") + + assert result.exit_code == 0, result.output + + +class TestValidation: + """``ExperimentResult``'s own guards surface as a nonzero exit.""" + + def test_mismatched_dataset_names_are_rejected(self, tmp_path: UPath) -> None: + first = tmp_path / "a.npz" + second = tmp_path / "b.npz" + make_run_result(dataset_name="DatasetA").save(first) + make_run_result(dataset_name="DatasetB").save(second) + + result = _invoke([first, second], tmp_path / "out") + + assert isinstance(result.exception, ValueError) diff --git a/tests/cli/test_cli_app.py b/tests/cli/test_cli_app.py deleted file mode 100644 index a864fbc44..000000000 --- a/tests/cli/test_cli_app.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Tests for the Typer-based ``drevalpy`` CLI.""" - -from __future__ import annotations - -import re -import warnings -from typing import cast - -import pytest -from typer.testing import CliRunner - -from drevalpy.cli._helpers import normalize_list_argv -from drevalpy.cli.legacy import load_response -from drevalpy.cli.main import app - -runner = CliRunner() -_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") - - -def _plain_stdout(text: str) -> str: - """Strip terminal escape codes (e.g. when CI sets ``FORCE_COLOR=1``). - - :param text: Captured CLI stdout. - :return: *text* without ANSI escape sequences. - """ - return _ANSI_ESCAPE.sub("", text) - - -def test_drevalpy_help_lists_subcommands() -> None: - result = runner.invoke(app, ["--help"]) - assert result.exit_code == 0 - assert "viability-preprocess" in result.stdout - assert "train-cv" in result.stdout - assert "make-pipeline-report" in result.stdout - - -def test_viability_preprocess_help() -> None: - result = runner.invoke( - app, - ["viability-preprocess", "--help"], - env={"FORCE_COLOR": "1", "CI": "true"}, - ) - assert result.exit_code == 0 - help_text = _plain_stdout(result.stdout) - assert "--dataset_name" in help_text - assert "--path_data" in help_text - assert "--cores" in help_text - - -def test_load_response_requires_response_dataset() -> None: - result = runner.invoke(app, ["load-response"]) - assert result.exit_code != 0 - - -def test_legacy_load_response_emits_deprecation_warning(monkeypatch: pytest.MonkeyPatch) -> None: - """Legacy script warns and forwards argv to the Typer subcommand.""" - monkeypatch.setattr("sys.argv", ["drevalpy-load-response", "--help"]) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - with pytest.raises(SystemExit) as exc_info: - load_response() - - assert exc_info.value.code == 0 - assert any(issubclass(w.category, FutureWarning) and "drevalpy load-response" in str(w.message) for w in caught) - - -def test_pipeline_root_missing_models_fails_fast() -> None: - result = runner.invoke( - app, - normalize_list_argv( - [ - "--dataset_name", - "GDSC1", - ] - ), - ) - assert result.exit_code != 0 - assert result.exception is not None - assert "At least one model must be specified" in str(result.exception) - - -def test_pipeline_accepts_space_separated_models(monkeypatch: pytest.MonkeyPatch) -> None: - captured: dict[str, list[str]] = {} - - def fake_check(args: object) -> None: - captured["models"] = list(args.models) # type: ignore[attr-defined] - - def fake_main(args: object) -> None: - return None - - monkeypatch.setattr("drevalpy.cli.pipeline.check_arguments", fake_check) - monkeypatch.setattr("drevalpy.cli.pipeline.main", fake_main) - - result = runner.invoke( - app, - normalize_list_argv(["--models", "Simple", "ElasticNet", "--dataset_name", "GDSC1"]), - ) - assert result.exit_code == 0 - assert captured["models"] == ["Simple", "ElasticNet"] - - -def test_evaluate_hpams_accepts_space_separated_lists(monkeypatch: pytest.MonkeyPatch) -> None: - captured: dict[str, list[str]] = {} - - def fake_run(**kwargs: object) -> None: - captured["hpam_yamls"] = list(cast("list[str]", kwargs["hpam_yamls"])) - captured["pred_datas"] = list(cast("list[str]", kwargs["pred_datas"])) - - monkeypatch.setattr("drevalpy.cli.evaluate_hpams.run_evaluate_and_find_max", fake_run) - - result = runner.invoke( - app, - normalize_list_argv( - [ - "evaluate-hpams", - "--model_name", - "m", - "--split_id", - "0", - "--hpam_yamls", - "a.yml", - "b.yml", - "--pred_datas", - "p1.pkl", - "p2.pkl", - ] - ), - ) - assert result.exit_code == 0 - assert captured["hpam_yamls"] == ["a.yml", "b.yml"] - assert captured["pred_datas"] == ["p1.pkl", "p2.pkl"] - - -def test_dash_h_shows_root_help() -> None: - """``-h`` is accepted as an alias for ``--help`` on the root command.""" - result = runner.invoke(app, ["-h"]) - assert result.exit_code == 0 - assert "Usage" in result.stdout - - -def test_dash_h_shows_subcommand_help() -> None: - """``-h`` propagates to subcommands (e.g. ``drevalpy report -h``).""" - result = runner.invoke(app, ["report", "-h"]) - assert result.exit_code == 0 - help_text = _plain_stdout(result.stdout) - assert "--dataset_name" in help_text - - -def test_report_uses_dataset_name_option() -> None: - """``report`` exposes ``--dataset_name`` and no longer accepts ``--dataset``.""" - help_result = runner.invoke(app, ["report", "--help"], env={"FORCE_COLOR": "1", "CI": "true"}) - assert help_result.exit_code == 0 - assert "--dataset_name" in _plain_stdout(help_result.stdout) - - rejected = runner.invoke(app, ["report", "--run_id", "x", "--dataset", "TOYv1"]) - # Click reports unknown options with a usage error (exit code 2); assert on that - # specifically so the test cannot pass via an unrelated downstream failure. - assert rejected.exit_code == 2 - assert "No such option" in _plain_stdout(rejected.output) - - -def test_report_forwards_dataset_name(monkeypatch: pytest.MonkeyPatch) -> None: - """``report --dataset_name`` is forwarded to ``run_report`` as ``dataset``.""" - captured: dict[str, object] = {} - - def fake_run_report(**kwargs: object) -> None: - captured.update(kwargs) - - monkeypatch.setattr("drevalpy.cli.report.run_report", fake_run_report) - - result = runner.invoke(app, ["report", "--run_id", "my_run", "--dataset_name", "TOYv1"]) - assert result.exit_code == 0 - assert captured["run_id"] == "my_run" - assert captured["dataset"] == "TOYv1" - - -def test_pipeline_help_uses_valid_randomization_example() -> None: - result = runner.invoke( - app, - ["--help"], - env={"FORCE_COLOR": "1", "CI": "true"}, - ) - help_text = _plain_stdout(result.stdout) - assert "SVCC" in help_text - assert "SVCD" in help_text - assert "SCVC" not in help_text - assert "SCVD" not in help_text diff --git a/tests/cli/test_curate.py b/tests/cli/test_curate.py new file mode 100644 index 000000000..c4231bdbb --- /dev/null +++ b/tests/cli/test_curate.py @@ -0,0 +1,233 @@ +"""Tests for :mod:`drevalpy.cli.curate`, the ``drevalpy curate`` command. + +The CSV -> h5ad round-trip here replaces the ``TestCLI`` class that used to live +in ``tests/curation/test_init.py``; it keeps ``fit_speed="fast"`` and +``cores=1`` so the real ``curve_curator`` fit stays cheap and serial. The +remaining tests patch :func:`drevalpy.curation.curate` to isolate argument +plumbing and the format guard from the fitter. +""" + +from __future__ import annotations + +import anndata +import numpy as np +import pandas as pd +import pytest +from upath import UPath + +from drevalpy.cli.main import app +from tests.cli._helpers import HELP_ENV, Recorder, make_runner, patch_worker, plain + +runner = make_runner() + +CONCENTRATIONS = (0.001, 0.01, 0.1, 1.0, 10.0) +CELL_LINES = ("CL_A", "CL_B", "CL_C") +DRUGS = ("DrugX", "DrugY") + + +def _sigmoid(x: np.ndarray, top: float, bottom: float, ec50: float, slope: float) -> np.ndarray: + """4-parameter log-logistic sigmoid.""" + return bottom + (top - bottom) / (1 + (x / ec50) ** slope) + + +def build_dose_response_df() -> pd.DataFrame: + """Build artificial dose-response data: 3 cell lines x 2 drugs. + + ``DrugX`` gets a genuine sigmoid so the fit converges; ``DrugY`` is flat. + + Returns: + Long-format frame with ``drug``/``cell_line``/``concentration``/``intensity``. + """ + rng = np.random.default_rng(42) + conc_arr = np.array(CONCENTRATIONS) + rows: list[dict] = [] + + for cell_line in CELL_LINES: + for drug in DRUGS: + if drug == "DrugX": + intensity = _sigmoid(conc_arr, top=1.0, bottom=0.1, ec50=0.5, slope=1.5) + else: + intensity = np.ones_like(conc_arr) * 0.95 + intensity = np.clip(intensity + rng.normal(0, 0.02, size=len(conc_arr)), 0.01, 1.5) + + rows.extend( + {"drug": drug, "cell_line": cell_line, "concentration": conc, "intensity": value} + for conc, value in zip(CONCENTRATIONS, intensity, strict=True) + ) + + return pd.DataFrame(rows) + + +@pytest.fixture() +def dose_response_df() -> pd.DataFrame: + """Artificial dose-response data: 3 cell lines x 2 drugs.""" + return build_dose_response_df() + + +@pytest.fixture() +def input_csv(dose_response_df: pd.DataFrame, tmp_path: UPath) -> UPath: + """The dose-response frame written as CSV.""" + path = tmp_path / "input.csv" + dose_response_df.to_csv(path, index=False) + return path + + +@pytest.fixture() +def worker(monkeypatch: pytest.MonkeyPatch) -> Recorder: + """Patch :func:`drevalpy.curation.curate` with a recorder returning a 1x1 AnnData. + + Args: + monkeypatch: Fixture used to replace the source-module worker. + + Returns: + Recorder standing in for the curve fitter. + """ + recorder = Recorder(return_value=anndata.AnnData(X=np.zeros((1, 1), dtype=np.float32))) + patch_worker(monkeypatch, "drevalpy.curation", "curate", recorder) + return recorder + + +class TestArguments: + """Both positional arguments are required.""" + + @pytest.mark.parametrize( + "argv", + [pytest.param(["curate"], id="none"), pytest.param(["curate", "in.csv"], id="missing-output")], + ) + def test_missing_positional_arguments_are_usage_errors(self, argv: list[str]) -> None: + result = runner.invoke(app, argv, env=HELP_ENV) + + assert result.exit_code == 2 + + +class TestFormatDetection: + """The suffix decides the reader; anything else is a ``BadParameter``.""" + + def test_csv_is_read(self, worker: Recorder, input_csv: UPath, tmp_path: UPath) -> None: + result = runner.invoke(app, ["curate", str(input_csv), str(tmp_path / "out.h5ad")]) + + assert result.exit_code == 0, result.output + assert len(worker.args[0]) == len(CONCENTRATIONS) * len(CELL_LINES) * len(DRUGS) + + @pytest.mark.parametrize("suffix", [".parquet", ".pq"], ids=["parquet", "pq"]) + def test_parquet_suffixes_are_read( + self, worker: Recorder, dose_response_df: pd.DataFrame, tmp_path: UPath, suffix: str + ) -> None: + path = tmp_path / f"input{suffix}" + dose_response_df.to_parquet(path) + + result = runner.invoke(app, ["curate", str(path), str(tmp_path / "out.h5ad")]) + + assert result.exit_code == 0, result.output + assert len(worker.args[0]) == len(dose_response_df) + + def test_suffix_matching_is_case_insensitive( + self, worker: Recorder, dose_response_df: pd.DataFrame, tmp_path: UPath + ) -> None: + path = tmp_path / "input.CSV" + dose_response_df.to_csv(path, index=False) + + result = runner.invoke(app, ["curate", str(path), str(tmp_path / "out.h5ad")]) + + assert result.exit_code == 0, result.output + + def test_unsupported_suffix_is_rejected(self, worker: Recorder, tmp_path: UPath) -> None: + path = tmp_path / "input.tsv" + path.write_text("drug\tcell_line\n") + + result = runner.invoke(app, ["curate", str(path), str(tmp_path / "out.h5ad")], env=HELP_ENV) + + assert result.exit_code == 2 + assert "Unsupported file format: .tsv" in plain(result.output) + + def test_unsupported_suffix_does_not_reach_the_fitter(self, worker: Recorder, tmp_path: UPath) -> None: + path = tmp_path / "input.tsv" + path.write_text("drug\tcell_line\n") + + runner.invoke(app, ["curate", str(path), str(tmp_path / "out.h5ad")], env=HELP_ENV) + + assert worker.call_count == 0 + + +class TestFitOptions: + """Fit options map onto :func:`drevalpy.curation.curate` keywords.""" + + def test_defaults(self, worker: Recorder, input_csv: UPath, tmp_path: UPath) -> None: + runner.invoke(app, ["curate", str(input_csv), str(tmp_path / "out.h5ad")]) + + assert worker.kwargs == { + "cores": 4, + "normalize": False, + "fit_type": "OLS", + "fit_speed": "exhaustive", + } + + def test_overrides(self, worker: Recorder, input_csv: UPath, tmp_path: UPath) -> None: + runner.invoke( + app, + [ + "curate", + str(input_csv), + str(tmp_path / "out.h5ad"), + "--cores", + "2", + "--normalize", + "--fit-type", + "OLS", + "--fit-speed", + "fast", + ], + ) + + assert worker.kwargs == {"cores": 2, "normalize": True, "fit_type": "OLS", "fit_speed": "fast"} + + def test_cores_short_option(self, worker: Recorder, input_csv: UPath, tmp_path: UPath) -> None: + runner.invoke(app, ["curate", str(input_csv), str(tmp_path / "out.h5ad"), "-c", "1"]) + + assert worker.kwargs["cores"] == 1 + + def test_non_integer_cores_is_a_usage_error(self, worker: Recorder, input_csv: UPath, tmp_path: UPath) -> None: + result = runner.invoke(app, ["curate", str(input_csv), str(tmp_path / "out.h5ad"), "--cores", "all"]) + + assert result.exit_code == 2 + + +class TestRoundTrip: + """End-to-end CSV -> .h5ad with the real fitter, kept cheap and serial. + + Extended tier: the class-scoped ``curated`` fixture runs the real CurveCurator + fitter (~0.7s). Shared across the three tests, so only deselecting all of them + saves it; the argument-parsing tests above stay in the fast tier. + """ + + pytestmark = pytest.mark.slow + + @pytest.fixture(scope="class") + def curated(self, tmp_path_factory: pytest.TempPathFactory) -> UPath: + """Run the real command once and hand back the written .h5ad path.""" + tmp_path = UPath(tmp_path_factory.mktemp("curate_round_trip")) + input_path = tmp_path / "input.csv" + build_dose_response_df().to_csv(input_path, index=False) + output_path = tmp_path / "output.h5ad" + + result = runner.invoke( + app, + ["curate", str(input_path), str(output_path), "--cores", "1", "--fit-speed", "fast"], + ) + + assert result.exit_code == 0, f"CLI failed: {result.output}" + return output_path + + def test_writes_the_output_file(self, curated: UPath) -> None: + assert curated.exists() + + def test_output_is_readable_anndata(self, curated: UPath) -> None: + adata = anndata.read_h5ad(curated) + + assert adata.shape == (len(CELL_LINES), len(DRUGS)) + + def test_output_carries_curve_metric_layers(self, curated: UPath) -> None: + adata = anndata.read_h5ad(curated) + + assert "EC50" in adata.layers + assert "AUC" in adata.layers diff --git a/tests/cli/test_helpers.py b/tests/cli/test_helpers.py deleted file mode 100644 index 73c23a8b8..000000000 --- a/tests/cli/test_helpers.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for drevalpy.cli._helpers.""" - -from __future__ import annotations - -from drevalpy.cli._helpers import normalize_list_argv - - -def test_normalize_list_argv_expands_space_separated_values() -> None: - argv = ["--models", "Simple", "ElasticNet", "--dataset_name", "GDSC1"] - assert normalize_list_argv(argv) == [ - "--models", - "Simple", - "--models", - "ElasticNet", - "--dataset_name", - "GDSC1", - ] - - -def test_normalize_list_argv_preserves_repeated_flags() -> None: - argv = ["evaluate-hpams", "--hpam_yamls", "a.yml", "--hpam_yamls", "b.yml"] - assert normalize_list_argv(argv) == [ - "evaluate-hpams", - "--hpam_yamls", - "a.yml", - "--hpam_yamls", - "b.yml", - ] - - -def test_normalize_list_argv_stops_at_next_option() -> None: - argv = ["--test_mode", "LPO", "LCO", "--path_out", "results/"] - assert normalize_list_argv(argv) == [ - "--test_mode", - "LPO", - "--test_mode", - "LCO", - "--path_out", - "results/", - ] - - -def test_normalize_list_argv_does_not_expand_scalar_subcommand_test_mode() -> None: - argv = ["test-cv", "--test_mode", "LPO", "LCO", "--split_id", "0"] - assert normalize_list_argv(argv) == argv - - -def test_normalize_list_argv_expands_subcommand_cross_study_datasets() -> None: - argv = ["test-cv", "--cross_study_datasets", "a.pkl", "b.pkl", "--split_id", "0"] - assert normalize_list_argv(argv) == [ - "test-cv", - "--cross_study_datasets", - "a.pkl", - "--cross_study_datasets", - "b.pkl", - "--split_id", - "0", - ] - - -def test_normalize_list_argv_does_not_expand_scalar_randomization_mode() -> None: - argv = ["make-randomization-yamls", "--randomization_mode", "SVCC", "SVRC", "--model_name", "Simple"] - assert normalize_list_argv(argv) == argv diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py new file mode 100644 index 000000000..f9cea41a2 --- /dev/null +++ b/tests/cli/test_init.py @@ -0,0 +1,18 @@ +"""Tests for the :mod:`drevalpy.cli` package surface.""" + +from __future__ import annotations + +import drevalpy.cli as cli_pkg +from drevalpy.cli import main as cli_main_module + + +def test_exports_the_app_from_main() -> None: + assert cli_pkg.app is cli_main_module.app + + +def test_exports_the_console_script_entry_point() -> None: + assert cli_pkg.cli_main is cli_main_module.cli_main + + +def test_all_lists_only_the_two_public_names() -> None: + assert cli_pkg.__all__ == ["app", "cli_main"] diff --git a/tests/cli/test_legacy.py b/tests/cli/test_legacy.py deleted file mode 100644 index 1bbd5e2df..000000000 --- a/tests/cli/test_legacy.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Tests for legacy ``drevalpy-*`` console script aliases.""" - -from __future__ import annotations - -import warnings - -import pytest - -from drevalpy.cli.legacy import load_response, train_and_predict_cv - - -def test_legacy_alias_emits_deprecation_warning(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("sys.argv", ["drevalpy-train-cv", "--help"]) - - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - with pytest.raises(SystemExit): - train_and_predict_cv() - - assert any(issubclass(w.category, FutureWarning) and "drevalpy train-cv" in str(w.message) for w in caught) - - -def test_legacy_alias_invalid_option_exits_without_traceback(monkeypatch: pytest.MonkeyPatch) -> None: - """Invalid legacy alias flags should exit via Click, not bubble Python exceptions.""" - monkeypatch.setattr("sys.argv", ["drevalpy-load-response", "--not-a-real-flag"]) - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", FutureWarning) - with pytest.raises(SystemExit) as exc_info: - load_response() - - assert exc_info.value.code != 0 diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py new file mode 100644 index 000000000..74921ffe1 --- /dev/null +++ b/tests/cli/test_main.py @@ -0,0 +1,206 @@ +"""Tests for :mod:`drevalpy.cli.main`: the root app, its callback and entry point.""" + +from __future__ import annotations + +import pytest +from upath import UPath + +from drevalpy.cli.main import app, cli_main +from tests.cli._helpers import HELP_ENV, Recorder, make_runner, plain + +runner = make_runner() + +EXPECTED_COMMANDS = ("run", "single", "aggregate", "curate", "report", "data", "experiments", "list") + + +class TestHelp: + """The root app is help-first: bare invocation and ``-h`` both print usage.""" + + def test_no_arguments_prints_help_instead_of_erroring(self) -> None: + result = runner.invoke(app, [], env=HELP_ENV) + + assert "Usage" in plain(result.output) + + def test_no_arguments_exits_nonzero(self) -> None: + result = runner.invoke(app, [], env=HELP_ENV) + + assert result.exit_code != 0 + + def test_dash_h_is_accepted_as_a_help_alias(self) -> None: + result = runner.invoke(app, ["-h"], env=HELP_ENV) + + assert result.exit_code == 0 + assert "Usage" in plain(result.output) + + @pytest.mark.parametrize("command", EXPECTED_COMMANDS, ids=EXPECTED_COMMANDS) + def test_help_lists_every_registered_command(self, command: str) -> None: + result = runner.invoke(app, ["--help"], env=HELP_ENV) + + assert command in plain(result.output) + + @pytest.mark.parametrize("command", EXPECTED_COMMANDS, ids=EXPECTED_COMMANDS) + def test_dash_h_propagates_to_subcommands(self, command: str) -> None: + result = runner.invoke(app, [command, "-h"], env=HELP_ENV) + + assert result.exit_code == 0 + assert "Usage" in plain(result.output) + + def test_unknown_command_is_a_usage_error(self) -> None: + result = runner.invoke(app, ["not-a-command"], env=HELP_ENV) + + assert result.exit_code == 2 + + +class TestRegistration: + """The commands wired up in ``main`` are the ones click knows about.""" + + def test_registered_command_names(self) -> None: + registered = {command.name for command in app.registered_commands} + + assert registered == {"run", "single", "aggregate", "curate", "report"} + + def test_registered_group_names(self) -> None: + registered = {group.name for group in app.registered_groups} + + assert registered == {"data", "experiments", "list"} + + +class TestExtensionLoading: + """``main_callback`` forwards extension directories to the registry loader. + + Every invocation here appends a subcommand before ``-h``: click's root-level + eager help option short-circuits before the group callback runs, so + ``["-e", d, "-h"]`` would never reach the loader at all. + """ + + def test_extensions_dir_option_is_forwarded(self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath) -> None: + loader = Recorder() + monkeypatch.delenv("DREVALPY_EXTENSIONS_DIR", raising=False) + monkeypatch.setattr("drevalpy.registry.load_extension_dir", loader) + ext_dir = tmp_path / "ext" + ext_dir.mkdir() + + result = runner.invoke(app, ["--extensions-dir", str(ext_dir), "run", "-h"], env=HELP_ENV) + + assert result.exit_code == 0 + assert loader.args == (str(ext_dir),) + + def test_root_help_short_circuits_before_the_callback( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath + ) -> None: + loader = Recorder() + monkeypatch.delenv("DREVALPY_EXTENSIONS_DIR", raising=False) + monkeypatch.setattr("drevalpy.registry.load_extension_dir", loader) + ext_dir = tmp_path / "ext" + ext_dir.mkdir() + + result = runner.invoke(app, ["-e", str(ext_dir), "-h"], env=HELP_ENV) + + assert result.exit_code == 0 + assert loader.call_count == 0 + + def test_short_option_accepts_repeats_in_order(self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath) -> None: + loader = Recorder() + monkeypatch.delenv("DREVALPY_EXTENSIONS_DIR", raising=False) + monkeypatch.setattr("drevalpy.registry.load_extension_dir", loader) + first = tmp_path / "one" + second = tmp_path / "two" + for directory in (first, second): + directory.mkdir() + + result = runner.invoke(app, ["-e", str(first), "-e", str(second), "run", "-h"], env=HELP_ENV) + + assert result.exit_code == 0 + assert [call[0][0] for call in loader.calls] == [str(first), str(second)] + + def test_env_var_is_loaded_before_the_option(self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath) -> None: + loader = Recorder() + env_dir = tmp_path / "from_env" + opt_dir = tmp_path / "from_option" + for directory in (env_dir, opt_dir): + directory.mkdir() + monkeypatch.setenv("DREVALPY_EXTENSIONS_DIR", str(env_dir)) + monkeypatch.setattr("drevalpy.registry.load_extension_dir", loader) + + result = runner.invoke(app, ["-e", str(opt_dir), "run", "-h"], env=HELP_ENV) + + assert result.exit_code == 0 + assert [call[0][0] for call in loader.calls] == [str(env_dir), str(opt_dir)] + + def test_unset_env_var_triggers_no_load(self, monkeypatch: pytest.MonkeyPatch) -> None: + loader = Recorder() + monkeypatch.delenv("DREVALPY_EXTENSIONS_DIR", raising=False) + monkeypatch.setattr("drevalpy.registry.load_extension_dir", loader) + + result = runner.invoke(app, ["run", "-h"], env=HELP_ENV) + + assert result.exit_code == 0 + assert loader.call_count == 0 + + def test_empty_env_var_triggers_no_load(self, monkeypatch: pytest.MonkeyPatch) -> None: + loader = Recorder() + monkeypatch.setenv("DREVALPY_EXTENSIONS_DIR", "") + monkeypatch.setattr("drevalpy.registry.load_extension_dir", loader) + + result = runner.invoke(app, ["run", "-h"], env=HELP_ENV) + + assert result.exit_code == 0 + assert loader.call_count == 0 + + def test_real_loader_accepts_an_empty_directory(self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath) -> None: + """An empty dir exercises the real loader without mutating any registry.""" + monkeypatch.delenv("DREVALPY_EXTENSIONS_DIR", raising=False) + ext_dir = tmp_path / "empty" + ext_dir.mkdir() + + result = runner.invoke(app, ["-e", str(ext_dir), "run", "-h"], env=HELP_ENV) + + assert result.exit_code == 0 + + def test_real_loader_rejects_a_path_that_is_not_a_directory( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath + ) -> None: + monkeypatch.delenv("DREVALPY_EXTENSIONS_DIR", raising=False) + not_a_dir = tmp_path / "plain.py" + not_a_dir.write_text("") + + result = runner.invoke(app, ["-e", str(not_a_dir), "run", "-h"], env=HELP_ENV) + + assert isinstance(result.exception, FileNotFoundError) + + +class TestCliMain: + """``cli_main`` is the console-script wrapper around ``app()``.""" + + def test_keyboard_interrupt_maps_to_exit_code_130( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + def interrupt() -> None: + raise KeyboardInterrupt + + monkeypatch.setattr("drevalpy.cli.main.app", interrupt) + + with pytest.raises(SystemExit) as exc_info: + cli_main() + + assert exc_info.value.code == 130 + assert "Interrupted." in capsys.readouterr().err + + def test_keyboard_interrupt_does_not_chain_the_original_exception( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + def interrupt() -> None: + raise KeyboardInterrupt + + monkeypatch.setattr("drevalpy.cli.main.app", interrupt) + + with pytest.raises(SystemExit) as exc_info: + cli_main() + capsys.readouterr() + + assert exc_info.value.__cause__ is None + + def test_clean_exit_is_propagated_untouched(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("drevalpy.cli.main.app", Recorder()) + + assert cli_main() is None diff --git a/tests/cli/test_report.py b/tests/cli/test_report.py new file mode 100644 index 000000000..d6c9a37e5 --- /dev/null +++ b/tests/cli/test_report.py @@ -0,0 +1,172 @@ +"""Tests for :mod:`drevalpy.cli.report`, the ``drevalpy report`` command. + +``create_report`` drives MultiQC, which is far too heavy for a CLI plumbing +test, so the worker is patched and the assertions cover argument forwarding, the +deliberately-unused ``--dataset`` flag and the echoed confirmation. Report +rendering itself belongs to the ``tests/visualization`` mirror. +""" + +from __future__ import annotations + +import logging + +import pytest +from upath import UPath + +from drevalpy.cli.main import app +from drevalpy.types.results import ExperimentResult +from tests.cli._helpers import HELP_ENV, FakeDataset, Recorder, make_runner, patch_worker, plain +from tests.synthetic import make_experiment_result + +runner = make_runner() + + +@pytest.fixture() +def experiment_dir(tmp_path: UPath) -> UPath: + """A real saved ``ExperimentResult`` tree, so ``load`` stays unpatched.""" + path = tmp_path / "experiment" + make_experiment_result(n_models=2, n_folds=2).save(path) + return path + + +@pytest.fixture() +def worker(monkeypatch: pytest.MonkeyPatch) -> Recorder: + """Patch :func:`drevalpy.visualization.report.create_report`. + + Args: + monkeypatch: Fixture used to replace the source-module worker. + + Returns: + Recorder standing in for the report writer. + """ + recorder = Recorder() + patch_worker(monkeypatch, "drevalpy.visualization.report", "create_report", recorder) + return recorder + + +class TestArguments: + """The experiment directory is the one required argument.""" + + def test_missing_experiment_dir_is_a_usage_error(self, worker: Recorder) -> None: + result = runner.invoke(app, ["report"], env=HELP_ENV) + + assert result.exit_code == 2 + + def test_nonexistent_experiment_dir_fails(self, worker: Recorder, tmp_path: UPath) -> None: + result = runner.invoke(app, ["report", str(tmp_path / "absent")]) + + assert result.exit_code != 0 + + def test_nonexistent_experiment_dir_does_not_reach_the_worker(self, worker: Recorder, tmp_path: UPath) -> None: + runner.invoke(app, ["report", str(tmp_path / "absent")]) + + assert worker.call_count == 0 + + +class TestForwarding: + """Options map onto :func:`create_report`'s signature.""" + + def test_exits_cleanly(self, worker: Recorder, experiment_dir: UPath) -> None: + result = runner.invoke(app, ["report", str(experiment_dir)]) + + assert result.exit_code == 0, result.output + + def test_passes_the_loaded_experiment_and_output_dir_positionally( + self, worker: Recorder, experiment_dir: UPath + ) -> None: + runner.invoke(app, ["report", str(experiment_dir), "--output-dir", "my_report"]) + + experiment, output_dir = worker.args + assert isinstance(experiment, ExperimentResult) + assert output_dir == "my_report" + + def test_default_output_dir_and_title(self, worker: Recorder, experiment_dir: UPath) -> None: + runner.invoke(app, ["report", str(experiment_dir)]) + + assert worker.args[1] == "report" + assert worker.kwargs["title"] == "Drug Response Evaluation" + + def test_title_short_option(self, worker: Recorder, experiment_dir: UPath) -> None: + runner.invoke(app, ["report", str(experiment_dir), "-t", "My Title"]) + + assert worker.kwargs["title"] == "My Title" + + def test_reference_model_defaults_to_none(self, worker: Recorder, experiment_dir: UPath) -> None: + runner.invoke(app, ["report", str(experiment_dir)]) + + assert worker.kwargs["reference_model"] is None + + def test_reference_model_short_option(self, worker: Recorder, experiment_dir: UPath) -> None: + runner.invoke(app, ["report", str(experiment_dir), "-r", "NaiveMeanEffectsPredictor"]) + + assert worker.kwargs["reference_model"] == "NaiveMeanEffectsPredictor" + + def test_echoes_the_output_directory(self, worker: Recorder, experiment_dir: UPath) -> None: + result = runner.invoke(app, ["report", str(experiment_dir), "-o", "out_here"]) + + assert "Report generated at out_here" in plain(result.output) + + +class TestDatasetEnrichment: + """``--dataset`` stays accepted but is deliberately never read. + + Every visualization takes ``dataset`` and ignores it, and the .h5mu is large enough + that loading it was a meaningful slice of the report container's memory, so the CLI + accepts the flag for pipeline compatibility and logs that it is unused. + """ + + def test_dataset_is_none_by_default(self, worker: Recorder, experiment_dir: UPath) -> None: + runner.invoke(app, ["report", str(experiment_dir)]) + + assert worker.kwargs["dataset"] is None + + def test_dataset_is_still_none_when_a_path_is_given( + self, worker: Recorder, experiment_dir: UPath, tmp_path: UPath + ) -> None: + result = runner.invoke(app, ["report", str(experiment_dir), "-d", str(tmp_path / "ds.h5mu")]) + + assert result.exit_code == 0, result.output + assert worker.kwargs["dataset"] is None + + def test_the_dataset_file_is_never_opened( + self, worker: Recorder, experiment_dir: UPath, monkeypatch: pytest.MonkeyPatch, tmp_path: UPath + ) -> None: + seen: list[str] = [] + monkeypatch.setattr( + "drevalpy.types.data.dataset.Dataset.load", + classmethod(lambda cls, path: seen.append(path) or FakeDataset()), + ) + + runner.invoke(app, ["report", str(experiment_dir), "--dataset", str(tmp_path / "ds.h5mu")]) + + assert seen == [] + + def test_the_ignored_dataset_is_logged( + self, worker: Recorder, experiment_dir: UPath, tmp_path: UPath, caplog: pytest.LogCaptureFixture + ) -> None: + dataset_path = tmp_path / "ds.h5mu" + + with caplog.at_level(logging.INFO, logger="drevalpy.cli.report"): + runner.invoke(app, ["report", str(experiment_dir), "--dataset", str(dataset_path)]) + + assert any(str(dataset_path) in record.getMessage() for record in caplog.records) + + +class TestTrialSkipping: + """The report never reads HPO trial predictions, which dwarf the fold predictions.""" + + def test_the_experiment_is_loaded_without_trials( + self, worker: Recorder, experiment_dir: UPath, monkeypatch: pytest.MonkeyPatch + ) -> None: + seen: list[bool] = [] + original = ExperimentResult.load + + def spy(directory, *, with_trials=True): + seen.append(with_trials) + return original(directory, with_trials=with_trials) + + monkeypatch.setattr(ExperimentResult, "load", staticmethod(spy)) + + runner.invoke(app, ["report", str(experiment_dir)]) + + assert seen == [False] diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py new file mode 100644 index 000000000..3ce3e2720 --- /dev/null +++ b/tests/cli/test_run.py @@ -0,0 +1,210 @@ +"""Tests for :mod:`drevalpy.cli.run`, the ``drevalpy run`` command.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from upath import UPath + +from drevalpy.cli.main import app +from tests.cli._helpers import HELP_ENV, Recorder, make_runner, patch_worker, plain + +runner = make_runner() + + +class StubExperiment: + """Stand-in for the ``ExperimentResult`` that ``run`` returns.""" + + def __init__(self) -> None: + """Start with an empty save log.""" + self.saved: list[str] = [] + + def save(self, path: str) -> None: + """Record the directory the command asked for. + + Args: + path: Destination directory chosen by the command. + """ + self.saved.append(path) + + def __repr__(self) -> str: + """Sentinel text the command is expected to echo verbatim.""" + return "STUB-EXPERIMENT-REPR" + + +@pytest.fixture() +def constructed() -> list[str]: + """Collect the model names handed to ``construct_model``.""" + return [] + + +@pytest.fixture() +def worker(monkeypatch: pytest.MonkeyPatch, constructed: list[str]) -> Recorder: + """Patch both lazy imports of ``run_cmd`` and hand back the ``run`` recorder. + + Args: + monkeypatch: Fixture used to replace the source-module workers. + constructed: List that receives every requested model name. + + Returns: + Recorder standing in for :func:`drevalpy.run`. + """ + + def fake_construct_model(name: str) -> type: + constructed.append(name) + return type(f"Stub{name}", (), {}) + + recorder = Recorder(return_value=StubExperiment()) + monkeypatch.setattr("drevalpy.models.construct_model", fake_construct_model) + patch_worker(monkeypatch, "drevalpy._run", "run", recorder) + return recorder + + +def _invoke(tmp_path: UPath, *extra: str) -> Any: + out_dir = tmp_path / "results" + return runner.invoke(app, ["run", "ElasticNet", "--dataset", "TOYv1", "--output-dir", str(out_dir), *extra]) + + +class TestDefaults: + """A minimal invocation forwards the documented default hyperparameters.""" + + def test_exits_cleanly(self, worker: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path) + + assert result.exit_code == 0, result.output + + def test_forwards_dataset_and_split_mode(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + assert worker.kwargs["dataset"] == "TOYv1" + assert worker.kwargs["split_mode"] == "LPO" + + def test_forwards_hpo_defaults(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + assert worker.kwargs["hyperparameter_tuning"] is True + assert worker.kwargs["hpo_metric"] == "RMSE" + assert worker.kwargs["hpo_num_samples"] == 16 + assert worker.kwargs["hpo_random_state"] == 42 + + def test_forwards_experiment_defaults(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + assert worker.kwargs["randomization_modes"] is None + assert worker.kwargs["randomization_type"] == "permutation" + assert worker.kwargs["robustness_trials"] == 0 + assert worker.kwargs["precomputed_only"] is False + + def test_passes_constructed_classes_not_names(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + assert [cls.__name__ for cls in worker.kwargs["models"]] == ["StubElasticNet"] + + +class TestModelArgument: + """``models`` is variadic and every name goes through ``construct_model``.""" + + def test_each_name_is_constructed(self, worker: Recorder, tmp_path: UPath, constructed: list[str]) -> None: + out_dir = tmp_path / "results" + runner.invoke( + app, + ["run", "ElasticNet", "RandomForest", "--dataset", "TOYv1", "--output-dir", str(out_dir)], + ) + + assert constructed == ["ElasticNet", "RandomForest"] + + def test_missing_models_is_a_usage_error(self, worker: Recorder) -> None: + result = runner.invoke(app, ["run", "--dataset", "TOYv1"], env=HELP_ENV) + + assert result.exit_code == 2 + + def test_missing_dataset_is_a_usage_error(self, worker: Recorder) -> None: + result = runner.invoke(app, ["run", "ElasticNet"], env=HELP_ENV) + + assert result.exit_code == 2 + + def test_nothing_runs_on_a_usage_error(self, worker: Recorder) -> None: + runner.invoke(app, ["run", "ElasticNet"], env=HELP_ENV) + + assert worker.call_count == 0 + + +class TestOptionForwarding: + """Every option maps onto a keyword of :func:`drevalpy.run`.""" + + def test_no_hpo_disables_tuning(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "--no-hpo") + + assert worker.kwargs["hyperparameter_tuning"] is False + + def test_split_mode_short_option(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "-s", "LCO") + + assert worker.kwargs["split_mode"] == "LCO" + + def test_repeated_randomization_modes_become_a_list(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "-r", "SVRC", "-r", "SVCD") + + assert worker.kwargs["randomization_modes"] == ["SVRC", "SVCD"] + + def test_randomization_type(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "--randomization-type", "invariant") + + assert worker.kwargs["randomization_type"] == "invariant" + + def test_hpo_tuning_options(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "--hpo-metric", "Pearson", "--hpo-num-samples", "3", "--hpo-random-state", "7") + + assert worker.kwargs["hpo_metric"] == "Pearson" + assert worker.kwargs["hpo_num_samples"] == 3 + assert worker.kwargs["hpo_random_state"] == 7 + + def test_robustness_trials(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "--robustness-trials", "4") + + assert worker.kwargs["robustness_trials"] == 4 + + def test_precomputed_only(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path, "--precomputed-only") + + assert worker.kwargs["precomputed_only"] is True + + def test_non_integer_hpo_samples_is_a_usage_error(self, worker: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path, "--hpo-num-samples", "many") + + assert result.exit_code == 2 + + +class TestOutput: + """The command owns creating the output directory and reporting where it wrote.""" + + def test_creates_the_output_directory_including_parents(self, worker: Recorder, tmp_path: UPath) -> None: + out_dir = tmp_path / "nested" / "results" + runner.invoke(app, ["run", "ElasticNet", "--dataset", "TOYv1", "--output-dir", str(out_dir)]) + + assert out_dir.is_dir() + + def test_tolerates_a_pre_existing_output_directory(self, worker: Recorder, tmp_path: UPath) -> None: + out_dir = tmp_path / "results" + out_dir.mkdir() + + result = _invoke(tmp_path) + + assert result.exit_code == 0, result.output + + def test_saves_the_result_into_the_output_directory(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path) + + experiment = worker.return_value + assert experiment.saved == [str(tmp_path / "results")] + + def test_echoes_the_output_directory(self, worker: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path) + + assert f"Wrote experiment results to {tmp_path / 'results'}" in plain(result.output) + + def test_echoes_the_result_repr(self, worker: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path) + + assert "STUB-EXPERIMENT-REPR" in plain(result.output) diff --git a/tests/cli/test_single.py b/tests/cli/test_single.py new file mode 100644 index 000000000..e2fe63789 --- /dev/null +++ b/tests/cli/test_single.py @@ -0,0 +1,270 @@ +"""Tests for :mod:`drevalpy.cli.single`, the ``drevalpy single`` command.""" + +from __future__ import annotations + +import numpy as np +import pytest +from sklearn.preprocessing import MinMaxScaler, RobustScaler, StandardScaler +from upath import UPath + +from drevalpy.cli.main import app +from drevalpy.types import SplitMask, SplitMasks +from tests.cli._helpers import HELP_ENV, FakeDataset, Recorder, make_runner, patch_worker, plain + +runner = make_runner() + + +class StubRunResult: + """Stand-in for the ``RunResult`` that :func:`drevalpy.single` returns.""" + + model_name = "StubElasticNet" + dataset_name = "StubDataset" + fold_index = 3 + + def __init__(self) -> None: + """Start with an empty save log.""" + self.saved: list[str] = [] + + def save(self, path: str) -> None: + """Record the destination the command derived. + + Args: + path: Output file path chosen by the command. + """ + self.saved.append(path) + + +def _write_split(path: UPath, *, metadata: dict[str, object] | None = None) -> None: + """Write a real 2x2 ``SplitMasks`` .npz so ``SplitMasks.load`` stays unpatched.""" + train = np.zeros((2, 2), dtype=bool) + train[0, 0] = True + test = np.zeros((2, 2), dtype=bool) + test[1, 1] = True + val = np.zeros((2, 2), dtype=bool) + val[0, 1] = True + SplitMasks( + train=SplitMask(train), + test=SplitMask(test), + val=SplitMask(val), + metadata=dict(metadata or {"fold_index": 3}), + ).save(path) + + +@pytest.fixture() +def split_file(tmp_path: UPath) -> UPath: + """A fold .npz whose metadata deliberately lacks ``split_mode``.""" + path = tmp_path / "fold_0.npz" + _write_split(path) + return path + + +@pytest.fixture() +def dataset() -> FakeDataset: + """The dataset stub ``Dataset.load`` is made to return.""" + return FakeDataset() + + +@pytest.fixture() +def worker(monkeypatch: pytest.MonkeyPatch, dataset: FakeDataset) -> Recorder: + """Patch every lazy import of ``single_cmd`` except ``SplitMasks``. + + ``SplitMasks.load`` is left real so the ``split_mode`` injection is asserted + against the genuine round-trip rather than a stub's attribute. + + Args: + monkeypatch: Fixture used to replace the source-module workers. + dataset: Stub returned by the patched ``Dataset.load``. + + Returns: + Recorder standing in for :func:`drevalpy.single`. + """ + recorder = Recorder(return_value=StubRunResult()) + monkeypatch.setattr("drevalpy.models.construct_model", lambda name: type(f"Stub{name}", (), {})) + monkeypatch.setattr("drevalpy.types.data.dataset.Dataset.load", classmethod(lambda cls, path: dataset)) + patch_worker(monkeypatch, "drevalpy._single", "single", recorder) + return recorder + + +def _invoke(split_file: UPath, tmp_path: UPath, *extra: str): + out = tmp_path / "out" / "result.npz" + return runner.invoke( + app, + ["single", "ElasticNet", str(tmp_path / "ds.h5mu"), str(split_file), str(out), *extra], + ) + + +class TestArguments: + """All four positional arguments are required.""" + + @pytest.mark.parametrize( + "argv", + [ + pytest.param(["single"], id="none"), + pytest.param(["single", "ElasticNet"], id="model-only"), + pytest.param(["single", "ElasticNet", "ds.h5mu"], id="missing-split-and-output"), + pytest.param(["single", "ElasticNet", "ds.h5mu", "fold.npz"], id="missing-output"), + ], + ) + def test_missing_positional_arguments_are_usage_errors(self, worker: Recorder, argv: list[str]) -> None: + result = runner.invoke(app, argv, env=HELP_ENV) + + assert result.exit_code == 2 + + +class TestForwarding: + """Options map onto :func:`drevalpy.single` keywords.""" + + def test_exits_cleanly(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + result = _invoke(split_file, tmp_path) + + assert result.exit_code == 0, result.output + + def test_passes_model_class_dataset_and_masks_positionally( + self, worker: Recorder, split_file: UPath, tmp_path: UPath, dataset: FakeDataset + ) -> None: + _invoke(split_file, tmp_path) + + model_class, passed_dataset, split_masks = worker.args + assert model_class.__name__ == "StubElasticNet" + assert passed_dataset is dataset + assert isinstance(split_masks, SplitMasks) + + def test_forwards_hpo_defaults(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + """Exhaustive on the keyword set, so a newly forwarded option cannot slip in unnoticed. + + ``response_transformation`` is popped rather than compared because sklearn + transformers have no value equality; :class:`TestResponseTransformation` + pins the instance it must be. + """ + _invoke(split_file, tmp_path) + + forwarded = dict(worker.kwargs) + forwarded.pop("response_transformation") + assert forwarded == { + "hyperparameter_tuning": True, + "hpo_metric": "RMSE", + "hpo_num_samples": 16, + "hpo_random_state": 42, + } + + def test_no_hpo_disables_tuning(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + _invoke(split_file, tmp_path, "--no-hpo") + + assert worker.kwargs["hyperparameter_tuning"] is False + + def test_forwards_overridden_hpo_options(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + _invoke(split_file, tmp_path, "--hpo-metric", "Pearson", "--hpo-num-samples", "2", "--hpo-random-state", "9") + + assert worker.kwargs["hpo_metric"] == "Pearson" + assert worker.kwargs["hpo_num_samples"] == 2 + assert worker.kwargs["hpo_random_state"] == 9 + + def test_split_mode_is_not_a_worker_keyword(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + """``--split-mode`` only ever reaches the worker through the masks' metadata.""" + _invoke(split_file, tmp_path, "--split-mode", "LCO") + + assert "split_mode" not in worker.kwargs + + +class TestResponseTransformation: + """``--response-transformation`` resolves to the sklearn transformer prototype.""" + + def test_the_default_standardizes_the_response(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + _invoke(split_file, tmp_path) + + assert isinstance(worker.kwargs["response_transformation"], StandardScaler) + + def test_the_prototype_is_handed_over_unfitted(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + """``single`` fits a clone per scope, so the CLI must not fit anything itself.""" + _invoke(split_file, tmp_path) + + assert not hasattr(worker.kwargs["response_transformation"], "mean_") + + @pytest.mark.parametrize( + ("option", "expected"), + [ + pytest.param("standard", StandardScaler, id="standard"), + pytest.param("minmax", MinMaxScaler, id="minmax"), + pytest.param("robust", RobustScaler, id="robust"), + ], + ) + def test_each_option_selects_its_transformer( + self, worker: Recorder, split_file: UPath, tmp_path: UPath, option: str, expected: type + ) -> None: + _invoke(split_file, tmp_path, "--response-transformation", option) + + assert isinstance(worker.kwargs["response_transformation"], expected) + + def test_none_disables_the_transformation(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + _invoke(split_file, tmp_path, "--response-transformation", "None") + + assert worker.kwargs["response_transformation"] is None + + def test_an_unknown_option_is_rejected(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + result = _invoke(split_file, tmp_path, "--response-transformation", "logarithmic") + + assert result.exit_code != 0 + assert worker.call_count == 0 + + +class TestSplitModeInjection: + """``--split-mode`` is a fallback: it fills the gap, it does not override.""" + + def test_injected_when_absent_from_metadata(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + _invoke(split_file, tmp_path, "--split-mode", "LCO") + + assert worker.args[2].metadata["split_mode"] == "LCO" + + def test_default_is_lpo(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + _invoke(split_file, tmp_path) + + assert worker.args[2].metadata["split_mode"] == "LPO" + + def test_existing_metadata_wins(self, worker: Recorder, tmp_path: UPath) -> None: + split_file = tmp_path / "fold_with_mode.npz" + _write_split(split_file, metadata={"fold_index": 3, "split_mode": "LDO"}) + + _invoke(split_file, tmp_path, "--split-mode", "LCO") + + assert worker.args[2].metadata["split_mode"] == "LDO" + + def test_other_metadata_is_preserved(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + _invoke(split_file, tmp_path) + + assert worker.args[2].metadata["fold_index"] == 3 + + +class TestOutput: + """The command creates the output *parent* and echoes a one-line summary.""" + + def test_creates_the_parent_directory(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + _invoke(split_file, tmp_path) + + assert (tmp_path / "out").is_dir() + + def test_saves_to_the_requested_path(self, worker: Recorder, split_file: UPath, tmp_path: UPath) -> None: + _invoke(split_file, tmp_path) + + assert worker.return_value.saved == [str(tmp_path / "out" / "result.npz")] + + def test_echoes_model_dataset_fold_and_destination( + self, worker: Recorder, split_file: UPath, tmp_path: UPath + ) -> None: + result = _invoke(split_file, tmp_path) + + expected = f"Result: StubElasticNet on StubDataset (fold 3) -> {tmp_path / 'out' / 'result.npz'}" + assert expected in plain(result.output) + + +class TestMissingSplitFile: + """A missing fold file surfaces as the loader's own error, not a silent pass.""" + + def test_nonexistent_split_raises(self, worker: Recorder, tmp_path: UPath) -> None: + result = _invoke(tmp_path / "absent.npz", tmp_path) + + assert result.exit_code != 0 + + def test_worker_is_not_called(self, worker: Recorder, tmp_path: UPath) -> None: + _invoke(tmp_path / "absent.npz", tmp_path) + + assert worker.call_count == 0 diff --git a/tests/cli/test_test_cv.py b/tests/cli/test_test_cv.py deleted file mode 100644 index ed6562e02..000000000 --- a/tests/cli/test_test_cv.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Regression tests for ``drevalpy test-cv`` in randomization mode.""" - -from __future__ import annotations - -import os -import pathlib -import pickle - -import pytest -import yaml -from typer.testing import CliRunner - -from drevalpy.cli.main import app -from drevalpy.datasets.dataset import DrugResponseDataset - -runner = CliRunner() - - -@pytest.mark.parametrize("randomization_type", ["permutation", "invariant"]) -def test_randomization_cli( - data_dir: pathlib.Path, - sample_dataset: DrugResponseDataset, - tmp_path: pathlib.Path, - randomization_type: str, -) -> None: - """Tests the functionality of the CLI call test-cv --mode randomization.""" - cv_splits = sample_dataset.split_dataset(n_cv_splits=5, mode="LCO", random_state=42) - split = cv_splits[0] - - split_path = tmp_path / "split_0.pkl" - with open(split_path, "wb") as fh: - pickle.dump(split, fh) - - hpam_path = tmp_path / "best_hpam_combi_split_0.yaml" - with open(hpam_path, "w") as fh: - yaml.dump( - { - "ElasticNet_split_0": { - "best_hpam_combi": { - "cell_line_views": ["gene_expression"], - "drug_views": ["fingerprints"], - "alpha": 0.1, - "l1_ratio": 0.5, - } - } - }, - fh, - ) - - rand_views_path = tmp_path / "randomization_test_view_SVRC_gene_expression.yaml" - with open(rand_views_path, "w") as fh: - yaml.dump({"test_name": "SVRC_gene_expression", "view": "gene_expression"}, fh) - - prev_dir = os.getcwd() - try: - os.chdir(tmp_path) - result = runner.invoke( - app, - [ - "test-cv", - "--mode", - "randomization", - "--model_name", - "ElasticNet", - "--split_id", - "split_0", - "--split_dataset_path", - str(split_path), - "--hyperparameters_path", - str(hpam_path), - "--path_data", - str(data_dir), - "--randomization_views_path", - str(rand_views_path), - "--randomization_type", - randomization_type, - "--test_mode", - "LCO", - ], - ) - finally: - os.chdir(prev_dir) - - assert result.exit_code == 0, f"CLI exited with code {result.exit_code}:\n{result.output}" - - rand_files = list(tmp_path.rglob("randomization_SVRC_gene_expression_split_0.csv")) - assert len(rand_files) == 1, f"Expected one output CSV, found: {rand_files}" diff --git a/tests/components/contracts/test_contracts.py b/tests/components/contracts/test_contracts.py new file mode 100644 index 000000000..24d128f8c --- /dev/null +++ b/tests/components/contracts/test_contracts.py @@ -0,0 +1,85 @@ +"""Tests for internal feature contracts.""" + +from typing import Any, cast + +import pytest + +from drevalpy.components.contracts.contracts import ( + FeatureContract, + FeatureFormat, + contracts_compatible, + featurizer_contract, + normalize_feature_contract, + predictor_contracts, +) + + +def test_numeric_contracts_compatible() -> None: + produced = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + required = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + assert contracts_compatible(produced, required) + + +def test_graph_contracts_compatible_by_format_only() -> None: + produced = FeatureContract(format=FeatureFormat.GRAPH) + required = FeatureContract(format=FeatureFormat.GRAPH) + assert contracts_compatible(produced, required) + + +def test_ragged_contracts_compatible() -> None: + produced = FeatureContract(format=FeatureFormat.RAGGED_SEQUENCE) + required = FeatureContract(format=FeatureFormat.RAGGED_SEQUENCE) + assert contracts_compatible(produced, required) + + +def test_format_mismatch_is_incompatible() -> None: + produced = FeatureContract(format=FeatureFormat.GRAPH) + required = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + assert not contracts_compatible(produced, required) + + +def test_feature_contract_is_frozen() -> None: + contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + try: + cast(Any, contract).format = FeatureFormat.GRAPH + raised = False + except AttributeError: + raised = True + assert raised + + +def test_normalize_feature_contract_accepts_format_shorthand() -> None: + assert normalize_feature_contract(FeatureFormat.GRAPH) == FeatureContract(format=FeatureFormat.GRAPH) + + +def test_featurizer_contract_reads_canonical_attribute() -> None: + class WithContract: + contract = FeatureContract(format=FeatureFormat.GRAPH) + + assert featurizer_contract(WithContract).format == FeatureFormat.GRAPH + + +def test_featurizer_contract_requires_contract() -> None: + class WithoutContract: + pass + + with pytest.raises(TypeError, match="must define a contract"): + featurizer_contract(WithoutContract) + + +def test_predictor_contracts_reads_canonical_attributes() -> None: + class WithContracts: + cell_line_contract = FeatureContract(format=FeatureFormat.RAGGED_SEQUENCE) + drug_contract = FeatureContract(format=FeatureFormat.GRAPH) + + cell_line, drug = predictor_contracts(WithContracts) + assert cell_line.format == FeatureFormat.RAGGED_SEQUENCE + assert drug.format == FeatureFormat.GRAPH + + +def test_predictor_contracts_requires_both_contracts() -> None: + class MissingDrugContract: + cell_line_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + with pytest.raises(TypeError, match="must define both"): + predictor_contracts(MissingDrugContract) diff --git a/tests/components/contracts/test_hyperparameter_space.py b/tests/components/contracts/test_hyperparameter_space.py new file mode 100644 index 000000000..24d74f0a7 --- /dev/null +++ b/tests/components/contracts/test_hyperparameter_space.py @@ -0,0 +1,191 @@ +"""Tests for hyperparameter-space default enforcement.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from pydantic import ValidationError + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.contracts.hyperparameter_space import ( + TunableComponentMixin, + validate_component_hyperparameter_space, + validate_hyperparameter_space, +) +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.models.config import FeaturizerConfig, PredictorConfig +from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry +from drevalpy.registry.cell_line_featurizer import register as register_cell_line_featurizer +from drevalpy.registry.drug_featurizer import drug_featurizer_registry +from drevalpy.registry.predictor import predictor_registry +from drevalpy.registry.predictor import register as register_predictor + + +@pytest.fixture(autouse=True) +def _clear_registries() -> Iterator[None]: + cell_line_featurizer_registry.clear() + drug_featurizer_registry.clear() + predictor_registry.clear() + yield + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + + +def test_validate_hyperparameter_space_rejects_missing_default() -> None: + with pytest.raises(ValueError, match="missing 'default' for 'alpha'"): + validate_hyperparameter_space( + {"alpha": {"type": "float", "low": 0.1, "high": 1.0}}, + context="unit-test", + ) + + +def test_validate_hyperparameter_space_rejects_non_mapping_spec() -> None: + with pytest.raises(ValueError, match="non-mapping specs for 'alpha'"): + validate_hyperparameter_space({"alpha": 1.0}, context="unit-test") + + +def test_validate_hyperparameter_space_accepts_complete_specs() -> None: + validate_hyperparameter_space( + {"alpha": {"type": "float", "default": 0.5}}, + context="unit-test", + ) + + +def test_predictor_registration_rejects_space_without_default() -> None: + with pytest.raises(ValueError, match="missing 'default'"): + + @register_predictor( + "badSpacePred", + description="missing default", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class BadSpacePred(MatrixPredictor): + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, object]]: + return {"alpha": {"type": "float", "low": 0.1, "high": 1.0}} + + def _fit_matrix(self, x, y) -> None: + return None + + def _predict_matrix(self, x): + return x[:, 0] + + +def test_featurizer_registration_rejects_space_without_default() -> None: + with pytest.raises(ValueError, match="missing 'default'"): + + @register_cell_line_featurizer( + "badSpaceFeat", + description="missing default", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class BadSpaceFeat: + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, object]]: + return {"n_components": {"type": "int", "low": 8, "high": 64}} + + +def test_predictor_config_rejects_space_without_default() -> None: + with pytest.raises(ValidationError, match="missing 'default'"): + PredictorConfig( + name="elasticNet", + hyperparameter_space={"alpha": {"type": "float", "low": 0.1, "high": 1.0}}, + ) + + +def test_featurizer_config_rejects_space_without_default() -> None: + with pytest.raises(ValidationError, match="missing 'default'"): + FeaturizerConfig( + name="pca", + view="expression", + hyperparameter_space={"n_components": {"type": "int", "low": 8, "high": 64}}, + ) + + +def test_validate_component_hyperparameter_space_uses_class_getter() -> None: + class Ok: + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, object]]: + return {"alpha": {"type": "float", "default": 1.0}} + + validate_component_hyperparameter_space("ok", Ok) + + +class TestTunableComponentMixin: + """The four hooks both component kinds inherit rather than each declaring. + + ``Featurizer`` and ``Predictor`` are siblings under ``components/``, so this + module - the leaf both already imported the validator from - is the only home + for the shared copy that does not invert a dependency between them. + """ + + @pytest.mark.parametrize("component", [Featurizer, Predictor], ids=["featurizer", "predictor"]) + def test_both_component_bases_inherit_the_mixin(self, component: type) -> None: + assert issubclass(component, TunableComponentMixin) + + @pytest.mark.parametrize( + "hook", + ["get_hyperparameter_space", "get_default_hyperparameters", "get_state", "set_state"], + ) + def test_neither_base_keeps_its_own_copy_of_a_shared_hook(self, hook: str) -> None: + """A re-declaration would be a second implementation to keep in sync.""" + assert hook not in Featurizer.__dict__ + assert hook not in Predictor.__dict__ + + def test_the_default_space_is_empty_for_a_component_with_nothing_to_tune(self) -> None: + class Untunable(TunableComponentMixin): + pass + + assert Untunable.get_hyperparameter_space() == {} + assert Untunable.get_default_hyperparameters() == {} + + def test_defaults_are_read_off_the_declared_space(self) -> None: + class Tunable(TunableComponentMixin): + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, object]]: + return {"alpha": {"type": "float", "low": 0.1, "high": 1.0, "default": 0.25}} + + assert Tunable.get_default_hyperparameters() == {"alpha": 0.25} + + def test_defaults_reject_a_space_entry_without_one(self) -> None: + """The validator runs on the read path, not only at registration.""" + + class Incomplete(TunableComponentMixin): + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, object]]: + return {"alpha": {"type": "float", "low": 0.1, "high": 1.0}} + + with pytest.raises(ValueError, match=r"Incomplete.get_hyperparameter_space\(\)"): + Incomplete.get_default_hyperparameters() + + def test_the_default_state_is_empty_and_restoring_it_is_a_no_op(self) -> None: + """An unfitted component has nothing to persist; ``set_state`` must tolerate it.""" + + class Stateless(TunableComponentMixin): + pass + + component = Stateless() + + assert component.get_state() == {} + assert component.set_state({"ignored": 1}) is None + assert component.get_state() == {} + + def test_is_fitted_reads_the_inherited_state_hook(self) -> None: + """``Predictor.is_fitted`` stays predictor-only but is defined over ``get_state``.""" + + class Fitted(Predictor): + def get_state(self) -> dict[str, object]: + return {"weights": [1.0]} + + def _fit(self, batch) -> None: + return None + + def _predict(self, batch): + return None + + assert Fitted().is_fitted() is True diff --git a/tests/components/contracts/test_training_context.py b/tests/components/contracts/test_training_context.py new file mode 100644 index 000000000..9f03d33ae --- /dev/null +++ b/tests/components/contracts/test_training_context.py @@ -0,0 +1,50 @@ +"""Tests for the per-training-call runtime context. + +``training_context`` stores its checkpoint directory as a ``pathlib.Path`` +rather than the ``UPath`` used elsewhere in the package; these tests assert the +behaviour that exists today rather than the repo-wide convention. +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from drevalpy.components.contracts.training_context import _DEFAULT_CHECKPOINT_DIR, TrainingContext + + +def test_training_context_defaults_to_the_module_checkpoint_dir() -> None: + context = TrainingContext() + + assert context.checkpoint_dir == _DEFAULT_CHECKPOINT_DIR + assert str(_DEFAULT_CHECKPOINT_DIR) == "checkpoints" + + +def test_training_context_defaults_to_empty_logging_metadata() -> None: + context = TrainingContext() + + assert context.logging_metadata == {} + + +def test_training_context_logging_metadata_is_not_shared_between_instances() -> None: + first = TrainingContext() + second = TrainingContext() + + first.logging_metadata["run"] = "1" + + assert second.logging_metadata == {} + + +def test_training_context_accepts_an_explicit_checkpoint_dir(tmp_path) -> None: + context = TrainingContext(checkpoint_dir=tmp_path, logging_metadata={"fold": "0"}) + + assert context.checkpoint_dir == tmp_path + assert context.logging_metadata == {"fold": "0"} + + +def test_training_context_is_frozen(tmp_path) -> None: + context = TrainingContext(checkpoint_dir=tmp_path) + + with pytest.raises(dataclasses.FrozenInstanceError): + context.checkpoint_dir = tmp_path / "other" diff --git a/tests/components/featurizers/_helpers.py b/tests/components/featurizers/_helpers.py new file mode 100644 index 000000000..506732c14 --- /dev/null +++ b/tests/components/featurizers/_helpers.py @@ -0,0 +1,82 @@ +"""Shared stubs for featurizer tests. + +Plain module (no ``__init__.py``) imported by dotted path, per the test layout +rules in ``AGENTS.md``. +""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.types.data.batch.feature_block import FeatureBlock, numeric_feature_block +from drevalpy.types.data.feature_source import FeatureSource + + +class StubSource(FeatureSource): + """Minimal feature source serving one view matrix, NaN rows included.""" + + def __init__(self, view_matrix: np.ndarray, identifiers: np.ndarray) -> None: + """Store the backing matrix and its row identifiers. + + :param view_matrix: Rows aligned with *identifiers*. + :param identifiers: Entity IDs addressing the matrix rows. + """ + self._view_matrix = view_matrix + self._identifiers = identifiers + + @property + def identifiers(self) -> np.ndarray: + """All available entity IDs.""" + return self._identifiers + + @property + def mdata(self) -> None: + """No MuData backing for stubs.""" + return None + + def get_view_matrix(self, view: str, entity_ids: np.ndarray) -> np.ndarray: + """Return the rows of the backing matrix for *entity_ids*.""" + idx_map = {eid: i for i, eid in enumerate(self._identifiers)} + indices = [idx_map[eid] for eid in entity_ids] + return self._view_matrix[indices] + + def get_entity_view(self, entity_id: str, view: str) -> np.ndarray | None: + """Return one row, or ``None`` for an unknown entity.""" + idx_map = {eid: i for i, eid in enumerate(self._identifiers)} + if entity_id not in idx_map: + return None + return self._view_matrix[idx_map[entity_id]] + + def get_feature_names(self, view: str) -> tuple[str, ...] | None: + """No feature names for stubs.""" + return None + + +class DoublingFeaturizer(Featurizer): + """Featurizer that doubles the values of ``test_view``.""" + + input_views = ("test_view",) + + def _fit(self, source, *, entity_ids=None, pair_expanded_ids=None, pair_expanded_es_ids=None): + """Fitting is a no-op; the transform is stateless.""" + return self + + def _transform_blocks(self, source: FeatureSource, entity_ids: np.ndarray) -> dict[str, FeatureBlock]: + """Return one numeric block holding the doubled view.""" + return {"test_view": numeric_feature_block(self._transform(source, entity_ids))} + + def _transform(self, source: FeatureSource, entity_ids: np.ndarray) -> np.ndarray: + """Return the doubled view matrix.""" + matrix = source.get_view_matrix("test_view", entity_ids) + return (matrix * 2).astype(np.float32) + + @property + def output_dim(self) -> int: + """Fixed width of the stub view.""" + return 3 + + +# Registration normally injects the contract; these stubs are never registered. +DoublingFeaturizer.contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) diff --git a/tests/components/featurizers/cell_line/_helpers.py b/tests/components/featurizers/cell_line/_helpers.py new file mode 100644 index 000000000..8f7fa7435 --- /dev/null +++ b/tests/components/featurizers/cell_line/_helpers.py @@ -0,0 +1,87 @@ +"""Shared helpers for cell-line featurizer tests. + +Plain module (no ``__init__.py``) imported by dotted path, per the test layout +rules in ``AGENTS.md``. +""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.featurizers.storage import register_variant +from drevalpy.types.data.feature_source import CellLineFeatureSource +from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + +PRECOMPUTED = np.array([[9.0, 8.0], [7.0, 6.0]], dtype=np.float32) + + +def precomputed_source( + featurizer_cls: type, + *, + matrix: np.ndarray | None = None, + hyperparameters: dict[str, object] | None = None, +) -> CellLineFeatureSource: + """Return a dataset-backed source carrying a registered variant for *featurizer_cls*. + + Exercises the ``fetch``-hit branch every dense cell-line featurizer takes when + ``Dataset.precompute()`` has already written its matrix. + + :param featurizer_cls: Featurizer whose ``storage_key`` / ``side`` to register under. + :param matrix: Values to store; defaults to :data:`PRECOMPUTED`. + :param hyperparameters: HP setting the variant was computed under; featurizers + that pass their own HPs to ``fetch`` (``pca``) need these to match. + :returns: Cell-line feature source over a 2x2 synthetic dataset. + """ + values = PRECOMPUTED if matrix is None else matrix + dataset = synthetic_mudataset_gene_expression_fingerprints() + key = f"{featurizer_cls.__name__}_precomputed" + dataset.mdata.mod["response"].obsm[key] = values + register_variant( + dataset.mdata, + featurizer_cls.storage_key, + key, + hyperparameters, + side=featurizer_cls.side, + ) + return CellLineFeatureSource(dataset, dataset.cell_line_ids) + + +def assert_uses_precomputed_variant( + featurizer, + *, + ids_kwarg: str = "entity_ids", + hyperparameters: dict[str, object] | None = None, + expect_output_dim: bool = True, + expected_blocks: tuple[str, ...] | None = None, +) -> None: + """Assert *featurizer* serves a stored matrix rather than recomputing one. + + Every dense cell-line featurizer takes the identical ``fetch``-hit path, so + this assertion was written out once per featurizer. What actually differs per + featurizer is captured in the keyword arguments: which keyword its ``fit`` + names the entity IDs with, whether it forwards its own hyperparameters to + ``fetch``, whether ``output_dim`` follows the stored width, and which blocks + ``transform_blocks`` is expected to emit. + + :param featurizer: Unfitted featurizer instance to exercise. + :param ids_kwarg: ``fit`` keyword carrying the entity IDs. + :param hyperparameters: HP setting the stored variant is registered under. + :param expect_output_dim: Assert ``output_dim`` equals the stored width. + :param expected_blocks: When given, also assert ``transform_blocks`` returns + exactly these block names, each carrying the stored matrix. + """ + source = precomputed_source(type(featurizer), hyperparameters=hyperparameters) + ids = source.identifiers + + featurizer.fit(source, **{ids_kwarg: ids}) + + if expect_output_dim: + assert featurizer.output_dim == PRECOMPUTED.shape[1] + np.testing.assert_allclose(featurizer.transform(source, ids), PRECOMPUTED) + + if expected_blocks is None: + return + blocks = featurizer.transform_blocks(source, ids) + assert set(blocks) == set(expected_blocks) + for name in expected_blocks: + np.testing.assert_allclose(blocks[name].values, PRECOMPUTED) diff --git a/tests/components/featurizers/cell_line/gene_lists/test_init.py b/tests/components/featurizers/cell_line/gene_lists/test_init.py new file mode 100644 index 000000000..aad2565b9 --- /dev/null +++ b/tests/components/featurizers/cell_line/gene_lists/test_init.py @@ -0,0 +1,51 @@ +"""Tests for gene-list resolution helpers.""" + +from __future__ import annotations + +import pandas as pd +import pytest +from upath import UPath + +from drevalpy.components.featurizers.cell_line.gene_lists import ( + GENE_LISTS_DIR, + gene_names_from_list_csv, + resolve_gene_list_path, +) + + +def test_gene_names_from_symbol_column(tmp_path: UPath) -> None: + path = tmp_path / "genes.csv" + pd.DataFrame({"Symbol": ["A", "B", "C"]}).to_csv(path, index=False) + assert gene_names_from_list_csv(path) == ["A", "B", "C"] + + +def test_gene_names_from_gene_name_column(tmp_path: UPath) -> None: + path = tmp_path / "genes.csv" + pd.DataFrame({"gene_name": ["X", "Y"]}).to_csv(path, index=False) + assert gene_names_from_list_csv(path) == ["X", "Y"] + + +def test_gene_names_rejects_unknown_columns(tmp_path: UPath) -> None: + path = tmp_path / "genes.csv" + pd.DataFrame({"other": ["A"]}).to_csv(path, index=False) + with pytest.raises(ValueError, match="recognized gene-name column"): + gene_names_from_list_csv(path) + + +def test_resolve_gene_list_path_uses_packaged_csv() -> None: + path = resolve_gene_list_path("landmark_genes") + assert path.is_file() + assert path == GENE_LISTS_DIR / "landmark_genes.csv" + + +def test_resolve_gene_list_path_ignores_cache_dir(tmp_path: UPath, monkeypatch: pytest.MonkeyPatch) -> None: + gene_dir = tmp_path / "meta" / "gene_lists" + gene_dir.mkdir(parents=True) + pd.DataFrame({"Symbol": ["G1"]}).to_csv(gene_dir / "landmark_genes.csv", index=False) + monkeypatch.setenv("DREVALPY_CACHE_DIR", str(tmp_path)) + assert resolve_gene_list_path("landmark_genes") == GENE_LISTS_DIR / "landmark_genes.csv" + + +def test_resolve_gene_list_path_lists_available_lists_when_missing() -> None: + with pytest.raises(FileNotFoundError, match="Available gene lists: .*landmark_genes"): + resolve_gene_list_path("not_a_gene_list") diff --git a/tests/components/featurizers/cell_line/test_base.py b/tests/components/featurizers/cell_line/test_base.py new file mode 100644 index 000000000..463224a72 --- /dev/null +++ b/tests/components/featurizers/cell_line/test_base.py @@ -0,0 +1,56 @@ +"""Tests for the cell-line featurizer base class. + +Mirrors :mod:`drevalpy.components.featurizers.cell_line.base`, a three-statement +subclass of ``Featurizer``: the only behaviour it carries is its position in the +MRO, which registration and the config layer both key off. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.featurizers._dense_view import DenseViewFeaturizer +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer, DenseViewCellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.cell_line_featurizer import list as list_cell_line_featurizers + + +def test_cell_line_featurizer_extends_the_shared_base() -> None: + assert issubclass(CellLineFeaturizer, Featurizer) + + +def test_cell_line_featurizer_is_abstract() -> None: + with pytest.raises(TypeError, match="abstract"): + CellLineFeaturizer() + + +def test_cell_line_featurizer_adds_no_state_of_its_own() -> None: + assert set(CellLineFeaturizer.__dict__) - set(Featurizer.__dict__) <= { + "__module__", + "__doc__", + "__abstractmethods__", + "_abc_impl", + "__firstlineno__", + "__static_attributes__", + } + + +def test_every_registered_cell_line_featurizer_derives_from_the_base() -> None: + names = list_cell_line_featurizers() + + assert names + for name in names: + assert issubclass(get_cell_line_featurizer(name), CellLineFeaturizer), name + + +def test_dense_view_binding_sits_on_both_the_shared_base_and_the_side_base() -> None: + assert issubclass(DenseViewCellLineFeaturizer, DenseViewFeaturizer) + assert issubclass(DenseViewCellLineFeaturizer, CellLineFeaturizer) + + +def test_dense_view_binding_resolves_the_side_base_before_the_shared_featurizer() -> None: + """The MRO order is what lets the cell-line base override shared behaviour.""" + mro = DenseViewCellLineFeaturizer.__mro__ + + assert mro.index(CellLineFeaturizer) < mro.index(Featurizer) diff --git a/tests/components/featurizers/cell_line/test_bionic.py b/tests/components/featurizers/cell_line/test_bionic.py new file mode 100644 index 000000000..1661d7f14 --- /dev/null +++ b/tests/components/featurizers/cell_line/test_bionic.py @@ -0,0 +1,148 @@ +"""Tests for the BIONIC PPI cell-line featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.cell_line.bionic`. Only +``_load_ppi_data`` needs the 83 MB artifact download, so the aggregation kernel is +tested directly with a hand-built PPI lookup. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line.bionic import ( + BionicCellLineFeaturizer, + _aggregate_ppi_for_cell_line, +) +from tests.conftest import MockFeatureSource + +_GENE_NAMES = ("g_high", "g_mid", "g_low") +_EXPR_ROW = np.array([3.0, 2.0, 1.0]) +_PPI_LOOKUP = { + "g_high": np.array([1.0, 5.0], dtype=np.float32), + "g_mid": np.array([3.0, 1.0], dtype=np.float32), + "g_low": np.array([100.0, 100.0], dtype=np.float32), +} + + +def test_hyperparameter_space_exposes_gene_add_num_and_aggregation() -> None: + assert set(BionicCellLineFeaturizer.get_hyperparameter_space()) == {"gene_add_num", "aggregation"} + + +@pytest.mark.parametrize( + ("aggregation", "expected"), + [ + pytest.param("mean", [2.0, 3.0], id="mean"), + pytest.param("max", [3.0, 5.0], id="max"), + pytest.param("sum", [4.0, 6.0], id="sum"), + pytest.param("unrecognised", [2.0, 3.0], id="unknown-falls-back-to-mean"), + ], +) +def test_aggregate_selects_the_top_expressed_eligible_genes( + aggregation: str, + expected: list[float], +) -> None: + vector = _aggregate_ppi_for_cell_line( + _EXPR_ROW, + _GENE_NAMES, + {"g_high", "g_mid"}, + _PPI_LOOKUP, + gene_add_num=2, + embed_dim=2, + aggregation=aggregation, + ) + + np.testing.assert_allclose(vector, expected) + assert vector.dtype == np.float32 + + +def test_aggregate_honours_the_gene_add_num_budget() -> None: + vector = _aggregate_ppi_for_cell_line( + _EXPR_ROW, + _GENE_NAMES, + set(_GENE_NAMES), + _PPI_LOOKUP, + gene_add_num=1, + embed_dim=2, + aggregation="mean", + ) + + np.testing.assert_allclose(vector, [1.0, 5.0]) + + +def test_aggregate_returns_a_zero_vector_when_nothing_is_eligible() -> None: + vector = _aggregate_ppi_for_cell_line( + _EXPR_ROW, + _GENE_NAMES, + set(), + _PPI_LOOKUP, + gene_add_num=2, + embed_dim=2, + aggregation="mean", + ) + + np.testing.assert_allclose(vector, [0.0, 0.0]) + + +def test_bionic_reads_the_bionic_features_view_when_present() -> None: + source = MockFeatureSource( + features={ + "cl1": {"bionic_features": np.array([0.1, 0.2])}, + "cl2": {"bionic_features": np.array([0.3, 0.4])}, + } + ) + ids = np.array(["cl1", "cl2"], dtype=str) + + featurizer = BionicCellLineFeaturizer().fit(source, entity_ids=ids) + + assert featurizer.output_dim == 2 + np.testing.assert_allclose(featurizer.transform(source, ids), [[0.1, 0.2], [0.3, 0.4]], rtol=1e-6) + + +def test_bionic_compute_from_source_requires_gene_expression_feature_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import drevalpy.components.featurizers.cell_line.bionic as bionic_module + + monkeypatch.setattr( + bionic_module, + "_load_ppi_data", + lambda: (np.zeros((1, 2), dtype=np.float32), ["g_high"], {"g_high"}), + ) + source = MockFeatureSource(features={"cl1": {"gene_expression": np.array([1.0, 2.0])}}) + + with pytest.raises(ValueError, match="must provide feature names"): + BionicCellLineFeaturizer()._compute_from_source(source, np.array(["cl1"], dtype=str)) + + +def test_bionic_compute_from_source_aggregates_per_cell_line(monkeypatch: pytest.MonkeyPatch) -> None: + import drevalpy.components.featurizers.cell_line.bionic as bionic_module + + monkeypatch.setattr( + bionic_module, + "_load_ppi_data", + lambda: (np.array([[1.0, 5.0], [3.0, 1.0]], dtype=np.float32), ["g_high", "g_mid"], {"g_high", "g_mid"}), + ) + source = MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([3.0, 2.0])}, + "cl2": {"gene_expression": np.array([1.0, 4.0])}, + }, + meta_info={"gene_expression": ["g_high", "g_mid"]}, + ) + + matrix = BionicCellLineFeaturizer()._compute_from_source(source, np.array(["cl1", "cl2"], dtype=str)) + + assert matrix.shape == (2, 2) + np.testing.assert_allclose(matrix[0], [2.0, 3.0]) + + +@pytest.mark.network +def test_load_ppi_data_returns_features_gene_names_and_selection() -> None: + from drevalpy.components.featurizers.cell_line.bionic import _load_ppi_data + + ppi_features, ppi_gene_names, gene_list_sel = _load_ppi_data() + + assert ppi_features.ndim == 2 + assert len(ppi_gene_names) == ppi_features.shape[0] + assert gene_list_sel diff --git a/tests/components/featurizers/cell_line/test_dipk_gene_expression.py b/tests/components/featurizers/cell_line/test_dipk_gene_expression.py new file mode 100644 index 000000000..e8e1fbeca --- /dev/null +++ b/tests/components/featurizers/cell_line/test_dipk_gene_expression.py @@ -0,0 +1,85 @@ +"""Tests for the DIPK gene-expression featurizer.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line.dipk_gene_expression import ( + DIPKGeneExpressionFeaturizer, + GeneExpressionEncoder, +) +from tests.components.featurizers.cell_line._helpers import assert_uses_precomputed_variant +from tests.conftest import MockFeatureSource + + +def _features() -> MockFeatureSource: + return MockFeatureSource( + {f"cl{i}": {"gene_expression": np.arange(3, dtype=np.float32) + i} for i in range(3)}, + meta_info={"gene_expression": ["a", "b", "c"]}, + ) + + +def test_dipk_gene_expression_round_trips_state(monkeypatch) -> None: + features = _features() + # The featurizer imports the trainer from its defining module inside ``_fit`` + # (to keep ``torch`` off the ``import drevalpy`` path), so the patch has to + # land there rather than on the featurizer module's re-export. + monkeypatch.setattr( + "drevalpy.components.predictors.literature.dipk.gene_expression_encoder.train_gene_expession_autoencoder", + lambda train, validation, epochs: GeneExpressionEncoder(train.shape[1]), + ) + pair_expanded_ids = np.array(["cl0", "cl1", "cl0"]) + pair_expanded_es_ids = np.array(["cl2", "cl2"]) + featurizer = DIPKGeneExpressionFeaturizer(epochs_autoencoder=1).fit( + features, pair_expanded_ids=pair_expanded_ids, pair_expanded_es_ids=pair_expanded_es_ids + ) + matrix = featurizer.transform(features, np.array(["cl0", "cl2"])) + assert matrix.shape == (2, 512) + restored = DIPKGeneExpressionFeaturizer() + restored.set_state(featurizer.get_state()) + np.testing.assert_allclose(restored.transform(features, np.array(["cl0", "cl2"])), matrix) + + +def test_dipk_gene_expression_requires_pair_expanded_ids() -> None: + with pytest.raises(ValueError, match="requires pair_expanded_ids"): + DIPKGeneExpressionFeaturizer().fit(_features()) + + +def test_dipk_gene_expression_requires_non_empty_early_stopping_ids() -> None: + with pytest.raises(ValueError, match="non-empty train and early-stopping IDs"): + DIPKGeneExpressionFeaturizer().fit(_features(), pair_expanded_ids=np.array(["cl0"])) + + +def test_dipk_gene_expression_transform_before_fit_raises() -> None: + with pytest.raises(RuntimeError, match="must be fit before transform"): + DIPKGeneExpressionFeaturizer()._transform(_features(), np.array(["cl0"])) + + +def test_dipk_gene_expression_output_dim_is_zero_before_fit() -> None: + assert DIPKGeneExpressionFeaturizer().output_dim == 0 + + +def test_dipk_gene_expression_state_is_empty_before_fit() -> None: + assert DIPKGeneExpressionFeaturizer().get_state() == {} + + +def test_dipk_gene_expression_set_state_ignores_a_malformed_payload() -> None: + featurizer = DIPKGeneExpressionFeaturizer() + + featurizer.set_state({"encoder_state": "not-bytes", "input_dim": 3}) + + assert featurizer.output_dim == 0 + + +def test_dipk_gene_expression_hyperparameter_space_exposes_the_epoch_count() -> None: + assert set(DIPKGeneExpressionFeaturizer.get_hyperparameter_space()) == {"epochs_autoencoder"} + + +def test_dipk_gene_expression_serves_a_precomputed_variant() -> None: + assert_uses_precomputed_variant( + DIPKGeneExpressionFeaturizer(), + ids_kwarg="pair_expanded_ids", + expect_output_dim=False, + expected_blocks=("gene_expression",), + ) diff --git a/tests/components/featurizers/cell_line/test_landmark.py b/tests/components/featurizers/cell_line/test_landmark.py new file mode 100644 index 000000000..a9526b0ea --- /dev/null +++ b/tests/components/featurizers/cell_line/test_landmark.py @@ -0,0 +1,181 @@ +"""Tests for landmark gene featurizers.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from upath import UPath + +from drevalpy.components.featurizers.cell_line import gene_lists +from drevalpy.components.featurizers.cell_line.gene_lists import gene_names_from_list_csv, resolve_gene_list_path +from drevalpy.components.featurizers.cell_line.landmark import ( + LandmarkGenesFeaturizer, + LandmarkGenesReducedFeaturizer, +) +from tests.components.featurizers.cell_line._helpers import assert_uses_precomputed_variant +from tests.conftest import MockFeatureSource + + +def _features() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32)}, + "cl2": {"gene_expression": np.array([4.0, 3.0, 2.0, 1.0], dtype=np.float32)}, + }, + meta_info={"gene_expression": ["A", "B", "C", "D"]}, + ) + + +def test_landmark_uses_symbol_column_and_persists_state() -> None: + symbols = gene_names_from_list_csv(resolve_gene_list_path("landmark_genes"))[:2] + features = MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([1.0, 2.0, 3.0], dtype=np.float32)}, + "cl2": {"gene_expression": np.array([3.0, 2.0, 1.0], dtype=np.float32)}, + }, + meta_info={"gene_expression": [*symbols, "NOT_A_GENE"]}, + ) + featurizer = LandmarkGenesFeaturizer(standardize=True) + ids = np.array(["cl1", "cl2"]) + featurizer.fit(features, entity_ids=ids) + assert featurizer.output_dim == 2 + matrix = featurizer.transform(features, ids) + assert matrix.shape == (2, 2) + + restored = LandmarkGenesFeaturizer() + restored.set_state(featurizer.get_state()) + assert restored.output_dim == 2 + np.testing.assert_allclose(restored.transform(features, ids), matrix) + + +def test_landmark_reduced_uses_package_gene_list() -> None: + featurizer = LandmarkGenesReducedFeaturizer(standardize=False) + symbols = gene_names_from_list_csv(resolve_gene_list_path("landmark_genes_reduced"))[:3] + features = MockFeatureSource( + features={ + "cl1": {"gene_expression": np.arange(len(symbols) + 1, dtype=np.float32)}, + }, + meta_info={"gene_expression": [*symbols, "NOT_A_GENE"]}, + ) + featurizer.fit(features, entity_ids=np.array(["cl1"])) + assert featurizer.output_dim == 3 + + +def test_landmark_fails_clearly_on_bad_column(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + pd.DataFrame({"other": ["A"]}).to_csv(tmp_path / "landmark_genes.csv", index=False) + monkeypatch.setattr(gene_lists, "GENE_LISTS_DIR", UPath(tmp_path)) + featurizer = LandmarkGenesFeaturizer() + with pytest.raises(ValueError, match="recognized gene-name column"): + featurizer.fit(_features(), entity_ids=np.array(["cl1"])) + + +def test_landmark_requires_feature_names_on_the_view() -> None: + features = MockFeatureSource( + features={"cl1": {"gene_expression": np.array([1.0, 2.0], dtype=np.float32)}}, + ) + + with pytest.raises(ValueError, match="no feature names for view"): + LandmarkGenesFeaturizer().fit(features, entity_ids=np.array(["cl1"])) + + +def test_landmark_requires_at_least_one_matching_gene() -> None: + with pytest.raises(ValueError, match="matched view"): + LandmarkGenesFeaturizer().fit(_features(), entity_ids=np.array(["cl1"])) + + +def test_landmark_minmax_scaling_bounds_output_to_the_unit_interval() -> None: + symbols = gene_names_from_list_csv(resolve_gene_list_path("landmark_genes"))[:2] + features = MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([1.0, 5.0], dtype=np.float32)}, + "cl2": {"gene_expression": np.array([9.0, 2.0], dtype=np.float32)}, + }, + meta_info={"gene_expression": list(symbols)}, + ) + ids = np.array(["cl1", "cl2"]) + featurizer = LandmarkGenesFeaturizer(standardize=True, minmax_scale=True).fit(features, entity_ids=ids) + + matrix = featurizer.transform(features, ids) + + assert matrix.min() >= 0.0 + assert matrix.max() <= 1.0 + + +def test_landmark_without_standardization_keeps_raw_arcsinh_values() -> None: + symbols = gene_names_from_list_csv(resolve_gene_list_path("landmark_genes"))[:2] + features = MockFeatureSource( + features={"cl1": {"gene_expression": np.array([0.0, 1.0], dtype=np.float32)}}, + meta_info={"gene_expression": list(symbols)}, + ) + ids = np.array(["cl1"]) + featurizer = LandmarkGenesFeaturizer(standardize=False, arcsinh=True).fit(features, entity_ids=ids) + + matrix = featurizer.transform(features, ids) + + np.testing.assert_allclose(matrix, np.arcsinh([[0.0, 1.0]]), rtol=1e-6) + + +def test_landmark_serves_a_precomputed_variant() -> None: + assert_uses_precomputed_variant(LandmarkGenesFeaturizer()) + + +def test_landmark_transform_before_fit_raises() -> None: + with pytest.raises(RuntimeError, match="must be fit before transform"): + LandmarkGenesFeaturizer()._transform(_features(), np.array(["cl1"])) + + +def test_landmark_transform_blocks_before_fit_raises() -> None: + with pytest.raises(RuntimeError, match="must be fit before transform"): + LandmarkGenesFeaturizer()._transform_blocks(_features(), np.array(["cl1"])) + + +def test_landmark_state_is_empty_before_fit() -> None: + assert LandmarkGenesFeaturizer().get_state() == {} + + +def test_landmark_hyperparameter_space_exposes_the_two_scaling_flags() -> None: + assert set(LandmarkGenesFeaturizer.get_hyperparameter_space()) == {"standardize", "minmax_scale"} + + +def test_landmark_blocks_carry_the_selected_gene_names() -> None: + symbols = gene_names_from_list_csv(resolve_gene_list_path("landmark_genes"))[:2] + features = MockFeatureSource( + features={"cl1": {"gene_expression": np.array([1.0, 2.0, 3.0], dtype=np.float32)}}, + meta_info={"gene_expression": [*symbols, "NOT_A_GENE"]}, + ) + ids = np.array(["cl1"]) + featurizer = LandmarkGenesFeaturizer(standardize=False).fit(features, entity_ids=ids) + + blocks = featurizer.transform_blocks(features, ids) + + assert set(blocks) == {"gene_expression"} + assert blocks["gene_expression"].feature_names == tuple(symbols) + + +def test_landmark_set_state_derives_output_dim_from_gene_indices() -> None: + featurizer = LandmarkGenesFeaturizer() + + featurizer.set_state({"gene_indices": [0, 1, 2], "output_dim": None, "fitted": True}) + + assert featurizer.output_dim == 3 + + +def test_landmark_blocks_drop_gene_names_on_a_source_without_metadata() -> None: + """Fit needs feature names, but a later source is free not to expose any.""" + symbols = gene_names_from_list_csv(resolve_gene_list_path("landmark_genes"))[:2] + named = MockFeatureSource( + features={"cl1": {"gene_expression": np.array([1.0, 2.0], dtype=np.float32)}}, + meta_info={"gene_expression": list(symbols)}, + ) + unnamed = MockFeatureSource( + features={"cl1": {"gene_expression": np.array([1.0, 2.0], dtype=np.float32)}}, + ) + ids = np.array(["cl1"]) + featurizer = LandmarkGenesFeaturizer(standardize=False).fit(named, entity_ids=ids) + + blocks = featurizer.transform_blocks(unnamed, ids) + + assert blocks["gene_expression"].feature_names is None diff --git a/tests/components/featurizers/cell_line/test_molir_omics.py b/tests/components/featurizers/cell_line/test_molir_omics.py new file mode 100644 index 000000000..171023fb0 --- /dev/null +++ b/tests/components/featurizers/cell_line/test_molir_omics.py @@ -0,0 +1,71 @@ +"""Tests for MOLIR omics preprocessing.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.featurizers.cell_line.molir_omics import MOLIROmicsFeaturizer +from tests.components.featurizers.cell_line._helpers import assert_uses_precomputed_variant +from tests.conftest import MockFeatureSource + + +def test_molir_omics_selects_expression_and_round_trips_state() -> None: + features = MockFeatureSource( + { + f"cl{i}": { + "gene_expression": np.array([i, i * 2, 1], dtype=np.float32), + "mutations": np.array([i, 1], dtype=np.float32), + "copy_number_variation_gistic": np.array([2, i], dtype=np.float32), + } + for i in range(3) + }, + meta_info={ + "gene_expression": ["a", "b", "c"], + "mutations": ["m1", "m2"], + "copy_number_variation_gistic": ["c1", "c2"], + }, + ) + featurizer = MOLIROmicsFeaturizer(n_gene_expression_features=2).fit(features, entity_ids=np.array(["cl0", "cl1"])) + blocks = featurizer.transform_blocks(features, np.array(["cl0", "cl2"])) + assert set(blocks) == {"gene_expression", "mutations", "copy_number_variation_gistic"} + assert blocks["gene_expression"].values.shape == (2, 2) + restored = MOLIROmicsFeaturizer() + restored.set_state(featurizer.get_state()) + np.testing.assert_allclose(restored.transform(features, np.array(["cl0", "cl2"])), blocks["gene_expression"].values) + + +def test_molir_omics_serves_a_precomputed_variant() -> None: + assert_uses_precomputed_variant(MOLIROmicsFeaturizer()) + + +def test_molir_omics_output_dim_is_zero_before_fit() -> None: + assert MOLIROmicsFeaturizer().output_dim == 0 + + +def test_molir_omics_hyperparameter_space_exposes_the_feature_count() -> None: + assert set(MOLIROmicsFeaturizer.get_hyperparameter_space()) == {"n_gene_expression_features"} + + +def test_molir_omics_falls_back_to_empty_feature_names_without_metadata() -> None: + features = MockFeatureSource( + { + f"cl{i}": { + "gene_expression": np.array([i, i * 2, 1], dtype=np.float32), + "mutations": np.array([i, 1], dtype=np.float32), + "copy_number_variation_gistic": np.array([2, i], dtype=np.float32), + } + for i in range(2) + } + ) + + featurizer = MOLIROmicsFeaturizer(n_gene_expression_features=2).fit(features) + + assert featurizer.get_state()["selected_feature_names"] == () + + +def test_molir_omics_set_state_ignores_unrelated_keys() -> None: + featurizer = MOLIROmicsFeaturizer() + + featurizer.set_state({"scaler": None, "mask": None, "selected_feature_names": None, "feature_names": None}) + + assert featurizer.output_dim == 0 diff --git a/tests/components/featurizers/cell_line/test_normalized_proteomics.py b/tests/components/featurizers/cell_line/test_normalized_proteomics.py new file mode 100644 index 000000000..cdac20e50 --- /dev/null +++ b/tests/components/featurizers/cell_line/test_normalized_proteomics.py @@ -0,0 +1,122 @@ +"""Tests for normalized proteomics cell-line featurizer.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.featurizers.cell_line.normalized_proteomics import ( + NormalizedProteomicsCellLineFeaturizer, + log10_and_set_na, +) +from tests.components.featurizers.cell_line._helpers import assert_uses_precomputed_variant +from tests.conftest import MockFeatureSource + + +def _make_features() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": {"proteomics": np.array([1.0, 2.0, 3.0], dtype=np.float32)}, + "cl2": {"proteomics": np.array([4.0, 5.0, 6.0], dtype=np.float32)}, + "cl3": {"proteomics": np.array([7.0, 8.0, 9.0], dtype=np.float32)}, + } + ) + + +def test_normalized_proteomics_fit_transform() -> None: + featurizer = NormalizedProteomicsCellLineFeaturizer() + features = _make_features() + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + matrix = featurizer.transform(features, entity_ids) + assert matrix.shape == (2, 3) + assert matrix.dtype == np.float32 + + +def test_log10_replaces_infinities_with_nan() -> None: + transformed = log10_and_set_na(np.array([[1.0, 0.0, 100.0]])) + + np.testing.assert_allclose(transformed[0, 0], 0.0) + assert np.isnan(transformed[0, 1]) + np.testing.assert_allclose(transformed[0, 2], 2.0) + + +def test_normalized_proteomics_hyperparameter_space_exposes_four_tunables() -> None: + assert set(NormalizedProteomicsCellLineFeaturizer.get_hyperparameter_space()) == { + "proteomics_feature_threshold", + "proteomics_n_features", + "proteomics_normalization_downshift", + "proteomics_normalization_width", + } + + +def test_normalized_proteomics_output_dim_is_zero_before_fit() -> None: + assert NormalizedProteomicsCellLineFeaturizer().output_dim == 0 + + +def test_normalized_proteomics_selects_thresholded_features_when_enough_are_complete() -> None: + featurizer = NormalizedProteomicsCellLineFeaturizer(proteomics_n_features=1) + features = _make_features() + + featurizer.fit(features, entity_ids=np.array(["cl1", "cl2", "cl3"], dtype=str)) + + assert featurizer.output_dim == 3 + + +def test_normalized_proteomics_imputes_missing_values() -> None: + features = MockFeatureSource( + features={ + "cl1": {"proteomics": np.array([1.0, 0.0, 100.0], dtype=np.float32)}, + "cl2": {"proteomics": np.array([10.0, 100.0, 1000.0], dtype=np.float32)}, + } + ) + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer = NormalizedProteomicsCellLineFeaturizer().fit(features, entity_ids=entity_ids) + + matrix = featurizer.transform(features, entity_ids) + + assert not np.isnan(matrix).any() + + +def test_normalized_proteomics_serves_a_precomputed_variant() -> None: + assert_uses_precomputed_variant(NormalizedProteomicsCellLineFeaturizer()) + + +def test_normalized_proteomics_round_trips_state() -> None: + features = _make_features() + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer = NormalizedProteomicsCellLineFeaturizer().fit(features, entity_ids=entity_ids) + + restored = NormalizedProteomicsCellLineFeaturizer() + restored.set_state(featurizer.get_state()) + + assert restored.output_dim == featurizer.output_dim + np.testing.assert_allclose( + restored.transform(features, entity_ids), + featurizer.transform(features, entity_ids), + ) + + +def test_normalized_proteomics_set_state_ignores_unrelated_keys() -> None: + featurizer = NormalizedProteomicsCellLineFeaturizer() + + featurizer.set_state({"proteomics_transformer": None, "view": 7, "output_dim": "many"}) + + assert featurizer.output_dim == 0 + assert featurizer._view == "proteomics" + + +def test_normalized_proteomics_blocks_carry_feature_names() -> None: + features = MockFeatureSource( + features={ + "cl1": {"proteomics": np.array([1.0, 2.0, 3.0], dtype=np.float32)}, + "cl2": {"proteomics": np.array([4.0, 5.0, 6.0], dtype=np.float32)}, + }, + meta_info={"proteomics": ["p1", "p2", "p3"]}, + ) + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer = NormalizedProteomicsCellLineFeaturizer().fit(features, entity_ids=entity_ids) + + blocks = featurizer.transform_blocks(features, entity_ids) + + assert set(blocks) == {"proteomics"} + assert blocks["proteomics"].feature_names == ("p1", "p2", "p3") diff --git a/tests/components/featurizers/cell_line/test_pathways.py b/tests/components/featurizers/cell_line/test_pathways.py new file mode 100644 index 000000000..255925d90 --- /dev/null +++ b/tests/components/featurizers/cell_line/test_pathways.py @@ -0,0 +1,113 @@ +"""Tests for the GSVA pathway cell-line featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.cell_line.pathways`. Not network +gated: it needs an ``mdata``-backed source plus ``uns["pathways_gmt"]``, both of +which the synthetic fixture carries. ``gseapy.gsva`` is the slow part, so exactly +one test runs it and the rest cover the guards. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line.pathways import PathwaysCellLineFeaturizer +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import CellLineFeatureSource +from tests.conftest import MockFeatureSource + + +def test_output_dim_is_zero_before_fit() -> None: + assert PathwaysCellLineFeaturizer().output_dim == 0 + + +def test_transform_before_fit_raises() -> None: + featurizer = PathwaysCellLineFeaturizer() + + with pytest.raises(RuntimeError, match="must be fit before transform"): + featurizer._transform(MockFeatureSource(features={}), np.array(["cl1"])) + + +def test_run_gsva_requires_pathways_gmt_in_uns() -> None: + featurizer = PathwaysCellLineFeaturizer() + source = MockFeatureSource(features={"cl1": {"gene_expression": np.array([1.0, 2.0])}}) + + with pytest.raises(ValueError, match="require pathways_gmt"): + featurizer._run_gsva(source, np.array(["cl1"])) + + +def test_run_gsva_requires_gene_expression_feature_names(synthetic_dataset: Dataset) -> None: + class _NamelessSource(CellLineFeatureSource): + def get_feature_names(self, view: str) -> None: + return None + + featurizer = PathwaysCellLineFeaturizer() + source = _NamelessSource(synthetic_dataset, synthetic_dataset.cell_line_ids) + + with pytest.raises(ValueError, match="must provide feature names"): + featurizer._run_gsva(source, synthetic_dataset.cell_line_ids[:2]) + + +def test_fit_then_transform_returns_pathway_scores(synthetic_dataset: Dataset) -> None: + source = CellLineFeatureSource(synthetic_dataset, synthetic_dataset.cell_line_ids) + cell_line_ids = synthetic_dataset.cell_line_ids[:6] + featurizer = PathwaysCellLineFeaturizer() + + featurizer.fit(source, entity_ids=cell_line_ids) + matrix = featurizer.transform(source, cell_line_ids) + + assert featurizer.output_dim > 0 + assert matrix.shape == (len(cell_line_ids), featurizer.output_dim) + assert matrix.dtype == np.float32 + + +def test_transform_of_unseen_cell_lines_recomputes_gsva( + synthetic_dataset: Dataset, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source = CellLineFeatureSource(synthetic_dataset, synthetic_dataset.cell_line_ids) + fit_ids = synthetic_dataset.cell_line_ids[:2] + featurizer = PathwaysCellLineFeaturizer() + featurizer._fit_scores = np.zeros((2, 3), dtype=np.float32) + featurizer._fit_ids = fit_ids + featurizer._output_dim = 3 + + calls: list[int] = [] + + def _fake_run_gsva(_source, entity_ids): + calls.append(len(entity_ids)) + return np.ones((len(entity_ids), 3), dtype=np.float32) + + monkeypatch.setattr(featurizer, "_run_gsva", _fake_run_gsva) + + matrix = featurizer._transform(source, np.array([*fit_ids, synthetic_dataset.cell_line_ids[5]])) + + assert calls == [3] + assert matrix.shape == (3, 3) + + +def test_transform_of_known_cell_lines_reuses_fitted_scores(synthetic_dataset: Dataset) -> None: + source = CellLineFeatureSource(synthetic_dataset, synthetic_dataset.cell_line_ids) + fit_ids = synthetic_dataset.cell_line_ids[:2] + featurizer = PathwaysCellLineFeaturizer() + featurizer._fit_scores = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + featurizer._fit_ids = fit_ids + featurizer._output_dim = 2 + + matrix = featurizer._transform(source, np.array([fit_ids[1], fit_ids[0]])) + + np.testing.assert_allclose(matrix, [[3.0, 4.0], [1.0, 2.0]]) + + +def test_transform_blocks_emit_one_block_named_after_the_view(synthetic_dataset: Dataset) -> None: + source = CellLineFeatureSource(synthetic_dataset, synthetic_dataset.cell_line_ids) + fit_ids = synthetic_dataset.cell_line_ids[:2] + featurizer = PathwaysCellLineFeaturizer() + featurizer._fit_scores = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + featurizer._fit_ids = fit_ids + featurizer._output_dim = 2 + + blocks = featurizer._transform_blocks(source, fit_ids) + + assert set(blocks) == {"pathways"} + assert blocks["pathways"].feature_names is None diff --git a/tests/components/featurizers/cell_line/test_pca.py b/tests/components/featurizers/cell_line/test_pca.py new file mode 100644 index 000000000..51bae6743 --- /dev/null +++ b/tests/components/featurizers/cell_line/test_pca.py @@ -0,0 +1,121 @@ +"""Tests for PCA cell-line featurizer.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line.pca import PCACellLineFeaturizer +from tests.components.featurizers.cell_line._helpers import assert_uses_precomputed_variant, precomputed_source +from tests.conftest import MockFeatureSource + + +def _make_features() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": {"gene_expression": np.arange(6, dtype=np.float32)}, + "cl2": {"gene_expression": np.arange(6, 12, dtype=np.float32)}, + "cl3": {"gene_expression": np.arange(12, 18, dtype=np.float32)}, + } + ) + + +def test_pca_reduces_view_dimension() -> None: + features = _make_features() + featurizer = PCACellLineFeaturizer(view="gene_expression", n_components=2) + entity_ids = np.array(["cl1", "cl2", "cl3"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + matrix = featurizer.transform(features, entity_ids) + assert matrix.shape == (3, 2) + assert featurizer.output_dim == 2 + + +def test_pca_requires_explicit_view() -> None: + with pytest.raises(ValueError, match="requires an explicit view"): + PCACellLineFeaturizer(view="") + + +def test_pca_state_roundtrip() -> None: + features = _make_features() + featurizer = PCACellLineFeaturizer(view="gene_expression", n_components=2) + entity_ids = np.array(["cl1", "cl2", "cl3"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + restored = PCACellLineFeaturizer(view="gene_expression", n_components=2) + restored.set_state(featurizer.get_state()) + assert np.allclose( + featurizer.transform(features, entity_ids), + restored.transform(features, entity_ids), + ) + + +def test_pca_aligns_cross_study_features_by_name() -> None: + training = MockFeatureSource( + features={ + "cl1": {"methylation": np.array([1.0, 2.0, 3.0])}, + "cl2": {"methylation": np.array([4.0, 5.0, 6.0])}, + }, + meta_info={"methylation": np.array(["a", "b", "c"])}, + ) + cross_study = MockFeatureSource( + features={"cl3": {"methylation": np.array([20.0, 30.0, 40.0])}}, + meta_info={"methylation": np.array(["b", "c", "d"])}, + ) + expected = MockFeatureSource( + features={"cl3": {"methylation": np.array([0.0, 20.0, 30.0])}}, + meta_info={"methylation": np.array(["a", "b", "c"])}, + ) + featurizer = PCACellLineFeaturizer(view="methylation", n_components=2) + featurizer.fit(training, entity_ids=np.array(["cl1", "cl2"])) + + assert np.allclose( + featurizer.transform(cross_study, np.array(["cl3"])), + featurizer.transform(expected, np.array(["cl3"])), + ) + + +def test_pca_prefers_a_precomputed_variant_for_matching_hyperparameters() -> None: + assert_uses_precomputed_variant( + PCACellLineFeaturizer(view="gene_expression", n_components=2), + hyperparameters={"n_components": 2}, + ) + + +def test_pca_ignores_a_variant_computed_under_different_hyperparameters() -> None: + """The counterpart to the test above: a mismatched HP set must not be served.""" + source = precomputed_source(PCACellLineFeaturizer, hyperparameters={"n_components": 2}) + ids = source.identifiers + featurizer = PCACellLineFeaturizer(view="gene_expression", n_components=1) + + featurizer.fit(source, entity_ids=ids) + + assert featurizer.output_dim == 1 + + +def test_pca_caps_n_components_at_the_smaller_matrix_dimension() -> None: + features = _make_features() + featurizer = PCACellLineFeaturizer(view="gene_expression", n_components=128) + + featurizer.fit(features, entity_ids=np.array(["cl1", "cl2", "cl3"], dtype=str)) + + assert featurizer.output_dim == 3 + + +def test_pca_hyperparameter_space_exposes_n_components() -> None: + assert set(PCACellLineFeaturizer.get_hyperparameter_space()) == {"n_components"} + + +def test_pca_set_state_ignores_unrelated_keys() -> None: + featurizer = PCACellLineFeaturizer(view="gene_expression", n_components=2) + + featurizer.set_state({"pca": None, "view": 3, "n_components": "two", "output_dim": None, "feature_names": None}) + + assert featurizer._view == "gene_expression" + assert featurizer.output_dim == 0 + + +def test_pca_set_state_restores_feature_names() -> None: + featurizer = PCACellLineFeaturizer(view="gene_expression", n_components=2) + + featurizer.set_state({"feature_names": ("a", "b")}) + + assert featurizer._feature_names == ("a", "b") diff --git a/tests/components/featurizers/cell_line/test_pharmaformer_gene_expression.py b/tests/components/featurizers/cell_line/test_pharmaformer_gene_expression.py new file mode 100644 index 000000000..5184408df --- /dev/null +++ b/tests/components/featurizers/cell_line/test_pharmaformer_gene_expression.py @@ -0,0 +1,58 @@ +"""Tests for PharmaFormer gene preprocessing.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line.pharmaformer_gene_expression import ( + PharmaFormerGeneExpressionFeaturizer, +) +from tests.components.featurizers.cell_line._helpers import assert_uses_precomputed_variant +from tests.conftest import MockFeatureSource + + +def _features() -> MockFeatureSource: + return MockFeatureSource( + {f"cl{i}": {"gene_expression": np.array([i, i + 2], dtype=np.float32)} for i in range(3)}, + meta_info={"gene_expression": ["a", "b"]}, + ) + + +def test_pharmaformer_gene_expression_round_trips_state() -> None: + features = _features() + pair_expanded_ids = np.array(["cl0", "cl1", "cl0"]) + pair_expanded_es_ids = np.array(["cl2"]) + featurizer = PharmaFormerGeneExpressionFeaturizer().fit( + features, pair_expanded_ids=pair_expanded_ids, pair_expanded_es_ids=pair_expanded_es_ids + ) + blocks = featurizer.transform_blocks(features, np.array(["cl0", "cl2"])) + assert blocks["gene_expression"].feature_names == ("a", "b") + restored = PharmaFormerGeneExpressionFeaturizer() + restored.set_state(featurizer.get_state()) + np.testing.assert_allclose(restored.transform(features, np.array(["cl0", "cl2"])), blocks["gene_expression"].values) + + +def test_pharmaformer_gene_expression_requires_pair_expanded_ids() -> None: + with pytest.raises(ValueError, match="requires pair_expanded_ids"): + PharmaFormerGeneExpressionFeaturizer().fit(_features()) + + +def test_pharmaformer_gene_expression_transform_before_fit_raises() -> None: + with pytest.raises(RuntimeError, match="must be fit before transform"): + PharmaFormerGeneExpressionFeaturizer()._transform(_features(), np.array(["cl0"])) + + +def test_pharmaformer_gene_expression_state_is_empty_before_fit() -> None: + assert PharmaFormerGeneExpressionFeaturizer().get_state() == {} + + +def test_pharmaformer_gene_expression_output_dim_is_zero_before_fit() -> None: + assert PharmaFormerGeneExpressionFeaturizer().output_dim == 0 + + +def test_pharmaformer_gene_expression_serves_a_precomputed_variant() -> None: + assert_uses_precomputed_variant( + PharmaFormerGeneExpressionFeaturizer(), + ids_kwarg="pair_expanded_ids", + ) diff --git a/tests/components/featurizers/cell_line/test_proteomics_transformer.py b/tests/components/featurizers/cell_line/test_proteomics_transformer.py new file mode 100644 index 000000000..5f74729ad --- /dev/null +++ b/tests/components/featurizers/cell_line/test_proteomics_transformer.py @@ -0,0 +1,119 @@ +"""Tests for the proteomics median-centering transformer. + +The transformer was split out of ``normalized_proteomics`` to keep ``sklearn`` +off the ``import drevalpy`` path, so this file also pins the two properties that +split has to preserve: it still satisfies the sklearn estimator protocol, and the +old import path still resolves it. +""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.featurizers.cell_line._proteomics_transformer import ( + ProteomicsMedianCenterAndImputeTransformer, +) + + +def _matrix() -> np.ndarray: + return np.array( + [ + [1.0, 2.0, np.nan], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + ] + ) + + +class TestFit: + def test_thresholding_keeps_only_sufficiently_complete_features(self) -> None: + transformer = ProteomicsMedianCenterAndImputeTransformer(feature_threshold=1.0, n_features=1) + + transformer.fit(_matrix()) + + assert sorted(transformer.protein_indices.tolist()) == [0, 1] + + def test_falls_back_to_the_n_most_complete_features(self) -> None: + """With ``n_features`` above the complete count, completeness ranking decides.""" + transformer = ProteomicsMedianCenterAndImputeTransformer(feature_threshold=1.0, n_features=3) + + transformer.fit(_matrix()) + + assert sorted(transformer.protein_indices.tolist()) == [0, 1, 2] + + def test_records_the_mean_of_the_per_row_medians(self) -> None: + transformer = ProteomicsMedianCenterAndImputeTransformer(feature_threshold=1.0, n_features=1) + + transformer.fit(_matrix()) + + # Medians over columns 0 and 1: 1.5, 4.5, 7.5. + np.testing.assert_allclose(transformer.mean_median, 4.5) + + def test_fit_returns_self_for_chaining(self) -> None: + transformer = ProteomicsMedianCenterAndImputeTransformer() + + assert transformer.fit(_matrix()) is transformer + + +class TestTransform: + def test_median_centers_against_the_fitted_mean_median(self) -> None: + transformer = ProteomicsMedianCenterAndImputeTransformer(feature_threshold=1.0, n_features=1) + transformer.fit(_matrix()) + + (row,) = transformer.transform(np.array([[4.0, 5.0, 6.0]])) + + # Row median is 4.5, which already equals mean_median, so values pass through. + np.testing.assert_allclose(row, [4.0, 5.0]) + + def test_imputes_missing_values_from_a_downshifted_normal(self) -> None: + transformer = ProteomicsMedianCenterAndImputeTransformer(feature_threshold=0.0, n_features=3) + transformer.fit(_matrix()) + + (row,) = transformer.transform(np.array([[1.0, np.nan, 3.0]])) + + assert not np.isnan(row).any() + + def test_imputation_is_seeded_and_therefore_reproducible(self) -> None: + """Two identical calls must agree; the seed is per call, not global RNG state.""" + transformer = ProteomicsMedianCenterAndImputeTransformer(feature_threshold=0.0, n_features=3) + transformer.fit(_matrix()) + + (first,) = transformer.transform(np.array([[1.0, np.nan, 3.0]])) + (second,) = transformer.transform(np.array([[1.0, np.nan, 3.0]])) + + np.testing.assert_allclose(first, second) + + +class TestTheSklearnContract: + def test_get_params_reports_every_constructor_argument(self) -> None: + """``BaseEstimator`` is a real base class here, so ``get_params`` must work.""" + params = ProteomicsMedianCenterAndImputeTransformer(n_features=7).get_params() + + assert params["n_features"] == 7 + assert set(params) == { + "feature_threshold", + "imputation_seed", + "n_features", + "normalization_downshift", + "normalization_width", + } + + def test_clone_produces_an_equivalent_unfitted_estimator(self) -> None: + from sklearn.base import clone + + original = ProteomicsMedianCenterAndImputeTransformer(feature_threshold=0.5) + + cloned = clone(original) + + assert cloned is not original + assert cloned.feature_threshold == 0.5 + assert cloned.protein_indices.size == 0 + + +def test_the_featurizer_module_still_re_exports_the_transformer() -> None: + """The class moved modules; the historical import path is a compatibility promise.""" + from drevalpy.components.featurizers.cell_line import normalized_proteomics + + assert normalized_proteomics.ProteomicsMedianCenterAndImputeTransformer is ( + ProteomicsMedianCenterAndImputeTransformer + ) diff --git a/tests/components/featurizers/cell_line/test_raw.py b/tests/components/featurizers/cell_line/test_raw.py new file mode 100644 index 000000000..378e9445a --- /dev/null +++ b/tests/components/featurizers/cell_line/test_raw.py @@ -0,0 +1,47 @@ +"""Tests for raw cell-line featurizer.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line.raw import RawCellLineFeaturizer +from tests.conftest import MockFeatureSource + + +def _make_features() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": { + "gene_expression": np.array([1.0, 2.0], dtype=np.float32), + "mutations": np.array([0.0, 1.0], dtype=np.float32), + }, + "cl2": { + "gene_expression": np.array([3.0, 4.0], dtype=np.float32), + "mutations": np.array([1.0, 0.0], dtype=np.float32), + }, + } + ) + + +def test_raw_passes_through_view() -> None: + features = _make_features() + featurizer = RawCellLineFeaturizer(view="gene_expression") + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + matrix = featurizer.transform(features, entity_ids) + assert matrix.shape == (2, 2) + assert np.allclose(matrix[0], [1.0, 2.0]) + assert np.allclose(matrix[1], [3.0, 4.0]) + + +def test_raw_requires_explicit_view() -> None: + with pytest.raises(ValueError, match="requires an explicit view"): + RawCellLineFeaturizer(view="") + + +def test_raw_prefers_a_precomputed_variant_over_the_view() -> None: + from drevalpy.components.featurizers.cell_line.raw import RawCellLineFeaturizer as Raw + from tests.components.featurizers.cell_line._helpers import assert_uses_precomputed_variant + + assert_uses_precomputed_variant(Raw(view="gene_expression")) diff --git a/tests/components/featurizers/cell_line/test_scaled_gene_expression.py b/tests/components/featurizers/cell_line/test_scaled_gene_expression.py new file mode 100644 index 000000000..3af79342f --- /dev/null +++ b/tests/components/featurizers/cell_line/test_scaled_gene_expression.py @@ -0,0 +1,60 @@ +"""Tests for scaled gene-expression featurizer state.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line.scaled_gene_expression import ScaledGeneExpressionFeaturizer +from tests.components.featurizers.cell_line._helpers import assert_uses_precomputed_variant +from tests.conftest import MockFeatureSource + + +def test_scaled_gene_expression_output_dim_round_trips() -> None: + features = MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([0.0, 1.0, 2.0], dtype=np.float32)}, + "cl2": {"gene_expression": np.array([1.0, 2.0, 3.0], dtype=np.float32)}, + }, + meta_info={"gene_expression": ["g1", "g2", "g3"]}, + ) + ids = np.array(["cl1", "cl2"]) + featurizer = ScaledGeneExpressionFeaturizer() + featurizer.fit(features, entity_ids=ids) + assert featurizer.output_dim == 3 + matrix = featurizer.transform(features, ids) + + restored = ScaledGeneExpressionFeaturizer() + restored.set_state(featurizer.get_state()) + assert restored.output_dim == 3 + np.testing.assert_allclose(restored.transform(features, ids), matrix) + + +def test_scaled_gene_expression_serves_a_precomputed_variant() -> None: + assert_uses_precomputed_variant( + ScaledGeneExpressionFeaturizer(), + expected_blocks=("gene_expression",), + ) + + +def test_scaled_gene_expression_transform_before_fit_raises() -> None: + with pytest.raises(RuntimeError, match="must be fit before transform"): + ScaledGeneExpressionFeaturizer()._transform(MockFeatureSource(features={}), np.array(["cl1"])) + + +def test_scaled_gene_expression_transform_blocks_before_fit_raises() -> None: + with pytest.raises(RuntimeError, match="must be fit before transform"): + ScaledGeneExpressionFeaturizer()._transform_blocks(MockFeatureSource(features={}), np.array(["cl1"])) + + +def test_scaled_gene_expression_state_is_empty_before_fit() -> None: + assert ScaledGeneExpressionFeaturizer().get_state() == {} + + +def test_scaled_gene_expression_set_state_ignores_unrelated_keys() -> None: + featurizer = ScaledGeneExpressionFeaturizer() + + featurizer.set_state({"gene_expression_scaler": None, "view": 3, "output_dim": "many"}) + + assert featurizer.output_dim == 0 + assert featurizer._view == "gene_expression" diff --git a/tests/components/featurizers/cell_line/test_sparsego_metadata.py b/tests/components/featurizers/cell_line/test_sparsego_metadata.py new file mode 100644 index 000000000..cac05c05b --- /dev/null +++ b/tests/components/featurizers/cell_line/test_sparsego_metadata.py @@ -0,0 +1,83 @@ +"""Tests for SparseGO ontology metadata attach/read helpers. + +Mirrors :mod:`drevalpy.components.featurizers.cell_line._sparsego_metadata`. The +featurizer that consumes this metadata is covered in ``test_sparsego_ontology.py``. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line._sparsego_metadata import ( + attach_sparsego_ontology_metadata, + read_sparsego_ontology_metadata, +) +from tests.conftest import MockFeatureSource + + +def _metadata() -> dict[str, Any]: + return { + "layer_connections": [np.array([["term", "a"]])], + "gene2id_mapping_ont": {"a": 0, "b": 1}, + "ontology_gene_order": ("a", "b"), + "gene_dim_input": 2, + } + + +def test_attach_then_read_round_trips_metadata() -> None: + source = MockFeatureSource({"cl1": {"gene_expression": np.array([1.0, 2.0])}}) + + attach_sparsego_ontology_metadata(source, _metadata()) + restored = read_sparsego_ontology_metadata(source) + + assert restored is not None + assert restored["gene_dim_input"] == 2 + assert restored["gene2id_mapping_ont"] == {"a": 0, "b": 1} + assert restored["ontology_gene_order"] == ("a", "b") + np.testing.assert_array_equal(restored["layer_connections"][0], np.array([["term", "a"]])) + + +def test_read_returns_none_without_attached_metadata() -> None: + source = MockFeatureSource({"cl1": {"gene_expression": np.array([1.0, 2.0])}}) + + assert read_sparsego_ontology_metadata(source) is None + + +def test_read_tolerates_a_source_that_raises_on_unknown_metadata_keys() -> None: + from drevalpy.types.data.feature_source import CellLineFeatureSource + from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + dataset = synthetic_mudataset_gene_expression_fingerprints() + source = CellLineFeatureSource(dataset, dataset.cell_line_ids) + + assert read_sparsego_ontology_metadata(source) is None + + +def test_read_ignores_payload_without_layer_connections() -> None: + source = MockFeatureSource({}, meta_info={"sparsego_ontology": {"gene_dim_input": 2}}) + + assert read_sparsego_ontology_metadata(source) is None + + +def test_attach_writes_to_mdata_uns_for_dataset_backed_sources() -> None: + from drevalpy.types.data.feature_source import CellLineFeatureSource + from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + dataset = synthetic_mudataset_gene_expression_fingerprints() + source = CellLineFeatureSource(dataset, dataset.cell_line_ids) + + attach_sparsego_ontology_metadata(source, _metadata()) + + assert dataset.mdata.uns["sparsego_ontology"]["gene_dim_input"] == 2 + assert read_sparsego_ontology_metadata(source) is not None + + +def test_attach_rejects_sources_without_a_metadata_home() -> None: + class _Opaque: + pass + + with pytest.raises(TypeError, match="Cannot attach metadata"): + attach_sparsego_ontology_metadata(_Opaque(), _metadata()) diff --git a/tests/components/featurizers/cell_line/test_sparsego_ontology.py b/tests/components/featurizers/cell_line/test_sparsego_ontology.py new file mode 100644 index 000000000..2a2f5d70b --- /dev/null +++ b/tests/components/featurizers/cell_line/test_sparsego_ontology.py @@ -0,0 +1,135 @@ +"""Tests for the SparseGO ontology-aligned cell-line featurizer.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line._sparsego_metadata import attach_sparsego_ontology_metadata +from drevalpy.components.featurizers.cell_line.sparsego_ontology import SparseGOOntologyFeaturizer +from tests.conftest import MockFeatureSource + + +def _source() -> MockFeatureSource: + return MockFeatureSource( + {"cl1": {"gene_expression": np.array([1.0, 2.0]), "mutations": np.array([0.0, 1.0])}}, + meta_info={"gene_expression": ["a", "b"], "mutations": ["a", "b"]}, + ) + + +def _with_ontology() -> MockFeatureSource: + features = _source() + attach_sparsego_ontology_metadata( + features, + { + "layer_connections": [np.array([["term", "a"]])], + "gene2id_mapping_ont": {"a": 0, "b": 1}, + "ontology_gene_order": ("a", "b"), + "gene_dim_input": 2, + }, + ) + return features + + +def test_sparsego_ontology_emits_active_block_and_round_trips_state() -> None: + features = _with_ontology() + featurizer = SparseGOOntologyFeaturizer().fit(features) + block = featurizer.transform_blocks(features, np.array(["cl1"]))["gene_expression"] + assert block.metadata is not None + assert block.metadata["gene_dim_input"] == 2 + restored = SparseGOOntologyFeaturizer() + restored.set_state(featurizer.get_state()) + np.testing.assert_allclose(restored.transform(features, np.array(["cl1"])), block.values) + + +def test_sparsego_mutations_input_type_reads_the_mutations_view() -> None: + features = _with_ontology() + featurizer = SparseGOOntologyFeaturizer(input_type="mutations").fit(features) + + blocks = featurizer.transform_blocks(features, np.array(["cl1"])) + + assert set(blocks) == {"mutations"} + np.testing.assert_allclose(blocks["mutations"].values, [[0.0, 1.0]]) + + +def test_sparsego_rejects_unknown_input_type() -> None: + with pytest.raises(ValueError, match="input_type must be"): + SparseGOOntologyFeaturizer(input_type="methylation") + + +def test_sparsego_fit_without_ontology_metadata_raises() -> None: + with pytest.raises(ValueError, match="ontology metadata is missing"): + SparseGOOntologyFeaturizer().fit(_source()) + + +def test_sparsego_transform_before_fit_raises() -> None: + with pytest.raises(RuntimeError, match="must be fit before transform"): + SparseGOOntologyFeaturizer().transform(_source(), np.array(["cl1"])) + + +def test_sparsego_state_is_empty_before_fit() -> None: + assert SparseGOOntologyFeaturizer().get_state() == {} + + +def test_sparsego_set_state_rejects_unknown_input_type() -> None: + with pytest.raises(ValueError, match="input_type must be"): + SparseGOOntologyFeaturizer().set_state({"input_type": "methylation"}) + + +def test_sparsego_output_block_specs_follow_the_configured_input_type() -> None: + class _Config: + hyperparameter_space = {"input_type": {"default": "mutations"}} + + specs = SparseGOOntologyFeaturizer.output_block_specs_for_config(_Config()) + + assert [spec.name for spec in specs] == ["mutations"] + + +def test_sparsego_output_block_specs_default_to_expression() -> None: + specs = SparseGOOntologyFeaturizer.output_block_specs_for_config(None) + + assert [spec.name for spec in specs] == ["gene_expression"] + + +def test_sparsego_output_block_specs_default_when_the_space_has_no_input_type() -> None: + class _Config: + hyperparameter_space = {"other": {"default": 1}} + + specs = SparseGOOntologyFeaturizer.output_block_specs_for_config(_Config()) + + assert [spec.name for spec in specs] == ["gene_expression"] + + +def test_sparsego_prefers_a_precomputed_variant() -> None: + from tests.components.featurizers.cell_line._helpers import PRECOMPUTED, precomputed_source + + source = precomputed_source(SparseGOOntologyFeaturizer) + attach_sparsego_ontology_metadata( + source, + { + "layer_connections": [np.array([["term", "gene0"]])], + "gene2id_mapping_ont": {"gene0": 0, "gene1": 1}, + "ontology_gene_order": ("gene0", "gene1"), + "gene_dim_input": 2, + }, + ) + featurizer = SparseGOOntologyFeaturizer().fit(source) + + matrix = featurizer.transform(source, source.identifiers) + + np.testing.assert_allclose(matrix, PRECOMPUTED) + + +def test_sparsego_output_dim_is_zero_before_fit() -> None: + assert SparseGOOntologyFeaturizer().output_dim == 0 + + +@pytest.mark.parametrize( + ("input_type", "expected"), + [ + pytest.param("expression", ("gene_expression",), id="expression"), + pytest.param("mutations", ("mutations",), id="mutations"), + ], +) +def test_sparsego_resolve_input_views(input_type: str, expected: tuple[str, ...]) -> None: + assert SparseGOOntologyFeaturizer.resolve_input_views(input_type=input_type) == expected diff --git a/tests/components/featurizers/cell_line/test_superfeltr_omics.py b/tests/components/featurizers/cell_line/test_superfeltr_omics.py new file mode 100644 index 000000000..ec8b0c8b6 --- /dev/null +++ b/tests/components/featurizers/cell_line/test_superfeltr_omics.py @@ -0,0 +1,86 @@ +"""Tests for SuperFELTR omics preprocessing.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.featurizers.cell_line.superfeltr_omics import SuperFELTROmicsFeaturizer +from tests.components.featurizers.cell_line._helpers import assert_uses_precomputed_variant +from tests.conftest import MockFeatureSource + + +def test_superfeltr_omics_selects_each_view_and_round_trips_state() -> None: + features = MockFeatureSource( + { + f"cl{i}": { + "gene_expression": np.array([i, i * 2, 1], dtype=np.float32), + "mutations": np.array([i, 1, i + 1], dtype=np.float32), + "copy_number_variation_gistic": np.array([2, i, i + 2], dtype=np.float32), + } + for i in range(3) + }, + meta_info={ + view: [f"{view}{i}" for i in range(3)] + for view in ( + "gene_expression", + "mutations", + "copy_number_variation_gistic", + ) + }, + ) + featurizer = SuperFELTROmicsFeaturizer(n_features_per_view=2).fit(features, entity_ids=np.array(["cl0", "cl1"])) + blocks = featurizer.transform_blocks(features, np.array(["cl0", "cl2"])) + assert all(block.values.shape == (2, 2) for block in blocks.values()) + restored = SuperFELTROmicsFeaturizer() + restored.set_state(featurizer.get_state()) + np.testing.assert_allclose(restored.transform(features, np.array(["cl0", "cl2"])), blocks["gene_expression"].values) + + +def test_superfeltr_omics_serves_a_precomputed_variant() -> None: + assert_uses_precomputed_variant( + SuperFELTROmicsFeaturizer(), + expect_output_dim=False, + expected_blocks=("gene_expression",), + ) + + +def test_superfeltr_omics_falls_back_to_empty_feature_names_without_metadata() -> None: + features = MockFeatureSource( + { + f"cl{i}": { + "gene_expression": np.array([i, i * 2, 1], dtype=np.float32), + "mutations": np.array([i, 1, i + 1], dtype=np.float32), + "copy_number_variation_gistic": np.array([2, i, i + 2], dtype=np.float32), + } + for i in range(2) + } + ) + + featurizer = SuperFELTROmicsFeaturizer(n_features_per_view=2).fit(features) + + assert featurizer.get_state()["feature_names"]["gene_expression"] == () + + +def test_superfeltr_omics_output_dim_sums_selected_features_across_views() -> None: + features = MockFeatureSource( + { + f"cl{i}": { + "gene_expression": np.array([i, i * 2, 1], dtype=np.float32), + "mutations": np.array([i, 1, i + 1], dtype=np.float32), + "copy_number_variation_gistic": np.array([2, i, i + 2], dtype=np.float32), + } + for i in range(2) + } + ) + + featurizer = SuperFELTROmicsFeaturizer(n_features_per_view=2).fit(features) + + assert featurizer.output_dim == 6 + + +def test_superfeltr_omics_output_dim_is_zero_before_fit() -> None: + assert SuperFELTROmicsFeaturizer().output_dim == 0 + + +def test_superfeltr_omics_hyperparameter_space_exposes_the_feature_count() -> None: + assert set(SuperFELTROmicsFeaturizer.get_hyperparameter_space()) == {"n_features_per_view"} diff --git a/tests/components/featurizers/cell_line/test_tissue.py b/tests/components/featurizers/cell_line/test_tissue.py new file mode 100644 index 000000000..5e2e926c6 --- /dev/null +++ b/tests/components/featurizers/cell_line/test_tissue.py @@ -0,0 +1,114 @@ +"""Tests for the one-hot tissue cell-line featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.cell_line.tissue`. These cases used +to live in ``test_identity.py`` because both featurizers share +``OneHotCategoryEncoder``; the encoder itself is covered by ``test_one_hot.py``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line.tissue import TissueFeaturizer +from tests.conftest import MockFeatureSource + + +def test_tissue_one_hot() -> None: + features = MockFeatureSource( + features={ + "cl1": {"tissue": np.array(["lung"])}, + "cl2": {"tissue": np.array(["skin"])}, + } + ) + featurizer = TissueFeaturizer() + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + matrix = featurizer.transform(features, entity_ids) + assert matrix.shape == (2, 2) + blocks = featurizer.transform_blocks(features, entity_ids) + assert "tissue_categories" in blocks + assert list(blocks["tissue_categories"].values) == ["lung", "skin"] + + +def test_tissue_strict_missing_raises() -> None: + features = MockFeatureSource( + features={ + "cl1": {"tissue": np.array(["lung"])}, + "cl2": {"gene_expression": np.array([1.0])}, + } + ) + featurizer = TissueFeaturizer(allow_missing=False) + with pytest.raises(ValueError, match="requires tissue"): + featurizer.fit(features, entity_ids=np.array(["cl1", "cl2"], dtype=str)) + + +def test_tissue_allow_missing_partial_rows_are_zero() -> None: + features = MockFeatureSource( + features={ + "cl1": {"tissue": np.array(["lung"])}, + "cl2": {"gene_expression": np.array([1.0])}, + } + ) + featurizer = TissueFeaturizer(allow_missing=True) + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + matrix = featurizer.transform(features, entity_ids) + assert matrix.shape == (2, 1) + assert matrix[0, 0] == 1.0 + assert matrix[1, 0] == 0.0 + + +def test_tissue_allow_missing_fully_absent_is_empty() -> None: + features = MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([1.0])}, + "cl2": {"gene_expression": np.array([2.0])}, + } + ) + featurizer = TissueFeaturizer(allow_missing=True) + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + matrix = featurizer.transform(features, entity_ids) + assert matrix.shape == (2, 0) + assert featurizer.output_dim == 0 + + +def test_tissue_strict_transform_of_unannotated_entity_raises() -> None: + features = MockFeatureSource( + features={ + "cl1": {"tissue": np.array(["lung"])}, + "cl2": {"gene_expression": np.array([1.0])}, + } + ) + featurizer = TissueFeaturizer(allow_missing=False) + featurizer.fit(features, entity_ids=np.array(["cl1"], dtype=str)) + with pytest.raises(ValueError, match="requires tissue"): + featurizer.transform(features, np.array(["cl1", "cl2"], dtype=str)) + + +def test_tissue_strict_empty_entity_set_raises() -> None: + features = MockFeatureSource(features={}) + featurizer = TissueFeaturizer(allow_missing=False) + + with pytest.raises(ValueError, match="requires tissue"): + featurizer.fit(features, entity_ids=np.array([], dtype=str)) + + +def test_tissue_round_trips_state() -> None: + features = MockFeatureSource( + features={ + "cl1": {"tissue": np.array(["lung"])}, + "cl2": {"tissue": np.array(["skin"])}, + } + ) + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer = TissueFeaturizer().fit(features, entity_ids=entity_ids) + + restored = TissueFeaturizer() + restored.set_state(featurizer.get_state()) + + np.testing.assert_allclose( + restored.transform(features, entity_ids), + featurizer.transform(features, entity_ids), + ) diff --git a/tests/components/featurizers/drug/test_base.py b/tests/components/featurizers/drug/test_base.py new file mode 100644 index 000000000..49c07d4d3 --- /dev/null +++ b/tests/components/featurizers/drug/test_base.py @@ -0,0 +1,56 @@ +"""Tests for the drug featurizer base class. + +Mirrors :mod:`drevalpy.components.featurizers.drug.base`, a three-statement +subclass of ``Featurizer``: the only behaviour it carries is its position in the +MRO, which registration and the config layer both key off. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.featurizers._dense_view import DenseViewFeaturizer +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.components.featurizers.drug.base import DenseViewDrugFeaturizer, DrugFeaturizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from drevalpy.registry.drug_featurizer import list as list_drug_featurizers + + +def test_drug_featurizer_extends_the_shared_base() -> None: + assert issubclass(DrugFeaturizer, Featurizer) + + +def test_drug_featurizer_is_abstract() -> None: + with pytest.raises(TypeError, match="abstract"): + DrugFeaturizer() + + +def test_drug_featurizer_adds_no_state_of_its_own() -> None: + assert set(DrugFeaturizer.__dict__) - set(Featurizer.__dict__) <= { + "__module__", + "__doc__", + "__abstractmethods__", + "_abc_impl", + "__firstlineno__", + "__static_attributes__", + } + + +def test_every_registered_drug_featurizer_derives_from_the_base() -> None: + names = list_drug_featurizers() + + assert names + for name in names: + assert issubclass(get_drug_featurizer(name), DrugFeaturizer), name + + +def test_dense_view_binding_sits_on_both_the_shared_base_and_the_side_base() -> None: + assert issubclass(DenseViewDrugFeaturizer, DenseViewFeaturizer) + assert issubclass(DenseViewDrugFeaturizer, DrugFeaturizer) + + +def test_dense_view_binding_resolves_the_side_base_before_the_shared_featurizer() -> None: + """The MRO order is what lets the drug base override shared behaviour.""" + mro = DenseViewDrugFeaturizer.__mro__ + + assert mro.index(DrugFeaturizer) < mro.index(Featurizer) diff --git a/tests/components/featurizers/drug/test_bpe_pharmaformer.py b/tests/components/featurizers/drug/test_bpe_pharmaformer.py new file mode 100644 index 000000000..d39d748f1 --- /dev/null +++ b/tests/components/featurizers/drug/test_bpe_pharmaformer.py @@ -0,0 +1,137 @@ +"""Tests for the BPE PharmaFormer drug featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.drug.bpe_pharmaformer`. ``subword-nmt`` +is a hard dependency and BPE codes are learned from the fixture's own SMILES, so +nothing here touches the network. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.drug.bpe_pharmaformer import BpePharmaformerDrugFeaturizer +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import DrugFeatureSource +from tests._import_shims import block_imports +from tests.conftest import MockFeatureSource + + +@pytest.fixture +def drug_source(synthetic_dataset: Dataset) -> DrugFeatureSource: + """Dataset-backed drug source with real, rdkit-parseable SMILES.""" + return DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + + +def test_output_dim_defaults_to_max_length() -> None: + assert BpePharmaformerDrugFeaturizer(max_length=64).output_dim == 64 + + +def test_hyperparameter_space_exposes_num_symbols_and_max_length() -> None: + assert set(BpePharmaformerDrugFeaturizer.get_hyperparameter_space()) == {"num_symbols", "max_length"} + + +def test_transform_before_fit_raises() -> None: + featurizer = BpePharmaformerDrugFeaturizer() + source = MockFeatureSource(features={"d1": {}}) + + with pytest.raises(RuntimeError, match="must be fit before transform"): + featurizer._transform(source, np.array(["d1"])) + + +def test_fit_without_smiles_raises() -> None: + featurizer = BpePharmaformerDrugFeaturizer() + source = MockFeatureSource(features={"d1": {}}) + + with pytest.raises(ValueError, match="Cannot learn BPE codes"): + featurizer.fit(source, entity_ids=np.array(["d1"])) + + +def test_fit_then_transform_emits_a_padded_token_matrix( + drug_source: DrugFeatureSource, + synthetic_dataset: Dataset, +) -> None: + drug_ids = synthetic_dataset.drug_ids[:2] + featurizer = BpePharmaformerDrugFeaturizer(num_symbols=50, max_length=32) + + featurizer.fit(drug_source, entity_ids=drug_ids) + matrix = featurizer.transform(drug_source, drug_ids) + + assert matrix.shape == (2, 32) + assert matrix.dtype == np.float32 + assert featurizer.output_dim == 32 + + +def test_transform_truncates_sequences_longer_than_max_length( + drug_source: DrugFeatureSource, + synthetic_dataset: Dataset, +) -> None: + drug_ids = synthetic_dataset.drug_ids[:2] + featurizer = BpePharmaformerDrugFeaturizer(num_symbols=50, max_length=4) + featurizer.fit(drug_source, entity_ids=drug_ids) + + matrix = featurizer.transform(drug_source, drug_ids) + + assert matrix.shape == (2, 4) + assert np.all(matrix > 0) + + +def test_transform_leaves_unknown_drugs_as_zero_rows( + drug_source: DrugFeatureSource, + synthetic_dataset: Dataset, +) -> None: + featurizer = BpePharmaformerDrugFeaturizer(num_symbols=50, max_length=16) + featurizer.fit(drug_source, entity_ids=synthetic_dataset.drug_ids[:2]) + + # ``_transform`` rather than ``transform``: the public wrapper's NaN detection + # sees no ``bpe_smiles`` row for an unknown drug and overwrites it with NaN. + matrix = featurizer._transform(drug_source, np.array(["not-a-drug"])) + + np.testing.assert_allclose(matrix, np.zeros((1, 16), dtype=np.float32)) + + +def test_transform_blocks_emit_a_single_bpe_smiles_block( + drug_source: DrugFeatureSource, + synthetic_dataset: Dataset, +) -> None: + drug_ids = synthetic_dataset.drug_ids[:2] + featurizer = BpePharmaformerDrugFeaturizer(num_symbols=50, max_length=16) + featurizer.fit(drug_source, entity_ids=drug_ids) + + blocks = featurizer.transform_blocks(drug_source, drug_ids) + + assert set(blocks) == {"bpe_smiles"} + assert blocks["bpe_smiles"].feature_names is None + + +def test_transform_pads_sequences_shorter_than_max_length( + drug_source: DrugFeatureSource, + synthetic_dataset: Dataset, +) -> None: + drug_ids = synthetic_dataset.drug_ids[:2] + featurizer = BpePharmaformerDrugFeaturizer(num_symbols=50, max_length=512) + featurizer.fit(drug_source, entity_ids=drug_ids) + + matrix = featurizer._transform(drug_source, drug_ids) + + assert matrix.shape == (2, 512) + assert np.any(matrix[0] == 0.0) + + +def test_transform_without_smiles_raises(drug_source: DrugFeatureSource, synthetic_dataset: Dataset) -> None: + featurizer = BpePharmaformerDrugFeaturizer(num_symbols=50, max_length=16) + featurizer.fit(drug_source, entity_ids=synthetic_dataset.drug_ids[:2]) + + with pytest.raises(ValueError, match="Cannot encode BPE"): + featurizer._transform(MockFeatureSource(features={"d1": {}}), np.array(["d1"])) + + +def test_learn_bpe_reports_missing_subword_nmt( + monkeypatch: pytest.MonkeyPatch, + drug_source: DrugFeatureSource, + synthetic_dataset: Dataset, +) -> None: + block_imports(monkeypatch, "subword_nmt") + + with pytest.raises(ImportError, match="subword-nmt is required"): + BpePharmaformerDrugFeaturizer().fit(drug_source, entity_ids=synthetic_dataset.drug_ids[:2]) diff --git a/tests/components/featurizers/drug/test_chemberta.py b/tests/components/featurizers/drug/test_chemberta.py new file mode 100644 index 000000000..2dbeba1f6 --- /dev/null +++ b/tests/components/featurizers/drug/test_chemberta.py @@ -0,0 +1,92 @@ +"""Tests for the ChemBERTa drug featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.drug.chemberta`. Only +``load_chemberta`` needs the mirrored weight download, so the pooling strategies +are tested directly against a bare torch tensor and marked as offline; the +end-to-end embedding path is left to the ``network``-marked smoke test. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch + +from drevalpy.components.featurizers.drug.chemberta import ChemBertaFeaturizer +from tests._import_shims import block_imports +from tests.conftest import MockFeatureSource + +_HIDDEN = torch.tensor([[[1.0, 2.0], [3.0, 8.0], [5.0, 2.0]]]) + + +def test_hyperparameter_space_exposes_pooling_and_max_length() -> None: + assert set(ChemBertaFeaturizer.get_hyperparameter_space()) == {"pooling", "max_length"} + + +@pytest.mark.parametrize( + ("pooling", "expected"), + [ + pytest.param("cls", [1.0, 2.0], id="cls-takes-the-first-token"), + pytest.param("max", [5.0, 8.0], id="max-over-tokens"), + pytest.param("mean", [3.0, 4.0], id="mean-over-tokens"), + ], +) +def test_pool_applies_the_configured_strategy(pooling: str, expected: list[float]) -> None: + featurizer = ChemBertaFeaturizer(pooling=pooling) + + pooled = featurizer._pool(_HIDDEN) + + np.testing.assert_allclose(pooled, expected) + + +def test_pool_falls_back_to_mean_for_an_unknown_strategy() -> None: + featurizer = ChemBertaFeaturizer(pooling="not-a-strategy") + + np.testing.assert_allclose(featurizer._pool(_HIDDEN), [3.0, 4.0]) + + +def test_compute_from_source_without_smiles_raises() -> None: + featurizer = ChemBertaFeaturizer() + source = MockFeatureSource(features={"d1": {}}) + + with pytest.raises(ValueError, match="no SMILES available"): + featurizer._compute_from_source(source, np.array(["d1"])) + + +def test_transform_blocks_are_named_chemberta() -> None: + source = MockFeatureSource( + features={"d1": {"chemberta": np.array([0.1, 0.2])}}, + meta_info={"chemberta": ["e1", "e2"]}, + ) + ids = np.array(["d1"], dtype=str) + featurizer = ChemBertaFeaturizer().fit(source, entity_ids=ids) + + blocks = featurizer.transform_blocks(source, ids) + + assert set(blocks) == {"chemberta"} + assert blocks["chemberta"].feature_names == ("e1", "e2") + + +def test_compute_from_source_reports_missing_transformers(monkeypatch: pytest.MonkeyPatch) -> None: + from drevalpy.components.featurizers.drug.chemberta import load_chemberta + + load_chemberta.cache_clear() + block_imports(monkeypatch, "transformers") + + with pytest.raises(ImportError, match="transformers and torch are required"): + load_chemberta() + + load_chemberta.cache_clear() + + +@pytest.mark.network +def test_compute_from_source_embeds_dataset_smiles(synthetic_dataset) -> None: + from drevalpy.types.data.feature_source import DrugFeatureSource + + source = DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + featurizer = ChemBertaFeaturizer(max_length=64) + + matrix = featurizer._compute_from_source(source, synthetic_dataset.drug_ids[:2]) + + assert matrix.shape[0] == 2 + assert matrix.dtype == np.float32 diff --git a/tests/components/featurizers/drug/test_drug_graph.py b/tests/components/featurizers/drug/test_drug_graph.py new file mode 100644 index 000000000..9525a021e --- /dev/null +++ b/tests/components/featurizers/drug/test_drug_graph.py @@ -0,0 +1,192 @@ +"""Tests for graph drug featurizer payload handling and on-the-fly fallback.""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch +from torch_geometric.data import Data + +from drevalpy.components.featurizers.drug.drug_graph import ( + DrugGraphFeaturizer, + _one_hot_encode, + _smiles_to_graph, +) +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import DrugFeatureSource +from tests._import_shims import block_imports +from tests.conftest import MockFeatureSource + + +def test_drug_graph_featurizer_preserves_graph_payloads() -> None: + graph = Data( + x=torch.ones((2, 3)), + edge_index=torch.tensor([[0], [1]], dtype=torch.long), + ) + features = MockFeatureSource({"d1": {"drug_graph": graph}}) + featurizer = DrugGraphFeaturizer().fit(features, entity_ids=np.array(["d1"])) + + block = featurizer.transform_blocks(features, np.array(["d1"]))["drug_graph"] + + assert block.values.shape == (1,) + assert block.values[0] is graph + + +def test_drug_graph_infers_node_feature_width_from_the_first_graph() -> None: + graph = Data(x=torch.ones((2, 3)), edge_index=torch.tensor([[0], [1]], dtype=torch.long)) + features = MockFeatureSource({"d1": {"drug_graph": graph}}) + + featurizer = DrugGraphFeaturizer().fit(features, entity_ids=np.array(["d1"])) + + assert featurizer.output_dim == 3 + assert set(featurizer.graph_by_drug) == {"d1"} + + +def test_drug_graph_hyperparameter_space_exposes_add_hydrogens() -> None: + assert set(DrugGraphFeaturizer.get_hyperparameter_space()) == {"add_hydrogens"} + + +def test_drug_graph_transform_reads_uncached_drugs_from_the_source() -> None: + graph = Data(x=torch.ones((2, 3)), edge_index=torch.tensor([[0], [1]], dtype=torch.long)) + other = Data(x=torch.zeros((1, 3)), edge_index=torch.empty((2, 0), dtype=torch.long)) + features = MockFeatureSource({"d1": {"drug_graph": graph}, "d2": {"drug_graph": other}}) + featurizer = DrugGraphFeaturizer().fit(features, entity_ids=np.array(["d1"])) + + payloads = featurizer.transform(features, np.array(["d2"])) + + assert payloads[0] is other + + +def test_drug_graph_fit_skips_drugs_it_can_neither_read_nor_compute() -> None: + features = MockFeatureSource({"d1": {}}) + + featurizer = DrugGraphFeaturizer().fit(features, entity_ids=np.array(["d1"])) + + assert featurizer.graph_by_drug == {} + assert featurizer.output_dim == 0 + + +def test_drug_graph_transform_raises_for_a_drug_it_cannot_resolve() -> None: + features = MockFeatureSource({"d1": {}}) + featurizer = DrugGraphFeaturizer().fit(features, entity_ids=np.array(["d1"])) + + with pytest.raises(KeyError, match="graph computation failed"): + featurizer.transform(features, np.array(["d1"])) + + +class _NoStoredGraphSource(DrugFeatureSource): + """Dataset-backed drug source that reports no stored graph payloads. + + ``DrugFeatureSource.get_entity_view`` raises ``KeyError`` rather than + returning ``None`` for an absent view, so this override is the only way to + reach the SMILES-based on-the-fly fallback in ``_fit`` / ``_transform``. + """ + + def get_entity_view(self, entity_id: str, view: str) -> None: + """Report every graph view as absent.""" + return None + + +def test_drug_graph_fit_computes_graphs_on_the_fly_for_missing_views(synthetic_dataset: Dataset) -> None: + source = _NoStoredGraphSource(synthetic_dataset, synthetic_dataset.drug_ids) + drug_ids = synthetic_dataset.drug_ids[:2] + + featurizer = DrugGraphFeaturizer().fit(source, entity_ids=drug_ids) + + assert set(featurizer.graph_by_drug) == set(drug_ids) + assert featurizer.output_dim > 0 + + +def test_drug_graph_transform_computes_graphs_on_the_fly_for_missing_views( + synthetic_dataset: Dataset, +) -> None: + source = _NoStoredGraphSource(synthetic_dataset, synthetic_dataset.drug_ids) + + payloads = DrugGraphFeaturizer().transform(source, synthetic_dataset.drug_ids[:1]) + + assert isinstance(payloads[0], Data) + + +def test_drug_graph_compute_from_source_emits_none_without_smiles() -> None: + features = MockFeatureSource({"d1": {}, "d2": {}}) + + payloads = DrugGraphFeaturizer()._compute_from_source(features, np.array(["d1", "d2"])) + + assert payloads.tolist() == [None, None] + + +def test_drug_graph_computes_graphs_from_dataset_smiles(synthetic_dataset: Dataset) -> None: + source = DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + drug_ids = synthetic_dataset.drug_ids[:2] + + payloads = DrugGraphFeaturizer()._compute_from_source(source, drug_ids) + + assert payloads.shape == (2,) + assert all(isinstance(payload, Data) for payload in payloads) + + +def test_smiles_to_graph_returns_none_for_unparseable_smiles() -> None: + assert _smiles_to_graph("this is not a molecule") is None + + +def test_smiles_to_graph_emits_empty_edges_for_a_single_atom() -> None: + graph = _smiles_to_graph("C") + + assert graph is not None + assert graph.x.shape[0] == 1 + assert graph.edge_index.shape == (2, 0) + assert graph.edge_attr.shape == (0, 6) + + +def test_smiles_to_graph_adds_explicit_hydrogens_on_request() -> None: + without = _smiles_to_graph("C", add_hydrogens=False) + with_hs = _smiles_to_graph("C", add_hydrogens=True) + + assert without is not None + assert with_hs is not None + assert with_hs.x.shape[0] == without.x.shape[0] + 4 + assert with_hs.edge_index.shape[1] == 8 + + +def test_one_hot_encode_uses_the_trailing_bin_for_unknown_values() -> None: + assert _one_hot_encode("z", ["a", "b"]) == [0, 0, 1] + + +def test_one_hot_encode_sets_the_matching_position() -> None: + assert _one_hot_encode("b", ["a", "b"]) == [0, 1, 0] + + +@pytest.mark.parametrize( + ("blocked", "message"), + [ + pytest.param(("rdkit",), "rdkit is required", id="rdkit"), + pytest.param(("torch",), "torch and torch_geometric are required", id="torch-and-geometric"), + ], +) +def test_smiles_to_graph_names_the_missing_dependency( + monkeypatch: pytest.MonkeyPatch, + blocked: tuple[str, ...], + message: str, +) -> None: + """``"torch"`` as a prefix blocks ``torch_geometric`` as well.""" + block_imports(monkeypatch, *blocked) + + with pytest.raises(ImportError, match=message): + _smiles_to_graph("CCO") + + +def test_drug_graph_compute_from_source_returns_none_for_a_non_string_smiles( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import pandas as pd + + import drevalpy.components.featurizers.drug.drug_graph as drug_graph_module + + monkeypatch.setattr( + drug_graph_module, + "get_smiles_for_entities", + lambda source, entity_ids: pd.Series({"d1": float("nan")}), + ) + features = MockFeatureSource({"d1": {}}) + + assert DrugGraphFeaturizer()._compute_graph_from_smiles_for_entity(features, "d1") is None diff --git a/tests/components/featurizers/drug/test_fingerprints.py b/tests/components/featurizers/drug/test_fingerprints.py new file mode 100644 index 000000000..eaeac66c4 --- /dev/null +++ b/tests/components/featurizers/drug/test_fingerprints.py @@ -0,0 +1,109 @@ +"""Tests for the Morgan fingerprint drug featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.drug.fingerprints`. The happy path +through ``_compute_from_source`` is already smoke-tested in +``test_precompute_smoke.py``; this file pins the residual error and NaN paths of +``_fingerprint_for_smiles``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.drug.fingerprints import FingerprintsFeaturizer +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import DrugFeatureSource +from tests._import_shims import block_imports +from tests.conftest import MockFeatureSource + + +def _generator(n_bits: int = 16): + from rdkit.Chem import rdFingerprintGenerator + + return rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=n_bits) + + +def test_hyperparameter_space_declares_the_four_tunables() -> None: + space = FingerprintsFeaturizer.get_hyperparameter_space() + + assert set(space) == {"radius", "n_bits", "use_chirality", "use_counts"} + + +def test_compute_from_source_without_smiles_raises() -> None: + featurizer = FingerprintsFeaturizer(n_bits=16) + source = MockFeatureSource(features={"d1": {}}) + + with pytest.raises(ValueError, match="no SMILES available"): + featurizer._compute_from_source(source, np.array(["d1"])) + + +def test_compute_from_source_reports_a_missing_rdkit( + monkeypatch: pytest.MonkeyPatch, + synthetic_dataset: Dataset, +) -> None: + block_imports(monkeypatch, "rdkit") + featurizer = FingerprintsFeaturizer(n_bits=16) + source = DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + + with pytest.raises(ImportError, match="rdkit is required"): + featurizer._compute_from_source(source, synthetic_dataset.drug_ids[:1]) + + +@pytest.mark.parametrize( + "smiles", + [ + pytest.param(None, id="missing"), + pytest.param("", id="empty-string"), + pytest.param(float("nan"), id="not-a-string"), + pytest.param("this is not a molecule", id="unparseable"), + ], +) +def test_unusable_smiles_produce_a_nan_row(smiles: object) -> None: + from drevalpy.components.featurizers.drug.fingerprints import _fingerprint_for_smiles + + row = _fingerprint_for_smiles(smiles, _generator(), 16, False) + + assert row.shape == (16,) + assert np.all(np.isnan(row)) + + +def test_binary_fingerprints_are_zero_or_one() -> None: + from drevalpy.components.featurizers.drug.fingerprints import _fingerprint_for_smiles + + row = _fingerprint_for_smiles("CCO", _generator(), 16, False) + + assert set(np.unique(row)) <= {0.0, 1.0} + + +def test_count_fingerprints_can_exceed_one() -> None: + from drevalpy.components.featurizers.drug.fingerprints import _fingerprint_for_smiles + + counts = _fingerprint_for_smiles("CCCCCCCCCC", _generator(8), 8, True) + binary = _fingerprint_for_smiles("CCCCCCCCCC", _generator(8), 8, False) + + assert counts.sum() > binary.sum() + + +def test_compute_from_source_emits_one_row_per_drug(synthetic_dataset: Dataset) -> None: + featurizer = FingerprintsFeaturizer(n_bits=32, use_counts=True) + source = DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + drug_ids = synthetic_dataset.drug_ids[:2] + + matrix = featurizer._compute_from_source(source, drug_ids) + + assert matrix.shape == (2, 32) + + +def test_transform_blocks_are_named_fingerprints() -> None: + source = MockFeatureSource( + features={"d1": {"morgan_fingerprint": np.array([1.0, 0.0])}}, + meta_info={"morgan_fingerprint": ["fp1", "fp2"]}, + ) + ids = np.array(["d1"], dtype=str) + featurizer = FingerprintsFeaturizer().fit(source, entity_ids=ids) + + blocks = featurizer.transform_blocks(source, ids) + + assert set(blocks) == {"fingerprints"} + assert blocks["fingerprints"].feature_names == ("fp1", "fp2") diff --git a/tests/components/featurizers/drug/test_molgnet.py b/tests/components/featurizers/drug/test_molgnet.py new file mode 100644 index 000000000..755640553 --- /dev/null +++ b/tests/components/featurizers/drug/test_molgnet.py @@ -0,0 +1,257 @@ +"""Tests for the MolGNet ragged drug featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.drug.molgnet`. Everything except +``_compute_molgnet_embedding``'s checkpoint load is exercised offline: the class +is a dict cache over ``FeatureSource.get_entity_view``, so a mock source serving +ragged arrays covers ``_fit`` / ``_transform`` / ``_transform_blocks`` without +touching the 300 MB ``MolGNet.pt`` artifact. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.drug.molgnet import ( + MolGNetDrugFeaturizer, + _compute_molgnet_embedding, +) +from tests._import_shims import block_imports +from tests.conftest import MockFeatureSource + +_D1 = np.arange(6, dtype=np.float32).reshape(2, 3) +_D2 = np.arange(9, dtype=np.float32).reshape(3, 3) + + +def _ragged_source() -> MockFeatureSource: + """Source serving two MolGNet tensors of differing atom counts.""" + return MockFeatureSource( + features={ + "d1": {"molgnet_features": _D1}, + "d2": {"molgnet_features": _D2}, + } + ) + + +def test_molgnet_fit_infers_embedding_width_from_the_first_tensor() -> None: + featurizer = MolGNetDrugFeaturizer() + + featurizer.fit(_ragged_source(), entity_ids=np.array(["d1", "d2"], dtype=str)) + + assert featurizer.output_dim == 3 + + +def test_molgnet_output_dim_is_zero_before_fit() -> None: + assert MolGNetDrugFeaturizer().output_dim == 0 + + +def test_molgnet_transform_returns_one_tensor_per_drug() -> None: + source = _ragged_source() + ids = np.array(["d1", "d2"], dtype=str) + featurizer = MolGNetDrugFeaturizer().fit(source, entity_ids=ids) + + payloads = featurizer.transform(source, ids) + + assert payloads.shape == (2,) + np.testing.assert_allclose(payloads[0], _D1) + np.testing.assert_allclose(payloads[1], _D2) + + +def test_molgnet_transform_reads_uncached_drugs_from_the_source() -> None: + source = _ragged_source() + featurizer = MolGNetDrugFeaturizer().fit(source, entity_ids=np.array(["d1"], dtype=str)) + + payloads = featurizer.transform(source, np.array(["d2"], dtype=str)) + + # A single tensor makes ``np.array(rows, dtype=object)`` build a (1, n, dim) + # object array rather than a 1-element array of tensors, so re-cast the row. + np.testing.assert_allclose(np.asarray(payloads[0], dtype=np.float32), _D2) + + +def test_molgnet_transform_blocks_emits_a_single_ragged_block() -> None: + source = _ragged_source() + ids = np.array(["d1", "d2"], dtype=str) + featurizer = MolGNetDrugFeaturizer().fit(source, entity_ids=ids) + + blocks = featurizer.transform_blocks(source, ids) + + assert set(blocks) == {"molgnet_features"} + assert blocks["molgnet_features"].format is FeatureFormat.RAGGED_SEQUENCE + assert blocks["molgnet_features"].values.shape == (2,) + + +def test_molgnet_honours_a_custom_view_name() -> None: + source = MockFeatureSource(features={"d1": {"custom": _D1}}) + featurizer = MolGNetDrugFeaturizer(view="custom") + + featurizer.fit(source, entity_ids=np.array(["d1"], dtype=str)) + + assert featurizer.output_dim == 3 + + +def test_molgnet_fit_flattens_one_dimensional_tensors() -> None: + source = MockFeatureSource(features={"d1": {"molgnet_features": np.arange(4, dtype=np.float32)}}) + featurizer = MolGNetDrugFeaturizer() + + featurizer.fit(source, entity_ids=np.array(["d1"], dtype=str)) + + assert featurizer.output_dim == 4 + + +def test_molgnet_fit_over_all_identifiers_when_none_are_given() -> None: + featurizer = MolGNetDrugFeaturizer().fit(_ragged_source()) + + assert featurizer.output_dim == 3 + + +def test_molgnet_fit_skips_drugs_it_can_neither_read_nor_compute() -> None: + source = MockFeatureSource(features={"d1": {}}) + + featurizer = MolGNetDrugFeaturizer().fit(source, entity_ids=np.array(["d1"], dtype=str)) + + assert featurizer.output_dim == 0 + + +def test_molgnet_transform_raises_for_a_drug_it_cannot_resolve() -> None: + source = MockFeatureSource(features={"d1": {}}) + featurizer = MolGNetDrugFeaturizer().fit(source, entity_ids=np.array(["d1"], dtype=str)) + + with pytest.raises(KeyError, match="on-the-fly computation failed"): + featurizer.transform(source, np.array(["d1"], dtype=str)) + + +def test_molgnet_compute_from_source_emits_empty_rows_without_smiles() -> None: + source = MockFeatureSource(features={"d1": {}, "d2": {}}) + featurizer = MolGNetDrugFeaturizer() + + computed = featurizer._compute_from_source(source, np.array(["d1", "d2"], dtype=str)) + + assert computed.shape[0] == 2 + assert computed[0].shape == (0, 768) + + +def test_molgnet_compute_embedding_returns_none_for_unparseable_smiles() -> None: + assert _compute_molgnet_embedding("not-a-smiles") is None + + +@pytest.mark.parametrize( + ("blocked", "message"), + [ + pytest.param("torch", "torch and torch_geometric are required", id="torch"), + pytest.param("rdkit", "rdkit is required", id="rdkit"), + ], +) +def test_molgnet_compute_embedding_names_the_missing_dependency( + monkeypatch: pytest.MonkeyPatch, + blocked: str, + message: str, +) -> None: + block_imports(monkeypatch, blocked) + + with pytest.raises(ImportError, match=message): + _compute_molgnet_embedding("CCO") + + +def test_molgnet_compute_single_embedding_returns_none_without_smiles() -> None: + source = MockFeatureSource(features={"d1": {}}) + + assert MolGNetDrugFeaturizer()._compute_single_embedding(source, "d1") is None + + +def _patch_embedding(monkeypatch: pytest.MonkeyPatch, rows: int = 4) -> np.ndarray: + """Replace the checkpoint-backed embedding with a fixed tensor. + + ``_compute_molgnet_embedding`` is the only artifact-download boundary in the + on-the-fly path; stubbing it exercises the surrounding fallback branches + offline. + """ + import drevalpy.components.featurizers.drug.molgnet as molgnet_module + + embedding = np.ones((rows, 3), dtype=np.float32) + monkeypatch.setattr(molgnet_module, "_compute_molgnet_embedding", lambda smiles: embedding) + return embedding + + +def test_molgnet_fit_computes_missing_drugs_on_the_fly( + monkeypatch: pytest.MonkeyPatch, + synthetic_dataset, +) -> None: + from drevalpy.types.data.feature_source import DrugFeatureSource + + class _NoStoredTensorSource(DrugFeatureSource): + def get_entity_view(self, entity_id: str, view: str) -> None: + return None + + embedding = _patch_embedding(monkeypatch) + source = _NoStoredTensorSource(synthetic_dataset, synthetic_dataset.drug_ids) + drug_ids = synthetic_dataset.drug_ids[:2] + + featurizer = MolGNetDrugFeaturizer().fit(source, entity_ids=drug_ids) + + assert featurizer.output_dim == embedding.shape[1] + assert set(featurizer._features_by_drug) == set(drug_ids) + + +def test_molgnet_transform_computes_missing_drugs_on_the_fly( + monkeypatch: pytest.MonkeyPatch, + synthetic_dataset, +) -> None: + from drevalpy.types.data.feature_source import DrugFeatureSource + + class _NoStoredTensorSource(DrugFeatureSource): + def get_entity_view(self, entity_id: str, view: str) -> None: + return None + + embedding = _patch_embedding(monkeypatch) + source = _NoStoredTensorSource(synthetic_dataset, synthetic_dataset.drug_ids) + + payloads = MolGNetDrugFeaturizer().transform(source, synthetic_dataset.drug_ids[:1]) + + np.testing.assert_allclose(np.asarray(payloads[0], dtype=np.float32), embedding) + + +def test_molgnet_compute_from_source_uses_the_computed_embedding( + monkeypatch: pytest.MonkeyPatch, + synthetic_dataset, +) -> None: + from drevalpy.types.data.feature_source import DrugFeatureSource + + embedding = _patch_embedding(monkeypatch) + source = DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + + computed = MolGNetDrugFeaturizer()._compute_from_source(source, synthetic_dataset.drug_ids[:2]) + + assert computed.shape[0] == 2 + np.testing.assert_allclose(np.asarray(computed[0], dtype=np.float32), embedding) + + +def test_molgnet_compute_single_embedding_returns_none_for_a_non_string_smiles( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import pandas as pd + + import drevalpy.components.featurizers.drug.molgnet as molgnet_module + + monkeypatch.setattr( + molgnet_module, + "get_smiles_for_entities", + lambda source, entity_ids: pd.Series({"d1": float("nan")}), + ) + + assert MolGNetDrugFeaturizer()._compute_single_embedding(MockFeatureSource(features={}), "d1") is None + + +@pytest.mark.network +def test_molgnet_checkpoint_path_resolves() -> None: + from drevalpy.components.featurizers.drug.molgnet import _get_molgnet_checkpoint + + assert _get_molgnet_checkpoint().endswith("MolGNet.pt") + + +@pytest.mark.network +def test_molgnet_computes_an_embedding_from_the_checkpoint() -> None: + embedding = _compute_molgnet_embedding("CCO") + + assert embedding is not None + assert embedding.shape == (3, 768) diff --git a/tests/components/featurizers/drug/test_molgnet_network.py b/tests/components/featurizers/drug/test_molgnet_network.py new file mode 100644 index 000000000..b667d0819 --- /dev/null +++ b/tests/components/featurizers/drug/test_molgnet_network.py @@ -0,0 +1,184 @@ +"""Tests for the MolGNet graph conversion and network. + +Mirrors :mod:`drevalpy.components.featurizers.drug._molgnet_network` (underscore +stripped, per the AGENTS.md private-module rule). The module was at zero coverage +because nothing imported it outside the checkpoint path; nothing here needs the +checkpoint, only a randomly initialised, deliberately tiny ``MolGNet``. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch +from rdkit import Chem + +from drevalpy.components.featurizers.drug._molgnet_network import ( + AddSegId, + BertLayerNorm, + LinearActivation, + MolGNet, + SelfLoop, + _gelu, + _MessagePassing, + atom_cumsum, + bond_cumsum, + mol_to_graph_data_obj_complex, +) + + +def _graph(smiles: str = "CCO"): + return mol_to_graph_data_obj_complex(Chem.MolFromSmiles(smiles)) + + +def test_cumulative_offsets_are_monotonic() -> None: + assert np.all(np.diff(atom_cumsum) > 0) + assert np.all(np.diff(bond_cumsum) > 0) + + +def test_mol_to_graph_encodes_eight_atom_features() -> None: + graph = _graph("CCO") + + assert graph.x.shape == (3, 8) + assert graph.x.dtype == torch.long + + +def test_mol_to_graph_emits_both_bond_directions() -> None: + graph = _graph("CCO") + + assert graph.edge_index.shape == (2, 4) + assert graph.edge_attr.shape == (4, 5) + + +def test_mol_to_graph_emits_empty_edges_for_a_single_atom() -> None: + graph = _graph("C") + + assert graph.x.shape == (1, 8) + assert graph.edge_index.shape == (2, 0) + assert graph.edge_attr.shape == (0, 5) + + +def test_mol_to_graph_handles_aromatic_rings() -> None: + graph = _graph("c1ccccc1") + + assert graph.x.shape == (6, 8) + assert graph.edge_index.shape == (2, 12) + + +def test_mol_to_graph_rejects_none() -> None: + with pytest.raises(ValueError, match="must not be None"): + mol_to_graph_data_obj_complex(None) + + +def test_self_loop_adds_one_edge_per_node() -> None: + graph = _graph("CCO") + before_edges = graph.edge_index.shape[1] + + looped = SelfLoop()(graph) + + assert looped.edge_index.shape[1] == before_edges + 3 + assert looped.edge_attr.shape[0] == before_edges + 3 + + +def test_add_seg_id_attaches_zero_filled_segment_tensors() -> None: + graph = AddSegId()(SelfLoop()(_graph("CCO"))) + + assert graph.node_seg.tolist() == [0, 0, 0] + assert graph.edge_seg.shape[0] == graph.num_edges + assert torch.all(graph.edge_seg == 0) + + +def test_add_seg_id_rejects_a_graph_without_a_node_count() -> None: + class _NodelessGraph: + num_nodes = None + num_edges = 0 + + with pytest.raises(ValueError, match="graph reports no node count"): + AddSegId()(_NodelessGraph()) + + +def test_bert_layer_norm_normalizes_the_last_dimension() -> None: + layer = BertLayerNorm(4) + + out = layer(torch.tensor([[1.0, 2.0, 3.0, 4.0]])) + + assert out.shape == (1, 4) + assert abs(float(out.mean().detach())) < 1e-5 + + +def test_molgnet_forward_returns_one_embedding_row_per_atom() -> None: + torch.manual_seed(0) + graph = AddSegId()(SelfLoop()(_graph("CCO"))) + model = MolGNet(num_layer=1, emb_dim=8, heads=2, num_message_passing=1, drop_ratio=0.0) + model.eval() + + with torch.no_grad(): + embedding = model(graph) + + assert embedding.shape == (3, 8) + assert torch.isfinite(embedding).all() + + +def test_molgnet_forward_accepts_unpacked_tensors() -> None: + torch.manual_seed(0) + graph = AddSegId()(SelfLoop()(_graph("CCO"))) + model = MolGNet(num_layer=1, emb_dim=8, heads=2, num_message_passing=1, drop_ratio=0.0) + model.eval() + + with torch.no_grad(): + embedding = model( + graph.x, + graph.edge_index, + graph.edge_attr, + graph.node_seg, + graph.edge_seg, + ) + + assert embedding.shape == (3, 8) + + +def test_molgnet_forward_rejects_an_unexpected_argument_count() -> None: + model = MolGNet(num_layer=1, emb_dim=8, heads=2, num_message_passing=1, drop_ratio=0.0) + + with pytest.raises(ValueError, match="unmatched number of arguments"): + model(1, 2) + + +def test_gelu_is_zero_at_the_origin_and_monotonic() -> None: + values = _gelu(torch.tensor([-2.0, 0.0, 2.0])) + + assert float(values[1]) == pytest.approx(0.0) + assert float(values[0]) < float(values[1]) < float(values[2]) + + +def test_linear_activation_without_a_bias_uses_the_plain_gelu_path() -> None: + torch.manual_seed(0) + layer = LinearActivation(4, 2, bias=False) + + out = layer(torch.ones((1, 4))) + + assert layer.bias is None + assert out.shape == (1, 2) + assert torch.isfinite(out).all() + + +def test_message_passing_defaults_forward_messages_unchanged() -> None: + passing = _MessagePassing() + x = torch.tensor([[1.0], [2.0]]) + edge_index = torch.tensor([[0, 1], [1, 0]], dtype=torch.long) + + out = passing.propagate(edge_index=edge_index, x=x) + + assert out.shape == (2, 1) + + +def test_message_passing_requires_node_features() -> None: + passing = _MessagePassing() + + with pytest.raises(ValueError, match="propagate requires 'x'"): + passing.propagate(edge_index=torch.tensor([[0], [1]], dtype=torch.long)) + + +def test_message_passing_message_requires_source_features() -> None: + with pytest.raises(ValueError, match="message requires 'x_j'"): + _MessagePassing().message() diff --git a/tests/components/featurizers/drug/test_smiles_utils.py b/tests/components/featurizers/drug/test_smiles_utils.py new file mode 100644 index 000000000..55e0469a7 --- /dev/null +++ b/tests/components/featurizers/drug/test_smiles_utils.py @@ -0,0 +1,47 @@ +"""Tests for SMILES lookup through a ``FeatureSource``. + +Mirrors :mod:`drevalpy.components.featurizers.drug._smiles_utils`. +""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.featurizers.drug._smiles_utils import get_smiles_for_entities +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import DrugFeatureSource +from tests.conftest import MockFeatureSource +from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + +def test_returns_none_without_a_mudata_backing() -> None: + source = MockFeatureSource(features={"d1": {}}) + + assert get_smiles_for_entities(source, np.array(["d1"])) is None + + +def test_returns_none_when_the_response_var_has_no_smiles_column() -> None: + dataset = synthetic_mudataset_gene_expression_fingerprints() + source = DrugFeatureSource(dataset, dataset.drug_ids) + + assert get_smiles_for_entities(source, dataset.drug_ids) is None + + +def test_returns_smiles_indexed_by_the_requested_drug_ids(synthetic_dataset: Dataset) -> None: + drug_ids = synthetic_dataset.drug_ids[:3] + source = DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + + smiles = get_smiles_for_entities(source, drug_ids) + + assert smiles is not None + assert list(smiles.index) == list(drug_ids) + assert all(isinstance(value, str) and value for value in smiles) + + +def test_unknown_drug_ids_reindex_to_nan(synthetic_dataset: Dataset) -> None: + source = DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + + smiles = get_smiles_for_entities(source, np.array(["not-a-drug"])) + + assert smiles is not None + assert smiles.isna().all() diff --git a/tests/components/featurizers/drug/test_smilesvec.py b/tests/components/featurizers/drug/test_smilesvec.py new file mode 100644 index 000000000..4b6c9aaf1 --- /dev/null +++ b/tests/components/featurizers/drug/test_smilesvec.py @@ -0,0 +1,109 @@ +"""Tests for the SMILESVec drug featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.drug.smilesvec`. Only +``_compute_from_source``'s ``get_artifact`` call needs the network; the k-mer +averaging in ``_smilesvec_embed`` is covered offline against a stub that exposes +the slice of the gensim ``KeyedVectors`` API the function actually uses. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.drug.smilesvec import SmilesVecDrugFeaturizer, _smilesvec_embed +from tests._import_shims import block_imports +from tests.conftest import MockFeatureSource + + +class _FakeKeyedVectors: + """Stub exposing the ``KeyedVectors`` surface ``_smilesvec_embed`` touches.""" + + def __init__(self, vectors: dict[str, np.ndarray], vector_size: int) -> None: + self._vectors = vectors + self.vector_size = vector_size + + @property + def key_to_index(self) -> dict[str, int]: + """Vocabulary membership, as gensim exposes it.""" + return {word: i for i, word in enumerate(self._vectors)} + + def __getitem__(self, word: str) -> np.ndarray: + return self._vectors[word] + + +def test_hyperparameter_space_exposes_the_kmer_length() -> None: + assert set(SmilesVecDrugFeaturizer.get_hyperparameter_space()) == {"k"} + + +def test_embed_averages_the_vectors_of_known_kmers() -> None: + kv = _FakeKeyedVectors({"CCO": np.array([1.0, 3.0]), "CON": np.array([3.0, 5.0])}, vector_size=2) + + embedding = _smilesvec_embed("CCOCON", kv, k=3, dim=2) + + # "CCO" and "CON" are the only two of the four 3-mers in the vocabulary. + np.testing.assert_allclose(embedding, [2.0, 4.0]) + assert embedding.dtype == np.float32 + + +def test_embed_treats_a_short_smiles_as_a_single_word() -> None: + kv = _FakeKeyedVectors({"CC": np.array([2.0, 4.0])}, vector_size=2) + + embedding = _smilesvec_embed("CC", kv, k=8, dim=2) + + np.testing.assert_allclose(embedding, [2.0, 4.0]) + + +def test_embed_returns_a_zero_vector_when_no_kmer_is_known() -> None: + kv = _FakeKeyedVectors({"XYZ": np.array([1.0, 1.0])}, vector_size=2) + + embedding = _smilesvec_embed("CCOCCO", kv, k=3, dim=2) + + np.testing.assert_allclose(embedding, [0.0, 0.0]) + + +def test_compute_from_source_without_smiles_raises() -> None: + featurizer = SmilesVecDrugFeaturizer() + source = MockFeatureSource(features={"d1": {}}) + + with pytest.raises(ValueError, match="no SMILES available"): + featurizer._compute_from_source(source, np.array(["d1"])) + + +def test_transform_blocks_are_named_smilesvec() -> None: + source = MockFeatureSource( + features={"d1": {"smilesvec": np.array([0.1, 0.2])}}, + meta_info={"smilesvec": ["v1", "v2"]}, + ) + ids = np.array(["d1"], dtype=str) + featurizer = SmilesVecDrugFeaturizer().fit(source, entity_ids=ids) + + blocks = featurizer.transform_blocks(source, ids) + + assert set(blocks) == {"smilesvec"} + assert blocks["smilesvec"].feature_names == ("v1", "v2") + + +def test_compute_from_source_reports_missing_gensim( + monkeypatch: pytest.MonkeyPatch, + synthetic_dataset, +) -> None: + from drevalpy.types.data.feature_source import DrugFeatureSource + + block_imports(monkeypatch, "gensim") + source = DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + + with pytest.raises(ImportError, match="gensim is required"): + SmilesVecDrugFeaturizer()._compute_from_source(source, synthetic_dataset.drug_ids[:1]) + + +@pytest.mark.network +def test_compute_from_source_embeds_dataset_smiles(synthetic_dataset) -> None: + from drevalpy.types.data.feature_source import DrugFeatureSource + + source = DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + + matrix = SmilesVecDrugFeaturizer()._compute_from_source(source, synthetic_dataset.drug_ids[:2]) + + assert matrix.shape[0] == 2 + assert matrix.dtype == np.float32 diff --git a/tests/components/featurizers/drug/test_view.py b/tests/components/featurizers/drug/test_view.py new file mode 100644 index 000000000..188bd7814 --- /dev/null +++ b/tests/components/featurizers/drug/test_view.py @@ -0,0 +1,55 @@ +"""Tests for the registered single-view drug featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.drug.view`, which is now a thin +registered binding over ``DenseViewFeaturizer``; the shared fit/transform/block +behaviour is covered once in ``tests/components/featurizers/test_dense_view.py``. +What is specific to this module is its registration, its default view, and the +fact that the three on-the-fly drug featurizers no longer inherit from it. +""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.featurizers._dense_view import DenseViewFeaturizer +from drevalpy.components.featurizers.drug.view import ViewDrugFeaturizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from tests.conftest import MockFeatureSource + + +def _drug_source() -> MockFeatureSource: + return MockFeatureSource( + features={ + "d1": {"morgan_fingerprint": np.array([1.0, 0.0])}, + "d2": {"morgan_fingerprint": np.array([0.0, 1.0])}, + }, + meta_info={"morgan_fingerprint": ["fp1", "fp2"]}, + ) + + +def test_view_drug_featurizer_is_registered_under_view() -> None: + assert get_drug_featurizer("view") is ViewDrugFeaturizer + assert ViewDrugFeaturizer.side == "drug" + + +def test_view_drug_featurizer_defaults_to_the_morgan_fingerprint_view() -> None: + assert ViewDrugFeaturizer.input_views == ("morgan_fingerprint",) + assert ViewDrugFeaturizer()._view == "morgan_fingerprint" + + +def test_view_drug_featurizer_reuses_the_shared_dense_base() -> None: + assert issubclass(ViewDrugFeaturizer, DenseViewFeaturizer) + assert "_transform" not in vars(ViewDrugFeaturizer) + + +def test_view_drug_featurizer_passes_the_view_through() -> None: + source = _drug_source() + ids = np.array(["d1", "d2"], dtype=str) + featurizer = ViewDrugFeaturizer().fit(source, entity_ids=ids) + + blocks = featurizer.transform_blocks(source, ids) + + assert featurizer.output_dim == 2 + assert set(blocks) == {"morgan_fingerprint"} + assert blocks["morgan_fingerprint"].feature_names == ("fp1", "fp2") + np.testing.assert_allclose(featurizer.transform(source, ids), [[1.0, 0.0], [0.0, 1.0]]) diff --git a/tests/components/featurizers/shared/test_concat.py b/tests/components/featurizers/shared/test_concat.py new file mode 100644 index 000000000..486475eec --- /dev/null +++ b/tests/components/featurizers/shared/test_concat.py @@ -0,0 +1,185 @@ +"""Tests for the shared concatenating featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.shared.concat`. The concatenation +logic lives in ``ConcatFeaturizersMixin`` and is covered in +``tests/components/featurizers/test_concat.py``; this file exercises the two side +bindings end to end, including that each resolves its children against its own +registry. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from pydantic import ValidationError + +from drevalpy.components.featurizers.shared.concat import ( + CellLineConcatFeaturizer, + DrugConcatFeaturizer, + SharedConcatFeaturizer, +) +from drevalpy.models.config import CellLineFeaturizerConfig, FeaturizerConfig +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from drevalpy.types.data.batch.feature_block import FeatureBlock +from tests.conftest import MockFeatureSource + + +def _cell_line_features() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": { + "gene_expression": np.array([1.0, 2.0], dtype=np.float32), + "mutations": np.array([0.0, 1.0], dtype=np.float32), + }, + "cl2": { + "gene_expression": np.array([3.0, 4.0], dtype=np.float32), + "mutations": np.array([1.0, 0.0], dtype=np.float32), + }, + }, + meta_info={"gene_expression": ["g1", "g2"], "mutations": ["m1", "m2"]}, + ) + + +def _multi_view_features() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": { + "gene_expression": np.array([1.0, 2.0], dtype=np.float32), + "proteomics": np.array([10.0, 20.0, 30.0], dtype=np.float32), + }, + "cl2": { + "gene_expression": np.array([3.0, 4.0], dtype=np.float32), + "proteomics": np.array([40.0, 50.0, 60.0], dtype=np.float32), + }, + } + ) + + +def _drug_features() -> MockFeatureSource: + return MockFeatureSource( + features={ + "drug1": {"morgan_fingerprint": np.array([1.0, 0.0, 1.0], dtype=np.float32)}, + "drug2": {"morgan_fingerprint": np.array([0.0, 1.0, 0.0], dtype=np.float32)}, + }, + meta_info={"morgan_fingerprint": ["fp1", "fp2", "fp3"]}, + ) + + +def test_cell_line_concat_fit_transform_and_blocks() -> None: + featurizer = CellLineConcatFeaturizer( + featurizers=[ + FeaturizerConfig(name="raw", view="gene_expression", registry="cell_line"), + FeaturizerConfig(name="raw", view="mutations", registry="cell_line"), + ], + ) + features = _cell_line_features() + ids = np.array(["cl1", "cl2"]) + featurizer.fit(features, entity_ids=ids) + + matrix = featurizer.transform(features, ids) + blocks = featurizer.transform_blocks(features, ids) + + assert matrix.shape == (2, 4) + assert set(blocks) == {"gene_expression", "mutations"} + assert all(isinstance(block, FeatureBlock) for block in blocks.values()) + assert blocks["gene_expression"].feature_names == ("g1", "g2") + np.testing.assert_allclose( + matrix, + np.concatenate([blocks["gene_expression"].values, blocks["mutations"].values], axis=1), + ) + + +def test_drug_concat_fit_transform_and_blocks() -> None: + featurizer = DrugConcatFeaturizer( + featurizers=[ + FeaturizerConfig(name="fingerprints", registry="drug"), + FeaturizerConfig(name="identity", registry="drug"), + ], + ) + features = _drug_features() + ids = np.array(["drug1", "drug2"]) + featurizer.fit(features, entity_ids=ids) + + matrix = featurizer.transform(features, ids) + blocks = featurizer.transform_blocks(features, ids) + + assert matrix.shape == (2, 5) + assert set(blocks) == {"fingerprints", "identity", "identity_categories"} + np.testing.assert_allclose( + matrix, + np.concatenate([blocks["fingerprints"].values, blocks["identity"].values], axis=1), + ) + + +def test_concat_uses_canonical_block_names_for_same_name_different_views() -> None: + featurizer = CellLineConcatFeaturizer( + featurizers=[ + CellLineFeaturizerConfig(name="pca", view="gene_expression", options={"n_components": 1}), + CellLineFeaturizerConfig(name="pca", view="proteomics", options={"n_components": 1}), + ], + ) + features = _multi_view_features() + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + + blocks = featurizer.transform_blocks(features, entity_ids) + + assert set(blocks) == {"gene_expression", "proteomics"} + assert featurizer.block_dims == {"pca[gene_expression]": 1, "pca[proteomics]": 1} + assert featurizer.transform(features, entity_ids).shape == (2, 2) + + +def test_concat_rejects_duplicate_emitted_block_names() -> None: + featurizer = CellLineConcatFeaturizer( + featurizers=[ + FeaturizerConfig(name="raw", view="gene_expression", registry="cell_line"), + FeaturizerConfig(name="scaledGeneExpression", registry="cell_line"), + ], + ) + features = _cell_line_features() + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + + with pytest.raises(ValueError, match="Duplicate featurizer block name 'gene_expression'"): + featurizer.transform_blocks(features, entity_ids) + + +def test_concat_duplicate_same_name_view_raises() -> None: + with pytest.raises(ValidationError, match="Duplicate featurizer selector 'raw\\[gene_expression\\]'"): + CellLineConcatFeaturizer( + featurizers=[ + FeaturizerConfig(name="raw", view="gene_expression", registry="cell_line"), + FeaturizerConfig(name="raw", view="gene_expression", registry="cell_line"), + ], + ) + + +def test_concat_rejects_non_numeric_children() -> None: + featurizer = DrugConcatFeaturizer( + featurizers=[ + FeaturizerConfig(name="fingerprints", registry="drug"), + FeaturizerConfig(name="drugGraph", registry="drug"), + ], + ) + + with pytest.raises(ValueError, match="only numeric_matrix children are supported"): + featurizer.fit(_drug_features(), entity_ids=np.array(["drug1"], dtype=str)) + + +def test_concat_registers_one_class_per_side() -> None: + assert get_cell_line_featurizer("concatFeaturizers") is CellLineConcatFeaturizer + assert get_drug_featurizer("concatFeaturizers") is DrugConcatFeaturizer + assert CellLineConcatFeaturizer is not DrugConcatFeaturizer + + +def test_concat_side_is_stamped_per_binding() -> None: + assert CellLineConcatFeaturizer.side == "cell_line" + assert DrugConcatFeaturizer.side == "drug" + assert issubclass(CellLineConcatFeaturizer, SharedConcatFeaturizer) + + +def test_concat_resolves_children_against_its_own_side_registry() -> None: + featurizer = DrugConcatFeaturizer(featurizers=[FeaturizerConfig(name="identity", registry="drug")]) + + assert featurizer._registry == "drug" diff --git a/tests/components/featurizers/shared/test_constant.py b/tests/components/featurizers/shared/test_constant.py new file mode 100644 index 000000000..e919d0212 --- /dev/null +++ b/tests/components/featurizers/shared/test_constant.py @@ -0,0 +1,67 @@ +"""Tests for the shared constant (intercept) featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.shared.constant`. The transform logic +itself lives in ``ConstantFeaturizerMixin`` and is covered in +``tests/components/featurizers/test_constant.py``; this file pins the two side +bindings and their registrations. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers._constant import ConstantFeaturizerMixin +from drevalpy.components.featurizers.shared.constant import ( + CellLineConstantFeaturizer, + DrugConstantFeaturizer, + SharedConstantFeaturizer, +) +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.cell_line_featurizer import metadata as cell_line_metadata +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from drevalpy.registry.drug_featurizer import metadata as drug_metadata +from tests.conftest import MockFeatureSource + +_SIDE_CLASSES = [ + pytest.param(CellLineConstantFeaturizer, id="cell-line"), + pytest.param(DrugConstantFeaturizer, id="drug"), +] + + +@pytest.mark.parametrize("featurizer_cls", _SIDE_CLASSES) +def test_constant_is_ones_column(featurizer_cls: type) -> None: + featurizer = featurizer_cls() + features = MockFeatureSource(features={}) + entity_ids = np.array(["e1", "e2", "e3"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + + matrix = featurizer.transform(features, entity_ids) + + assert matrix.shape == (3, 1) + assert matrix.dtype == np.float32 + assert np.allclose(matrix, 1.0) + assert featurizer.output_dim == 1 + + +@pytest.mark.parametrize("featurizer_cls", _SIDE_CLASSES) +def test_constant_uses_the_shared_mixin(featurizer_cls: type) -> None: + assert issubclass(featurizer_cls, ConstantFeaturizerMixin) + assert issubclass(featurizer_cls, SharedConstantFeaturizer) + assert featurizer_cls.entity_id_only is True + + +def test_constant_registers_one_class_per_side() -> None: + assert get_cell_line_featurizer("constant") is CellLineConstantFeaturizer + assert get_drug_featurizer("constant") is DrugConstantFeaturizer + assert CellLineConstantFeaturizer is not DrugConstantFeaturizer + + +def test_constant_side_is_stamped_per_binding() -> None: + assert CellLineConstantFeaturizer.side == "cell_line" + assert DrugConstantFeaturizer.side == "drug" + + +def test_constant_descriptions_are_worded_per_side() -> None: + assert "cell-line" in cell_line_metadata("constant")["description"] + assert "drug" in drug_metadata("constant")["description"] diff --git a/tests/components/featurizers/shared/test_identity.py b/tests/components/featurizers/shared/test_identity.py new file mode 100644 index 000000000..f469ddb1f --- /dev/null +++ b/tests/components/featurizers/shared/test_identity.py @@ -0,0 +1,88 @@ +"""Tests for the shared one-hot identity featurizer. + +Mirrors :mod:`drevalpy.components.featurizers.shared.identity`. One implementation +is bound to both entity sides by ``register_for_sides``, so the behavioural tests +are parameterized over the two generated classes and the registration assertions +check that each side got its own class with the right ``side`` stamped on. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.shared.identity import ( + CellLineIdentityFeaturizer, + DrugIdentityFeaturizer, + SharedIdentityFeaturizer, +) +from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer +from drevalpy.registry.drug_featurizer import get as get_drug_featurizer +from tests.conftest import MockFeatureSource + +_SIDE_CLASSES = [ + pytest.param(CellLineIdentityFeaturizer, id="cell-line"), + pytest.param(DrugIdentityFeaturizer, id="drug"), +] + + +@pytest.mark.parametrize("featurizer_cls", _SIDE_CLASSES) +def test_identity_one_hot(featurizer_cls: type) -> None: + featurizer = featurizer_cls() + features = MockFeatureSource(features={}) + entity_ids = np.array(["e1", "e2", "e1"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + + matrix = featurizer.transform(features, entity_ids) + + assert matrix.shape == (3, 2) + assert matrix.dtype == np.float32 + assert matrix[0, 0] == 1.0 + assert matrix[1, 1] == 1.0 + assert matrix[2, 0] == 1.0 + + +@pytest.mark.parametrize("featurizer_cls", _SIDE_CLASSES) +def test_identity_emits_category_metadata_block(featurizer_cls: type) -> None: + featurizer = featurizer_cls() + features = MockFeatureSource(features={}) + entity_ids = np.array(["e2", "e1"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + + blocks = featurizer.transform_blocks(features, entity_ids) + + assert set(blocks) == {"identity", "identity_categories"} + assert list(blocks["identity_categories"].values) == ["e1", "e2"] + + +@pytest.mark.parametrize("featurizer_cls", _SIDE_CLASSES) +def test_identity_round_trips_state(featurizer_cls: type) -> None: + features = MockFeatureSource(features={}) + entity_ids = np.array(["e1", "e2"], dtype=str) + featurizer = featurizer_cls().fit(features, entity_ids=entity_ids) + + restored = featurizer_cls() + restored.set_state(featurizer.get_state()) + + assert restored.output_dim == 2 + np.testing.assert_allclose( + restored.transform(features, entity_ids), + featurizer.transform(features, entity_ids), + ) + + +def test_identity_registers_one_class_per_side() -> None: + assert get_cell_line_featurizer("identity") is CellLineIdentityFeaturizer + assert get_drug_featurizer("identity") is DrugIdentityFeaturizer + assert CellLineIdentityFeaturizer is not DrugIdentityFeaturizer + + +def test_identity_side_is_stamped_per_binding() -> None: + assert CellLineIdentityFeaturizer.side == "cell_line" + assert DrugIdentityFeaturizer.side == "drug" + + +def test_identity_bindings_derive_from_the_shared_implementation() -> None: + assert issubclass(CellLineIdentityFeaturizer, SharedIdentityFeaturizer) + assert issubclass(DrugIdentityFeaturizer, SharedIdentityFeaturizer) + assert SharedIdentityFeaturizer.side == "" diff --git a/tests/components/featurizers/test_base.py b/tests/components/featurizers/test_base.py new file mode 100644 index 000000000..5153b32f3 --- /dev/null +++ b/tests/components/featurizers/test_base.py @@ -0,0 +1,495 @@ +"""Tests for the ``Featurizer`` base class contract and the registry sweeps over it. + +Mirrors :mod:`drevalpy.components.featurizers.base`, which is where the public +NaN-safe ``fit`` / ``transform`` / ``transform_blocks`` wrappers, the abstract +subclass hooks they call, and the default hyperparameter-space and fitted-state +hooks live. The registry sweeps assert class-body declarations across every +registered featurizer, so they belong to this module rather than to any single +concrete featurizer. + +Two neighbours cover what ``base.py`` delegates to: ``test_declarations.py`` for +the ``contract`` / ``input_views`` / output-block declarations and +``test_nan_tolerance.py`` for the ``_detect_valid`` / ``_expand_blocks_with_nan`` +/ ``_warn_if_above_threshold`` policy the wrappers below bracket their hooks with. +The HPO-space and fitted-state hooks now come from ``TunableComponentMixin``, +whose own contract is pinned in ``tests/components/contracts/test_hyperparameter_space.py``; +what the cases here assert is that a ``Featurizer`` inherits them. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat, featurizer_contract +from drevalpy.components.featurizers._concat import ConcatFeaturizersMixin +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry.cell_line_featurizer import ( + get as get_cell_line_featurizer, +) +from drevalpy.registry.cell_line_featurizer import ( + list as list_cell_line_featurizers, +) +from drevalpy.registry.drug_featurizer import ( + get as get_drug_featurizer, +) +from drevalpy.registry.drug_featurizer import ( + list as list_drug_featurizers, +) +from drevalpy.types.data.batch.feature_block import ( + FeatureBlock, + metadata_feature_block, + numeric_feature_block, +) +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.modalities import resolve_omics_accessor +from tests.components.featurizers._helpers import DoublingFeaturizer, StubSource + +_PROBE_VIEW = "gene_expression" + +#: The NaN warning is emitted by the mixin ``base.py`` delegates the policy to. +_NAN_LOGGER = "drevalpy.components.featurizers._nan_tolerance" + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def _featurizer_names(registry: str) -> list[str]: + register_builtin_components() + return list_cell_line_featurizers() if registry == "cell_line" else list_drug_featurizers() + + +@pytest.mark.parametrize("registry", ["cell_line", "drug"]) +def test_every_featurizer_declares_input_views(registry: str) -> None: + get = get_cell_line_featurizer if registry == "cell_line" else get_drug_featurizer + names = _featurizer_names(registry) + assert names + for name in names: + cls = get(name) + if issubclass(cls, ConcatFeaturizersMixin): + continue + kwargs = {"view": _PROBE_VIEW} if cls.requires_view else {} + views = cls.resolve_input_views(**kwargs) + assert isinstance(views, tuple), name + assert all(isinstance(view, str) and view.strip() for view in views), name + + +@pytest.mark.parametrize("registry", ["cell_line", "drug"]) +def test_entity_id_only_featurizers_need_no_views(registry: str) -> None: + get = get_cell_line_featurizer if registry == "cell_line" else get_drug_featurizer + entity_id_only = [name for name in _featurizer_names(registry) if get(name).entity_id_only] + assert entity_id_only + for name in entity_id_only: + assert get(name).resolve_input_views() == (), name + + +@pytest.mark.parametrize("registry", ["cell_line", "drug"]) +def test_concat_featurizer_refuses_standalone_view_resolution(registry: str) -> None: + get = get_cell_line_featurizer if registry == "cell_line" else get_drug_featurizer + cls = get("concatFeaturizers") + with pytest.raises(TypeError, match="has no input views of its own"): + cls.resolve_input_views() + + +@pytest.mark.parametrize("name", ["raw", "pca"]) +def test_view_parameterized_featurizers_require_an_explicit_view(name: str) -> None: + cls = get_cell_line_featurizer(name) + assert cls.requires_view + assert cls.resolve_input_views(view="mutations") == ("mutations",) + with pytest.raises(TypeError, match="requires an explicit view"): + cls.resolve_input_views() + + +_CELL_LINE_CONTRACTS = [ + ("landmarkGenes", FeatureFormat.NUMERIC_MATRIX), + ("landmarkGenesReduced", FeatureFormat.NUMERIC_MATRIX), + ("pathways", FeatureFormat.NUMERIC_MATRIX), + ("bionic", FeatureFormat.NUMERIC_MATRIX), + ("dipkGeneExpression", FeatureFormat.NUMERIC_MATRIX), + ("pharmaFormerGeneExpression", FeatureFormat.NUMERIC_MATRIX), + ("sparsegoOntology", FeatureFormat.NUMERIC_MATRIX), + ("molirOmics", FeatureFormat.NUMERIC_MATRIX), + ("superfeltrOmics", FeatureFormat.NUMERIC_MATRIX), + ("concatFeaturizers", FeatureFormat.NUMERIC_MATRIX), + ("raw", FeatureFormat.NUMERIC_MATRIX), + ("pca", FeatureFormat.NUMERIC_MATRIX), +] + +_DRUG_CONTRACTS = [ + ("molgnet", FeatureFormat.RAGGED_SEQUENCE), + ("bpePharmaformer", FeatureFormat.NUMERIC_MATRIX), + ("smilesvec", FeatureFormat.NUMERIC_MATRIX), + ("drugGraph", FeatureFormat.GRAPH), +] + + +@pytest.mark.parametrize(("name", "expected_format"), _CELL_LINE_CONTRACTS) +def test_cell_line_literature_featurizer_contracts( + name: str, + expected_format: FeatureFormat, +) -> None: + cls = get_cell_line_featurizer(name) + contract = featurizer_contract(cls) + assert isinstance(contract, FeatureContract) + assert contract.format == expected_format + + +@pytest.mark.parametrize(("name", "expected_format"), _DRUG_CONTRACTS) +def test_drug_literature_featurizer_contracts( + name: str, + expected_format: FeatureFormat, +) -> None: + cls = get_drug_featurizer(name) + contract = featurizer_contract(cls) + assert isinstance(contract, FeatureContract) + assert contract.format == expected_format + + +#: Featurizers whose declared source views the synthetic fixture deliberately +#: does not carry, mapped to why. ``bionic`` reaches for an S3 artifact CI cannot +#: download, and ``sparsegoOntology`` declares no source views at all. +_NO_FIXTURE_SOURCE = {"bionic", "sparsegoOntology"} + + +@pytest.mark.parametrize( + ("name", "getter"), + [(name, get_cell_line_featurizer) for name, _ in _CELL_LINE_CONTRACTS] + + [(name, get_drug_featurizer) for name, _ in _DRUG_CONTRACTS], +) +def test_literature_featurizer_source_views_exist_in_the_fixture( + name: str, + getter: Callable[[str], type], + synthetic_dataset: Dataset, +) -> None: + """Every literature featurizer's raw inputs are present in the synthetic fixture. + + Views are resolved through :data:`OMICS_ACCESSORS` before being looked up, + because the fixture stores omics under the accessor the published datasets + use. That resolution is exactly what the library's own read sites still do + not do, which is why ``molirOmics`` and ``superfeltrOmics`` work here yet + their models are xfailed in the model tests: the data is present, the lookup + is what asks for the wrong name. + + :param name: Featurizer registry name. + :param getter: Registry lookup for the featurizer's side. + :param synthetic_dataset: Session-scoped synthetic raw-omics dataset. + """ + if name in _NO_FIXTURE_SOURCE: + pytest.skip(f"{name} declares no source views the fixture can supply") + + cls = getter(name) + declared = tuple(cls.source_views or ()) or tuple(cls.input_views or ()) + if not declared: + pytest.skip(f"{name} declares neither source_views nor input_views") + + missing = [ + view + for view in declared + if view != "canonical_smiles" and not synthetic_dataset._has_required_views((resolve_omics_accessor(view),)) + ] + assert not missing, f"{name} reads {missing}, which the synthetic fixture does not provide" + + +# ---------------------------------------------------------------------- +# NaN tolerance in fit / transform / transform_blocks +# ---------------------------------------------------------------------- + + +@pytest.fixture +def mixed_source() -> tuple[StubSource, np.ndarray]: + """Source with 5 entities where the first and last rows are all-NaN.""" + ids = np.array(["A", "B", "C", "D", "E"]) + matrix = np.array( + [ + [np.nan, np.nan, np.nan], + [1.0, 2.0, 3.0], + [4.0, 5.0, 6.0], + [7.0, 8.0, 9.0], + [np.nan, np.nan, np.nan], + ], + dtype=np.float32, + ) + return StubSource(matrix, ids), ids + + +@pytest.fixture +def all_nan_source() -> tuple[StubSource, np.ndarray]: + """Source where every entity row is all-NaN.""" + ids = np.array(["X", "Y", "Z"]) + matrix = np.full((3, 3), np.nan, dtype=np.float32) + return StubSource(matrix, ids), ids + + +@pytest.fixture +def all_valid_source() -> tuple[StubSource, np.ndarray]: + """Source where every entity row is valid.""" + ids = np.array(["A", "B", "C"]) + matrix = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=np.float32) + return StubSource(matrix, ids), ids + + +class TestTransformNaNTolerance: + """Tests for the transform() NaN tolerance wrapper.""" + + def test_all_valid_passes_through(self, all_valid_source): + source, ids = all_valid_source + feat = DoublingFeaturizer() + feat.fit(source, entity_ids=ids) + result = feat.transform(source, ids) + expected = np.array([[2, 4, 6], [8, 10, 12], [14, 16, 18]], dtype=np.float32) + np.testing.assert_array_almost_equal(result, expected) + + def test_mixed_valid_invalid(self, mixed_source): + source, ids = mixed_source + feat = DoublingFeaturizer() + feat.fit(source, entity_ids=ids) + result = feat.transform(source, ids) + assert result.shape == (5, 3) + assert np.all(np.isnan(result[0])) + assert np.all(np.isnan(result[4])) + np.testing.assert_array_almost_equal(result[1], [2, 4, 6]) + np.testing.assert_array_almost_equal(result[2], [8, 10, 12]) + np.testing.assert_array_almost_equal(result[3], [14, 16, 18]) + + def test_all_nan_produces_nan_output(self, all_nan_source): + source, ids = all_nan_source + feat = DoublingFeaturizer() + feat.fit(source, entity_ids=ids) + result = feat.transform(source, ids) + assert result.shape[0] == 3 + assert np.all(np.isnan(result)) + + +class TestTransformBlocksNaNTolerance: + """Tests for the transform_blocks() NaN tolerance wrapper.""" + + def test_all_valid_passes_through(self, all_valid_source): + source, ids = all_valid_source + feat = DoublingFeaturizer() + feat.fit(source, entity_ids=ids) + blocks = feat.transform_blocks(source, ids) + assert "test_view" in blocks + expected = np.array([[2, 4, 6], [8, 10, 12], [14, 16, 18]], dtype=np.float32) + np.testing.assert_array_almost_equal(blocks["test_view"].values, expected) + + def test_mixed_valid_invalid(self, mixed_source): + source, ids = mixed_source + feat = DoublingFeaturizer() + feat.fit(source, entity_ids=ids) + blocks = feat.transform_blocks(source, ids) + values = blocks["test_view"].values + assert values.shape == (5, 3) + assert np.all(np.isnan(values[0])) + assert np.all(np.isnan(values[4])) + np.testing.assert_array_almost_equal(values[1], [2, 4, 6]) + np.testing.assert_array_almost_equal(values[2], [8, 10, 12]) + np.testing.assert_array_almost_equal(values[3], [14, 16, 18]) + + def test_all_nan_produces_nan_blocks(self, all_nan_source): + source, ids = all_nan_source + feat = DoublingFeaturizer() + feat.fit(source, entity_ids=ids) + blocks = feat.transform_blocks(source, ids) + values = blocks["test_view"].values + assert values.shape[0] == 3 + assert np.all(np.isnan(values)) + + +class TestNaNWarning: + """The wrappers pass their own context label into the shared warning.""" + + def test_warning_above_threshold(self, mixed_source, caplog, monkeypatch): + source, ids = mixed_source + monkeypatch.setattr(DoublingFeaturizer, "nan_threshold", 0.2) + feat = DoublingFeaturizer() + feat.fit(source, entity_ids=ids) + with caplog.at_level(logging.WARNING, logger=_NAN_LOGGER): + feat.transform(source, ids) + assert any("transform" in record.message for record in caplog.records) + + def test_no_warning_below_threshold(self, mixed_source, caplog, monkeypatch): + source, ids = mixed_source + monkeypatch.setattr(DoublingFeaturizer, "nan_threshold", 0.5) + feat = DoublingFeaturizer() + feat.fit(source, entity_ids=ids) + with caplog.at_level(logging.WARNING, logger=_NAN_LOGGER): + feat.transform(source, ids) + nan_warnings = [r for r in caplog.records if "invalid" in r.message.lower()] + assert not nan_warnings + + +class TestConsistency: + """Verify transform and transform_blocks produce consistent NaN handling.""" + + def test_transform_and_blocks_agree(self, mixed_source): + source, ids = mixed_source + feat = DoublingFeaturizer() + feat.fit(source, entity_ids=ids) + matrix = feat.transform(source, ids) + blocks = feat.transform_blocks(source, ids) + block_values = blocks["test_view"].values + np.testing.assert_array_equal( + np.isnan(matrix), + np.isnan(block_values), + ) + valid_mask = ~np.isnan(matrix).all(axis=1) + np.testing.assert_array_almost_equal(matrix[valid_mask], block_values[valid_mask]) + + +# ---------------------------------------------------------------------- +# The abstract contract's own default hooks +# ---------------------------------------------------------------------- + + +def test_default_state_hooks_are_no_ops() -> None: + feat = DoublingFeaturizer() + + assert feat.get_state() == {} + assert feat.set_state({"anything": 1}) is None + + +def test_default_hyperparameter_space_is_empty() -> None: + assert DoublingFeaturizer.get_hyperparameter_space() == {} + assert DoublingFeaturizer.get_default_hyperparameters() == {} + + +def test_default_transform_concatenates_numeric_blocks_only() -> None: + class _MixedBlocks(Featurizer): + input_views = ("test_view",) + + def _fit(self, source, *, entity_ids=None, pair_expanded_ids=None, pair_expanded_es_ids=None): + return self + + def _transform_blocks(self, source, entity_ids) -> dict[str, FeatureBlock]: + return { + "numeric": numeric_feature_block(np.ones((len(entity_ids), 2), dtype=np.float32)), + "categories": metadata_feature_block(np.asarray(["a"], dtype=str)), + } + + @property + def output_dim(self) -> int: + return 2 + + _MixedBlocks.contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + source = StubSource(np.zeros((2, 3), dtype=np.float32), np.array(["A", "B"])) + + matrix = _MixedBlocks()._transform(source, np.array(["A", "B"])) + + assert matrix.shape == (2, 2) + + +def test_default_transform_returns_an_empty_matrix_without_numeric_blocks() -> None: + class _NoNumericBlocks(Featurizer): + entity_id_only = True + + def _fit(self, source, *, entity_ids=None, pair_expanded_ids=None, pair_expanded_es_ids=None): + return self + + def _transform_blocks(self, source, entity_ids) -> dict[str, FeatureBlock]: + return {"categories": metadata_feature_block(np.asarray(["a"], dtype=str))} + + @property + def output_dim(self) -> int: + return 0 + + _NoNumericBlocks.contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + matrix = _NoNumericBlocks()._transform(StubSource(np.zeros((1, 1)), np.array(["A"])), np.array(["A"])) + + assert matrix.shape == (1, 0) + + +# ---------------------------------------------------------------------- +# store / fetch / list_stored_variants against a real MuData +# ---------------------------------------------------------------------- + + +class _StoredCellLine(DoublingFeaturizer): + """Cell-line-side featurizer with a storage key, for variant round-trips.""" + + storage_key = "stored_cell_line" + side = "cell_line" + + +class _StoredDrug(DoublingFeaturizer): + """Drug-side featurizer with a storage key, for variant round-trips.""" + + storage_key = "stored_drug" + side = "drug" + + +def test_fetch_returns_none_when_no_variant_matches() -> None: + from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + dataset = synthetic_mudataset_gene_expression_fingerprints() + + assert _StoredCellLine().fetch(dataset.mdata, dataset.cell_line_ids) is None + + +def test_store_then_fetch_round_trips_a_cell_line_variant() -> None: + from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + dataset = synthetic_mudataset_gene_expression_fingerprints() + featurizer = _StoredCellLine() + payload = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + + featurizer.store(dataset.mdata, dataset.cell_line_ids, payload, {"scale": 2}) + + assert list(_StoredCellLine.list_stored_variants(dataset.mdata)) == ["stored_cell_line_0"] + np.testing.assert_allclose( + featurizer.fetch(dataset.mdata, dataset.cell_line_ids, {"scale": 2}), + payload, + ) + + +def test_store_then_fetch_round_trips_a_drug_variant() -> None: + from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + dataset = synthetic_mudataset_gene_expression_fingerprints() + featurizer = _StoredDrug() + payload = np.array([[5.0], [6.0]], dtype=np.float32) + + featurizer.store(dataset.mdata, dataset.drug_ids, payload) + + np.testing.assert_allclose(featurizer.fetch(dataset.mdata, dataset.drug_ids), payload) + + +def test_store_allocates_a_fresh_index_per_hyperparameter_setting() -> None: + from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + dataset = synthetic_mudataset_gene_expression_fingerprints() + featurizer = _StoredCellLine() + payload = np.zeros((2, 2), dtype=np.float32) + + featurizer.store(dataset.mdata, dataset.cell_line_ids, payload, {"scale": 2}) + featurizer.store(dataset.mdata, dataset.cell_line_ids, payload, {"scale": 4}) + + assert list(_StoredCellLine.list_stored_variants(dataset.mdata)) == [ + "stored_cell_line_0", + "stored_cell_line_1", + ] + + +def test_fetch_prefers_a_modality_over_obsm() -> None: + from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + dataset = synthetic_mudataset_gene_expression_fingerprints() + + class _ModalityBacked(DoublingFeaturizer): + storage_key = "gene_expression" + side = "cell_line" + + from drevalpy.components.featurizers.storage import register_variant + + register_variant(dataset.mdata, "gene_expression", "gene_expression", None, side="cell_line") + + matrix = _ModalityBacked().fetch(dataset.mdata, dataset.cell_line_ids) + + assert matrix is not None + assert matrix.shape == (2, 3) diff --git a/tests/components/featurizers/test_concat.py b/tests/components/featurizers/test_concat.py new file mode 100644 index 000000000..d34d3537a --- /dev/null +++ b/tests/components/featurizers/test_concat.py @@ -0,0 +1,124 @@ +"""Tests for the shared concat featurizer mixin. + +Mirrors :mod:`drevalpy.components.featurizers._concat`. The two registered +wrappers around this mixin have their own tests in ``cell_line/test_concat.py`` +and ``drug/test_concat.py``; this file exercises the mixin's own guards and its +state round-trip. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers._concat import ConcatFeaturizersMixin +from drevalpy.components.featurizers.cell_line.raw import RawCellLineFeaturizer +from drevalpy.components.featurizers.drug.drug_graph import DrugGraphFeaturizer +from drevalpy.components.featurizers.shared.concat import CellLineConcatFeaturizer +from drevalpy.models.config import CellLineFeaturizerConfig +from tests.conftest import MockFeatureSource + + +def _cell_line_features() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": { + "gene_expression": np.array([1.0, 2.0], dtype=np.float32), + "mutations": np.array([0.0, 1.0], dtype=np.float32), + }, + "cl2": { + "gene_expression": np.array([3.0, 4.0], dtype=np.float32), + "mutations": np.array([1.0, 0.0], dtype=np.float32), + }, + } + ) + + +def test_concat_rejects_non_numeric_children() -> None: + mixin = ConcatFeaturizersMixin.__new__(ConcatFeaturizersMixin) + mixin._children = [("drugGraph", DrugGraphFeaturizer())] + + with pytest.raises(ValueError, match="only numeric_matrix"): + mixin._reject_non_numeric_children(mixin._children) + + +def test_concat_rejects_an_empty_featurizer_list() -> None: + with pytest.raises(ValueError, match="non-empty list"): + CellLineConcatFeaturizer(featurizers=[]) + + +def test_concat_accepts_pre_built_featurizer_instances() -> None: + featurizer = CellLineConcatFeaturizer( + featurizers=[ + RawCellLineFeaturizer(view="gene_expression"), + RawCellLineFeaturizer(view="mutations"), + ], + ) + features = _cell_line_features() + ids = np.array(["cl1", "cl2"], dtype=str) + + featurizer.fit(features, entity_ids=ids) + + assert featurizer.output_dim == 4 + assert set(featurizer.block_dims) == {"raw[gene_expression]", "raw[mutations]"} + + +def test_concat_transform_before_fit_raises() -> None: + featurizer = CellLineConcatFeaturizer( + featurizers=[CellLineFeaturizerConfig(name="raw", view="gene_expression")], + ) + + with pytest.raises(RuntimeError, match="must be fit before transform"): + featurizer.transform_blocks(_cell_line_features(), np.array(["cl1"], dtype=str)) + + +def test_concat_round_trips_state_into_a_fresh_instance() -> None: + features = _cell_line_features() + ids = np.array(["cl1", "cl2"], dtype=str) + featurizer = CellLineConcatFeaturizer( + featurizers=[ + CellLineFeaturizerConfig(name="raw", view="gene_expression"), + CellLineFeaturizerConfig(name="raw", view="mutations"), + ], + ).fit(features, entity_ids=ids) + + restored = CellLineConcatFeaturizer( + featurizers=[ + CellLineFeaturizerConfig(name="raw", view="gene_expression"), + CellLineFeaturizerConfig(name="raw", view="mutations"), + ], + ) + restored.set_state(featurizer.get_state()) + + assert restored.output_dim == featurizer.output_dim + assert restored.block_dims == featurizer.block_dims + np.testing.assert_allclose( + restored.transform(features, ids), + featurizer.transform(features, ids), + ) + + +def test_concat_set_state_ignores_unrelated_keys() -> None: + featurizer = CellLineConcatFeaturizer( + featurizers=[CellLineFeaturizerConfig(name="raw", view="gene_expression")], + ) + + featurizer.set_state({"child_states": "not-a-dict", "block_dims": None, "output_dim": "seven"}) + + assert featurizer.output_dim == 0 + assert featurizer.block_dims == {} + + +def test_concat_mixin_refuses_standalone_view_resolution() -> None: + with pytest.raises(TypeError, match="has no input views of its own"): + CellLineConcatFeaturizer.resolve_input_views() + + +def test_materialize_children_is_a_no_op_without_children_or_configs() -> None: + mixin = ConcatFeaturizersMixin.__new__(ConcatFeaturizersMixin) + mixin._children = [] + mixin._child_configs = [] + + mixin._materialize_children() + + assert mixin._children == [] diff --git a/tests/components/featurizers/test_constant.py b/tests/components/featurizers/test_constant.py new file mode 100644 index 000000000..d2982f422 --- /dev/null +++ b/tests/components/featurizers/test_constant.py @@ -0,0 +1,68 @@ +"""Tests for the shared constant (intercept) featurizer mixin. + +Mirrors :mod:`drevalpy.components.featurizers._constant`. The two registered +wrappers are covered in ``cell_line/test_constant.py`` and +``drug/test_constant.py``; this file pins the mixin's own behaviour. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers._constant import ConstantFeaturizerMixin +from drevalpy.components.featurizers.shared.constant import CellLineConstantFeaturizer, DrugConstantFeaturizer +from tests.conftest import MockFeatureSource + + +@pytest.mark.parametrize( + "featurizer_cls", + [ + pytest.param(CellLineConstantFeaturizer, id="cell-line"), + pytest.param(DrugConstantFeaturizer, id="drug"), + ], +) +def test_constant_featurizers_use_the_shared_mixin(featurizer_cls: type) -> None: + assert issubclass(featurizer_cls, ConstantFeaturizerMixin) + assert featurizer_cls.entity_id_only is True + + +def test_constant_needs_no_source_views() -> None: + featurizer = CellLineConstantFeaturizer() + entity_ids = np.array(["cl1", "cl2"], dtype=str) + + featurizer.fit(MockFeatureSource(features={}), entity_ids=entity_ids) + + assert featurizer.output_dim == 1 + + +def test_constant_output_dim_is_one_before_fit() -> None: + assert CellLineConstantFeaturizer().output_dim == 1 + + +def test_constant_state_round_trip_is_a_no_op() -> None: + featurizer = CellLineConstantFeaturizer() + features = MockFeatureSource(features={}) + entity_ids = np.array(["cl1", "cl2"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + + restored = CellLineConstantFeaturizer() + restored.set_state(featurizer.get_state()) + + assert featurizer.get_state() == {} + np.testing.assert_allclose( + restored.transform(features, entity_ids), + featurizer.transform(features, entity_ids), + ) + + +def test_constant_blocks_are_named_constant() -> None: + featurizer = DrugConstantFeaturizer() + features = MockFeatureSource(features={}) + entity_ids = np.array(["d1", "d2", "d3"], dtype=str) + featurizer.fit(features, entity_ids=entity_ids) + + blocks = featurizer.transform_blocks(features, entity_ids) + + assert list(blocks) == ["constant"] + np.testing.assert_allclose(blocks["constant"].values, np.ones((3, 1), dtype=np.float32)) diff --git a/tests/components/featurizers/test_declarations.py b/tests/components/featurizers/test_declarations.py new file mode 100644 index 000000000..e490aa25a --- /dev/null +++ b/tests/components/featurizers/test_declarations.py @@ -0,0 +1,133 @@ +"""Tests for the featurizer class-body declarations. + +Mirrors :mod:`drevalpy.components.featurizers._declarations`, which holds what a +featurizer declares rather than what it computes: the ``contract`` normalization +that runs at class creation, the ``resolve_input_views`` hook the model config +calls to know what to load from disk, and the ``output_block_specs_for_config`` +hook ``models/config/_block_specs.py`` reads to predict an output shape. + +None of it needs an instance, so every case below asserts against a class. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.components.featurizers._declarations import FeaturizerDeclarationsMixin +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.types.data.batch.feature_block import FeatureBlock +from tests.components.featurizers._helpers import DoublingFeaturizer + + +class _Declared(Featurizer): + """Minimal concrete featurizer, for the class-level hooks.""" + + entity_id_only = True + + def _fit(self, source, *, entity_ids=None, pair_expanded_ids=None, pair_expanded_es_ids=None): + return self + + def _transform_blocks(self, source, entity_ids) -> dict[str, FeatureBlock]: + return {} + + @property + def output_dim(self) -> int: + return 0 + + +_Declared.contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + +def test_the_mixin_is_part_of_the_featurizer_base() -> None: + """The declarations are reached through ``Featurizer``, not wired in per subclass.""" + assert issubclass(DoublingFeaturizer, FeaturizerDeclarationsMixin) + + +class TestContractNormalization: + """``__init_subclass__`` widens a class-body declaration at class creation.""" + + def test_a_class_body_contract_is_kept(self) -> None: + class _BodyContract(Featurizer): # noqa: B903 + contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + assert _BodyContract.contract == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + def test_a_format_shorthand_is_widened(self) -> None: + class _Shorthand(Featurizer): # noqa: B903 + contract = FeatureFormat.GRAPH + + assert _Shorthand.contract == FeatureContract(format=FeatureFormat.GRAPH) + + def test_an_invalid_declaration_is_rejected_at_class_creation(self) -> None: + with pytest.raises(TypeError, match="class-body contract is invalid"): + + class _Bad(Featurizer): # noqa: B903 + contract = "graph_but_a_plain_string" + + def test_a_subclass_declaring_nothing_inherits_the_contract(self) -> None: + """Registration supplies the contract for these; nothing is overwritten.""" + + class _Inheriting(DoublingFeaturizer): + pass + + assert _Inheriting.contract is DoublingFeaturizer.contract + + +class TestResolveInputViews: + """Which raw views the model config has to load from disk for this featurizer.""" + + def test_an_explicit_view_wins(self) -> None: + assert DoublingFeaturizer.resolve_input_views(view="mutations") == ("mutations",) + + def test_a_blank_view_falls_back_to_the_declaration(self) -> None: + assert DoublingFeaturizer.resolve_input_views(view=" ") == ("test_view",) + + def test_declared_input_views_are_used_without_kwargs(self) -> None: + assert DoublingFeaturizer.resolve_input_views() == ("test_view",) + + def test_an_entity_id_only_featurizer_needs_no_views(self) -> None: + assert _Declared.resolve_input_views() == () + + def test_a_view_parameterized_featurizer_insists_on_a_view(self) -> None: + class _NeedsView(_Declared): + entity_id_only = False + requires_view = True + + with pytest.raises(TypeError, match="requires an explicit view"): + _NeedsView.resolve_input_views() + + def test_a_featurizer_declaring_nothing_is_rejected(self) -> None: + class _Undeclared(_Declared): + entity_id_only = False + + with pytest.raises(TypeError, match="declare input_views on the class body"): + _Undeclared.resolve_input_views() + + +class TestOutputBlockSpecsForConfig: + """The block names and formats a featurizer will emit under a config node.""" + + def test_it_falls_back_to_the_declared_input_view(self) -> None: + class _Config: + view = None + + specs = DoublingFeaturizer.output_block_specs_for_config(_Config()) + + assert [spec.name for spec in specs] == ["test_view"] + + def test_an_explicit_config_view_wins(self) -> None: + class _Config: + view = "mutations" + + specs = DoublingFeaturizer.output_block_specs_for_config(_Config()) + + assert [spec.name for spec in specs] == ["mutations"] + + def test_it_carries_the_contract_format(self) -> None: + specs = DoublingFeaturizer.output_block_specs_for_config(None) + + assert [spec.format for spec in specs] == [FeatureFormat.NUMERIC_MATRIX] + + def test_it_is_empty_without_any_view(self) -> None: + assert _Declared.output_block_specs_for_config(None) == () diff --git a/tests/components/featurizers/test_dense_view.py b/tests/components/featurizers/test_dense_view.py new file mode 100644 index 000000000..a855783ed --- /dev/null +++ b/tests/components/featurizers/test_dense_view.py @@ -0,0 +1,304 @@ +"""Tests for the side-agnostic dense single-view featurizer base. + +Mirrors :mod:`drevalpy.components.featurizers._dense_view`. Both entity sides are +exercised through the same base, because the two per-side copies this replaced had +one implementation between them. The branches nothing else in the suite reaches are +the ``fetch`` hit (a pre-computed variant registered in the MuData) and the +``_compute_from_source`` fallback taken when the declared view is absent. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers.cell_line.base import DenseViewCellLineFeaturizer +from drevalpy.components.featurizers.drug.base import DenseViewDrugFeaturizer +from drevalpy.components.featurizers.storage import register_variant +from drevalpy.types.data.batch.feature_block import BlockSpec +from drevalpy.types.data.feature_source import CellLineFeatureSource, DrugFeatureSource +from tests.conftest import MockFeatureSource +from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + +_CELL_LINE_PRECOMPUTED = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) +_DRUG_PRECOMPUTED = np.array([[9.0, 8.0], [7.0, 6.0]], dtype=np.float32) + + +class _StoredDenseView(DenseViewCellLineFeaturizer): + """Dense view whose values were pre-computed into ``response.obsm``.""" + + storage_key = "stored_dense" + side = "cell_line" + input_views = ("gene_expression",) + + +class _ComputedDenseView(DenseViewCellLineFeaturizer): + """Dense view with an on-the-fly fallback and no stored matrix.""" + + precompute = True + input_views = ("bionic_features",) + + def _compute_from_source(self, source, entity_ids: np.ndarray) -> np.ndarray: + """Return a fixed-width matrix, standing in for a real computation.""" + return np.full((len(entity_ids), 4), 7.0, dtype=np.float32) + + +class _StrictDenseView(DenseViewCellLineFeaturizer): + """Dense view without a fallback, so a missing view must propagate.""" + + input_views = ("bionic_features",) + + +class _StoredDrugView(DenseViewDrugFeaturizer): + """Drug view whose values were pre-computed into ``response.varm``.""" + + storage_key = "stored_view" + side = "drug" + input_views = ("morgan_fingerprint",) + + +class _ComputedDrugView(DenseViewDrugFeaturizer): + """Drug view with an on-the-fly fallback and no stored matrix.""" + + precompute = True + input_views = ("morgan_fingerprint",) + + def _compute_from_source(self, source, entity_ids: np.ndarray) -> np.ndarray: + """Return a fixed-width matrix, standing in for a real computation.""" + return np.full((len(entity_ids), 3), 5.0, dtype=np.float32) + + +class _NamedBlockDrugView(DenseViewDrugFeaturizer): + """Drug view that declares its own output block name.""" + + input_views = ("morgan_fingerprint",) + output_block_specs = (BlockSpec("custom_block", None),) + + +def _cell_line_source() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([0.1, 0.2])}, + "cl2": {"gene_expression": np.array([0.3, 0.4])}, + }, + meta_info={"gene_expression": ["g1", "g2"]}, + ) + + +def _drug_source() -> MockFeatureSource: + return MockFeatureSource( + features={ + "d1": {"morgan_fingerprint": np.array([1.0, 0.0])}, + "d2": {"morgan_fingerprint": np.array([0.0, 1.0])}, + }, + meta_info={"morgan_fingerprint": ["fp1", "fp2"]}, + ) + + +@pytest.fixture +def stored_cell_line_source() -> CellLineFeatureSource: + """A dataset-backed source carrying a registered ``stored_dense`` variant.""" + dataset = synthetic_mudataset_gene_expression_fingerprints() + dataset.mdata.mod["response"].obsm["stored_dense_0"] = _CELL_LINE_PRECOMPUTED + register_variant(dataset.mdata, "stored_dense", "stored_dense_0", None, side="cell_line") + return CellLineFeatureSource(dataset, dataset.cell_line_ids) + + +@pytest.fixture +def stored_drug_source() -> DrugFeatureSource: + """A dataset-backed source carrying a registered ``stored_view`` variant.""" + dataset = synthetic_mudataset_gene_expression_fingerprints() + dataset.mdata.mod["response"].varm["stored_view_0"] = _DRUG_PRECOMPUTED + register_variant(dataset.mdata, "stored_view", "stored_view_0", None, side="drug") + return DrugFeatureSource(dataset, dataset.drug_ids) + + +def test_dense_view_passes_the_declared_view_through() -> None: + source = _cell_line_source() + ids = np.array(["cl1", "cl2"], dtype=str) + featurizer = _StrictDenseView(view="gene_expression").fit(source, entity_ids=ids) + + matrix = featurizer.transform(source, ids) + + assert featurizer.output_dim == 2 + np.testing.assert_allclose(matrix, [[0.1, 0.2], [0.3, 0.4]], rtol=1e-6) + + +def test_dense_view_passes_a_drug_view_through() -> None: + source = _drug_source() + ids = np.array(["d1", "d2"], dtype=str) + featurizer = _StoredDrugView().fit(source, entity_ids=ids) + + matrix = featurizer.transform(source, ids) + + assert featurizer.output_dim == 2 + np.testing.assert_allclose(matrix, [[1.0, 0.0], [0.0, 1.0]]) + + +def test_dense_view_defaults_to_the_single_declared_input_view() -> None: + assert _StoredDenseView()._view == "gene_expression" + assert _StoredDrugView()._view == "morgan_fingerprint" + + +def test_dense_view_names_its_block_after_the_view_and_carries_feature_names() -> None: + source = _cell_line_source() + ids = np.array(["cl1", "cl2"], dtype=str) + featurizer = _StrictDenseView(view="gene_expression").fit(source, entity_ids=ids) + + blocks = featurizer.transform_blocks(source, ids) + + assert set(blocks) == {"gene_expression"} + assert blocks["gene_expression"].feature_names == ("g1", "g2") + + +def test_dense_view_block_honours_declared_output_specs() -> None: + source = _drug_source() + ids = np.array(["d1", "d2"], dtype=str) + featurizer = _NamedBlockDrugView().fit(source, entity_ids=ids) + + assert set(featurizer.transform_blocks(source, ids)) == {"custom_block"} + + +def test_dense_view_fit_uses_a_precomputed_variant_when_one_is_registered( + stored_cell_line_source: CellLineFeatureSource, +) -> None: + featurizer = _StoredDenseView().fit(stored_cell_line_source, entity_ids=stored_cell_line_source.identifiers) + + assert featurizer.output_dim == _CELL_LINE_PRECOMPUTED.shape[1] + + +def test_dense_view_transform_returns_the_precomputed_variant( + stored_cell_line_source: CellLineFeatureSource, +) -> None: + featurizer = _StoredDenseView().fit(stored_cell_line_source, entity_ids=stored_cell_line_source.identifiers) + + matrix = featurizer.transform(stored_cell_line_source, stored_cell_line_source.identifiers) + + np.testing.assert_allclose(matrix, _CELL_LINE_PRECOMPUTED) + + +def test_dense_view_reads_a_drug_side_variant_from_varm(stored_drug_source: DrugFeatureSource) -> None: + featurizer = _StoredDrugView().fit(stored_drug_source, entity_ids=stored_drug_source.identifiers) + + matrix = featurizer.transform(stored_drug_source, stored_drug_source.identifiers) + + assert featurizer.output_dim == _DRUG_PRECOMPUTED.shape[1] + np.testing.assert_allclose(matrix, _DRUG_PRECOMPUTED) + + +def test_dense_view_fit_falls_back_to_computing_from_source() -> None: + featurizer = _ComputedDenseView().fit(_cell_line_source(), entity_ids=np.array(["cl1", "cl2"], dtype=str)) + + assert featurizer.output_dim == 4 + + +def test_dense_view_transform_falls_back_to_computing_from_source() -> None: + source = _cell_line_source() + ids = np.array(["cl1", "cl2"], dtype=str) + featurizer = _ComputedDenseView().fit(source, entity_ids=ids) + + np.testing.assert_allclose(featurizer.transform(source, ids), np.full((2, 4), 7.0, dtype=np.float32)) + + +def test_dense_view_drug_side_falls_back_to_computing_from_source() -> None: + source = _drug_source() + ids = np.array(["d1", "d2"], dtype=str) + featurizer = _ComputedDrugView(view="chemberta").fit(source, entity_ids=ids) + + assert featurizer.output_dim == 3 + np.testing.assert_allclose(featurizer.transform(source, ids), np.full((2, 3), 5.0, dtype=np.float32)) + + +def test_dense_view_without_a_fallback_propagates_the_missing_view() -> None: + with pytest.raises(KeyError): + _StrictDenseView().fit(_cell_line_source(), entity_ids=np.array(["cl1", "cl2"], dtype=str)) + + +def test_dense_view_transform_without_a_fallback_propagates_the_missing_view() -> None: + source = _cell_line_source() + ids = np.array(["cl1", "cl2"], dtype=str) + featurizer = _StrictDenseView(view="gene_expression").fit(source, entity_ids=ids) + featurizer._view = "bionic_features" + + with pytest.raises(KeyError): + featurizer.transform(source, ids) + + +def test_dense_view_fit_over_all_identifiers_when_none_are_given() -> None: + assert _StrictDenseView(view="gene_expression").fit(_cell_line_source()).output_dim == 2 + assert _StoredDrugView().fit(_drug_source()).output_dim == 2 + + +def test_dense_view_requires_fit_gate_is_off_by_default() -> None: + source = _cell_line_source() + ids = np.array(["cl1", "cl2"], dtype=str) + + matrix = _StrictDenseView(view="gene_expression")._transform(source, ids) + + assert matrix.shape == (2, 2) + + +def test_dense_view_requires_fit_gate_rejects_an_unfitted_transform() -> None: + class _NeedsFit(DenseViewCellLineFeaturizer): + input_views = ("gene_expression",) + requires_fit = True + + with pytest.raises(RuntimeError, match="must be fit before transform"): + _NeedsFit()._transform(_cell_line_source(), np.array(["cl1"], dtype=str)) + + +def test_dense_view_fit_on_unique_ids_deduplicates_the_fit_rows() -> None: + class _Unique(DenseViewCellLineFeaturizer): + input_views = ("gene_expression",) + fit_on_unique_ids = True + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.seen: np.ndarray | None = None + + def _fit_state(self, source, entity_ids: np.ndarray) -> int: + self.seen = entity_ids + return super()._fit_state(source, entity_ids) + + featurizer = _Unique().fit(_cell_line_source(), entity_ids=np.array(["cl1", "cl1", "cl2"], dtype=str)) + + assert list(featurizer.seen) == ["cl1", "cl2"] + + +class TestRestoreDenseState: + """``_restore_dense_state`` is the shared tail of every subclass ``set_state``. + + Four cell-line featurizers used to repeat these three type-guarded reads + verbatim around their own one fitted object, which is what made + ``normalized_proteomics`` and ``scaled_gene_expression`` the repository's most + co-changed duplication pair. + """ + + def test_it_restores_view_output_dim_and_the_fitted_flag(self) -> None: + featurizer = _StrictDenseView(view="gene_expression") + + featurizer._restore_dense_state({"view": "mutations", "output_dim": 7, "fitted": True}) + + assert featurizer._view == "mutations" + assert featurizer._output_dim == 7 + assert featurizer._is_fitted is True + + def test_absent_keys_leave_the_fields_alone(self) -> None: + """A subclass that never writes ``fitted`` must not have it reset for it.""" + featurizer = _StrictDenseView(view="gene_expression") + featurizer._output_dim = 3 + featurizer._is_fitted = True + + featurizer._restore_dense_state({}) + + assert featurizer._view == "gene_expression" + assert featurizer._output_dim == 3 + assert featurizer._is_fitted is True + + def test_wrongly_typed_values_are_ignored(self) -> None: + featurizer = _StrictDenseView(view="gene_expression") + + featurizer._restore_dense_state({"view": 5, "output_dim": "wide"}) + + assert featurizer._view == "gene_expression" + assert featurizer._output_dim == 0 diff --git a/tests/components/featurizers/test_featurizer_label.py b/tests/components/featurizers/test_featurizer_label.py new file mode 100644 index 000000000..96a1fe897 --- /dev/null +++ b/tests/components/featurizers/test_featurizer_label.py @@ -0,0 +1,26 @@ +"""Tests for qualified featurizer selectors and block labels.""" + +from __future__ import annotations + +from drevalpy.components.featurizers._featurizer_label import ( + featurizer_config_block_label, + qualified_featurizer_selector, + requires_explicit_view, +) + + +def test_qualified_selector_uses_view_brackets() -> None: + assert qualified_featurizer_selector("pca", "gene_expression") == "pca[gene_expression]" + assert qualified_featurizer_selector("raw", "mutations") == "raw[mutations]" + assert qualified_featurizer_selector("landmarkGenes") == "landmarkGenes" + + +def test_block_labels_match_qualified_selectors() -> None: + assert featurizer_config_block_label("pca", "proteomics") == "pca[proteomics]" + assert featurizer_config_block_label("fingerprints", None) == "fingerprints" + + +def test_requires_explicit_view() -> None: + assert requires_explicit_view("raw") + assert requires_explicit_view("pca") + assert not requires_explicit_view("landmarkGenes") diff --git a/tests/components/featurizers/test_featurizer_tree.py b/tests/components/featurizers/test_featurizer_tree.py new file mode 100644 index 000000000..6c7bba6d9 --- /dev/null +++ b/tests/components/featurizers/test_featurizer_tree.py @@ -0,0 +1,59 @@ +"""Tests for featurizer tree uniqueness helpers.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from drevalpy.components.featurizers._featurizer_tree import ensure_unique_qualified_featurizers +from drevalpy.models.config import FeaturizerConfig, ModelConfig, from_spec +from drevalpy.registry._builtins import register_builtin_components + + +def test_ensure_unique_allows_same_name_different_views() -> None: + config = FeaturizerConfig.model_validate( + { + "name": "concatFeaturizers", + "registry": "cell_line", + "featurizers": [ + {"name": "raw", "view": "gene_expression"}, + {"name": "raw", "view": "mutations"}, + ], + }, + ) + ensure_unique_qualified_featurizers(config, "cell_line") + + +def test_featurizer_config_rejects_duplicate_qualified_selector() -> None: + with pytest.raises(ValidationError, match="Duplicate featurizer selector 'raw\\[gene_expression\\]'"): + FeaturizerConfig.model_validate( + { + "name": "concatFeaturizers", + "registry": "cell_line", + "featurizers": [ + {"name": "raw", "view": "gene_expression"}, + {"name": "raw", "view": "gene_expression"}, + ], + }, + ) + + +def test_ensure_unique_ignores_a_non_concat_node() -> None: + config = FeaturizerConfig.model_validate({"name": "raw", "registry": "cell_line", "view": "gene_expression"}) + + ensure_unique_qualified_featurizers(config, "cell_line") + + +def test_recipe_string_rejects_duplicate_qualified_selector() -> None: + """``from_spec`` reports the duplicate as a config error naming the offending recipe.""" + register_builtin_components() + with pytest.raises(ValueError, match="Duplicate featurizer selector"): + from_spec("raw[expression]+raw[expression]:fingerprints:randomForest") + + +def test_recipe_string_allows_same_name_different_views() -> None: + register_builtin_components() + config = from_spec("raw[expression]+raw[mutations]:fingerprints:randomForest") + assert isinstance(config, ModelConfig) + assert config.cell_line_featurizer is not None + assert config.cell_line_featurizer.name == "concatFeaturizers" diff --git a/tests/components/featurizers/test_leaf_kwargs.py b/tests/components/featurizers/test_leaf_kwargs.py new file mode 100644 index 000000000..d77cfa814 --- /dev/null +++ b/tests/components/featurizers/test_leaf_kwargs.py @@ -0,0 +1,110 @@ +"""Tests for featurizer leaf kwarg resolution. + +Mirrors :mod:`drevalpy.components.featurizers._leaf_kwargs`, whose only caller is +``drevalpy.models.config.view_resolution.views_from_featurizer_config``. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.featurizers._leaf_kwargs import featurizer_leaf_kwargs +from drevalpy.models.config import ( + CellLineFeaturizerConfig, + DrugFeaturizerConfig, + ModelConfig, + PredictorConfig, +) +from drevalpy.models.config.resolved import ResolvedModelConfig + + +@pytest.fixture +def model_config() -> ModelConfig: + """A pca[gene_expression] + fingerprints + elasticNet stack.""" + return ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="pca", view="gene_expression"), + drug_featurizer=DrugFeaturizerConfig(name="fingerprints"), + predictor=PredictorConfig(name="elasticNet"), + ) + + +def test_leaf_kwargs_fill_hyperparameter_defaults_from_the_registry(model_config: ModelConfig) -> None: + leaf = model_config.cell_line_featurizer + assert leaf is not None + + kwargs = featurizer_leaf_kwargs(leaf, registry="cell_line", resolved=None) + + assert kwargs == {"n_components": 128, "view": "gene_expression"} + + +def test_leaf_kwargs_fill_defaults_for_drug_featurizers(model_config: ModelConfig) -> None: + leaf = model_config.drug_featurizer + assert leaf is not None + + kwargs = featurizer_leaf_kwargs(leaf, registry="drug", resolved=None) + + assert kwargs == {"radius": 2, "n_bits": 2048, "use_chirality": False, "use_counts": False} + + +def test_leaf_kwargs_prefer_config_options_over_registry_defaults() -> None: + leaf = CellLineFeaturizerConfig(name="pca", view="gene_expression", options={"n_components": 4}) + + kwargs = featurizer_leaf_kwargs(leaf, registry="cell_line", resolved=None) + + assert kwargs["n_components"] == 4 + + +def test_leaf_kwargs_prefer_a_declared_space_over_the_registry_space() -> None: + leaf = CellLineFeaturizerConfig( + name="pca", + view="gene_expression", + hyperparameter_space={"n_components": {"type": "int", "low": 2, "high": 4, "default": 3}}, + ) + + kwargs = featurizer_leaf_kwargs(leaf, registry="cell_line", resolved=None) + + assert kwargs["n_components"] == 3 + + +def test_leaf_kwargs_ignore_space_entries_without_a_default() -> None: + # ``FeaturizerConfig`` rejects a space entry without a ``default``, so the + # skip branch is only reachable through a config-shaped stub. + class _LeafWithoutDefault: + name = "pca" + view = "gene_expression" + options = None + hyperparameter_space = {"n_components": {"type": "int", "low": 2, "high": 4}} + + kwargs = featurizer_leaf_kwargs(_LeafWithoutDefault(), registry="cell_line", resolved=None) + + assert kwargs == {"view": "gene_expression"} + + +def test_leaf_kwargs_let_resolved_values_win(model_config: ModelConfig) -> None: + leaf = model_config.cell_line_featurizer + assert leaf is not None + resolved = ResolvedModelConfig( + template=model_config, + values={"cell_line_featurizer.pca[gene_expression].n_components": 16}, + ) + + kwargs = featurizer_leaf_kwargs(leaf, registry="cell_line", resolved=resolved) + + assert kwargs["n_components"] == 16 + + +def test_leaf_kwargs_omit_view_when_the_config_declares_none(model_config: ModelConfig) -> None: + leaf = model_config.drug_featurizer + assert leaf is not None + + kwargs = featurizer_leaf_kwargs(leaf, registry="drug", resolved=None) + + assert "view" not in kwargs + + +def test_leaf_kwargs_do_not_override_an_explicit_view_option() -> None: + leaf = CellLineFeaturizerConfig(name="raw", view="gene_expression", options={"view": "mutations"}) + + kwargs = featurizer_leaf_kwargs(leaf, registry="cell_line", resolved=None) + + assert kwargs["view"] == "mutations" diff --git a/tests/components/featurizers/test_matrix.py b/tests/components/featurizers/test_matrix.py new file mode 100644 index 000000000..9b3265773 --- /dev/null +++ b/tests/components/featurizers/test_matrix.py @@ -0,0 +1,109 @@ +"""Tests for the dense-matrix helpers shared by featurizers. + +Mirrors :mod:`drevalpy.components.featurizers._matrix`. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers._matrix import ( + entity_index_map, + feature_names_for_view, + stack_pair_features, + stack_view_matrix, + unique_entity_ids, +) +from tests.conftest import MockFeatureSource + + +def _source() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([1.0, 2.0])}, + "cl2": {"gene_expression": np.array([3.0, 4.0])}, + }, + meta_info={"gene_expression": ["g1", "g2"]}, + ) + + +def test_unique_entity_ids_keeps_first_seen_order() -> None: + ids = np.array(["cl2", "cl1", "cl2", "cl3"], dtype=str) + + assert unique_entity_ids(ids).tolist() == ["cl2", "cl1", "cl3"] + + +def test_unique_entity_ids_on_an_empty_array() -> None: + assert unique_entity_ids(np.array([], dtype=str)).tolist() == [] + + +def test_entity_index_map_maps_ids_to_row_positions() -> None: + ids = np.array(["cl1", "cl2", "cl3"], dtype=str) + + assert entity_index_map(ids) == {"cl1": 0, "cl2": 1, "cl3": 2} + + +def test_entity_index_map_keeps_the_last_position_for_duplicates() -> None: + ids = np.array(["cl1", "cl1"], dtype=str) + + assert entity_index_map(ids) == {"cl1": 1} + + +def test_feature_names_for_view_delegates_to_the_source() -> None: + assert feature_names_for_view(_source(), "gene_expression") == ("g1", "g2") + + +def test_feature_names_for_view_returns_none_for_an_unannotated_view() -> None: + assert feature_names_for_view(_source(), "mutations") is None + + +def test_stack_view_matrix_delegates_to_the_source() -> None: + matrix = stack_view_matrix(_source(), "gene_expression", np.array(["cl2", "cl1"], dtype=str)) + + np.testing.assert_allclose(matrix, [[3.0, 4.0], [1.0, 2.0]]) + + +def test_stack_pair_features_concatenates_both_sides() -> None: + cell_lines = np.array([[1.0, 2.0], [3.0, 4.0]]) + drugs = np.array([[5.0], [6.0]]) + + pairs = stack_pair_features(cell_lines, drugs, np.array([0, 1, 1]), np.array([1, 0, 1])) + + np.testing.assert_allclose(pairs, [[1.0, 2.0, 6.0], [3.0, 4.0, 5.0], [3.0, 4.0, 6.0]]) + + +def test_stack_pair_features_short_circuits_an_empty_cell_line_side() -> None: + drugs = np.array([[5.0], [6.0]]) + + pairs = stack_pair_features(np.empty((0, 0)), drugs, np.array([], dtype=int), np.array([1, 0])) + + np.testing.assert_allclose(pairs, [[6.0], [5.0]]) + + +def test_stack_pair_features_short_circuits_an_empty_drug_side() -> None: + cell_lines = np.array([[1.0, 2.0], [3.0, 4.0]]) + + pairs = stack_pair_features(cell_lines, np.empty((0, 0)), np.array([1, 0]), np.array([], dtype=int)) + + np.testing.assert_allclose(pairs, [[3.0, 4.0], [1.0, 2.0]]) + + +@pytest.mark.parametrize( + ("n_cell_line_features", "n_drug_features", "expected_width"), + [ + pytest.param(2, 3, 5, id="both-sides"), + pytest.param(1, 1, 2, id="single-column-each"), + ], +) +def test_stack_pair_features_width_is_the_sum_of_both_sides( + n_cell_line_features: int, + n_drug_features: int, + expected_width: int, +) -> None: + cell_lines = np.ones((2, n_cell_line_features)) + drugs = np.ones((2, n_drug_features)) + + pairs = stack_pair_features(cell_lines, drugs, np.array([0, 1]), np.array([0, 1])) + + assert pairs.shape == (2, expected_width) diff --git a/tests/components/featurizers/test_nan_tolerance.py b/tests/components/featurizers/test_nan_tolerance.py new file mode 100644 index 000000000..6adb50164 --- /dev/null +++ b/tests/components/featurizers/test_nan_tolerance.py @@ -0,0 +1,140 @@ +"""Tests for the featurizer NaN-tolerance policy. + +Mirrors :mod:`drevalpy.components.featurizers._nan_tolerance`, which holds the +three steps every public ``fit``/``transform`` on ``Featurizer`` brackets its +subclass hook with: work out which entities have usable rows, warn when too few +do, and pad the result back to full length. + +The wrappers themselves stay covered in ``test_base.py``, where they are defined; +what is asserted here is the policy they delegate to, including the four +"treat it as valid" escape hatches in ``_detect_valid`` that keep a featurizer +usable against a source it cannot probe. +""" + +from __future__ import annotations + +import logging + +import numpy as np +import pytest + +from drevalpy.components.featurizers._nan_tolerance import NanToleranceMixin +from drevalpy.types.data.batch.feature_block import ( + metadata_feature_block, + numeric_feature_block, + ragged_feature_block, +) +from tests.components.featurizers._helpers import DoublingFeaturizer, StubSource + +_LOGGER_NAME = "drevalpy.components.featurizers._nan_tolerance" + + +def test_the_mixin_is_part_of_the_featurizer_base() -> None: + """The policy is reached through ``Featurizer``, not wired in per subclass.""" + assert issubclass(DoublingFeaturizer, NanToleranceMixin) + + +class TestDetectValid: + """Which entities the featurizer considers usable.""" + + def test_all_nan_rows_are_invalid(self) -> None: + ids = np.array(["A", "B", "C"]) + matrix = np.array([[np.nan, np.nan], [1.0, 2.0], [np.nan, np.nan]], dtype=np.float32) + + mask = DoublingFeaturizer()._detect_valid(StubSource(matrix, ids), ids) + + assert mask.tolist() == [False, True, False] + + def test_entity_id_only_featurizers_are_all_valid(self, monkeypatch: pytest.MonkeyPatch) -> None: + feat = DoublingFeaturizer() + monkeypatch.setattr(DoublingFeaturizer, "entity_id_only", True) + + mask = feat._detect_valid(StubSource(np.zeros((1, 3)), np.array(["A"])), np.array(["A"])) + + assert mask.tolist() == [True] + + def test_a_viewless_featurizer_is_all_valid(self, monkeypatch: pytest.MonkeyPatch) -> None: + feat = DoublingFeaturizer() + monkeypatch.setattr(DoublingFeaturizer, "input_views", None) + + mask = feat._detect_valid(StubSource(np.zeros((1, 3)), np.array(["A"])), np.array(["A"])) + + assert mask.tolist() == [True] + + def test_an_unreadable_view_is_all_valid(self) -> None: + source = StubSource(np.zeros((1, 3)), np.array(["A"])) + + mask = DoublingFeaturizer()._detect_valid(source, np.array(["missing"])) + + assert mask.tolist() == [True] + + def test_a_non_numeric_view_is_all_valid(self) -> None: + source = StubSource(np.array([["a", "b"]], dtype=str), np.array(["A"])) + + mask = DoublingFeaturizer()._detect_valid(source, np.array(["A"])) + + assert mask.tolist() == [True] + + +class TestExpandBlocksWithNan: + """Padding valid-only blocks back out to the full entity list.""" + + def test_numeric_blocks_are_padded_with_nan(self) -> None: + block = numeric_feature_block(np.array([[1.0, 2.0]], dtype=np.float32)) + + expanded = DoublingFeaturizer()._expand_blocks_with_nan({"numeric": block}, np.array([True, False]), 2) + + values = expanded["numeric"].values + assert values.shape == (2, 2) + np.testing.assert_allclose(values[0], [1.0, 2.0]) + assert np.all(np.isnan(values[1])) + + def test_non_entity_aligned_blocks_pass_through_untouched(self) -> None: + block = metadata_feature_block(np.asarray(["lung", "skin"], dtype=str)) + + expanded = DoublingFeaturizer()._expand_blocks_with_nan({"categories": block}, np.array([True, False]), 2) + + assert expanded["categories"] is block + + def test_ragged_payloads_are_padded_with_none(self) -> None: + payload = np.empty(1, dtype=object) + payload[0] = np.ones((2, 3), dtype=np.float32) + + expanded = DoublingFeaturizer()._expand_blocks_with_nan( + {"ragged": ragged_feature_block(payload)}, + np.array([True, False]), + 2, + ) + + values = expanded["ragged"].values + assert values.shape == (2,) + assert values[1] is None + + def test_the_padded_block_keeps_its_feature_names(self) -> None: + block = numeric_feature_block(np.array([[1.0]], dtype=np.float32), feature_names=("g1",)) + + expanded = DoublingFeaturizer()._expand_blocks_with_nan({"numeric": block}, np.array([True, False]), 2) + + assert expanded["numeric"].feature_names == ("g1",) + + +class TestWarnIfAboveThreshold: + """The warning is the only signal a run has silently lost most of its rows.""" + + def test_it_warns_above_the_threshold(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME): + DoublingFeaturizer()._warn_if_above_threshold(np.array([True, False, False, False]), "probe") + + assert any("invalid" in record.message.lower() for record in caplog.records) + + def test_it_stays_quiet_below_the_threshold(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME): + DoublingFeaturizer()._warn_if_above_threshold(np.array([True, True, True, True]), "probe") + + assert not caplog.records + + def test_an_empty_mask_never_warns(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME): + DoublingFeaturizer()._warn_if_above_threshold(np.array([], dtype=bool), "empty") + + assert not caplog.records diff --git a/tests/components/featurizers/test_one_hot.py b/tests/components/featurizers/test_one_hot.py new file mode 100644 index 000000000..a6656894b --- /dev/null +++ b/tests/components/featurizers/test_one_hot.py @@ -0,0 +1,95 @@ +"""Tests for the shared one-hot category encoder. + +Mirrors :mod:`drevalpy.components.featurizers._one_hot`, used by the identity and +tissue featurizers. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.featurizers._one_hot import OneHotCategoryEncoder + + +def test_categories_are_sorted_and_indexed_in_order() -> None: + encoder = OneHotCategoryEncoder() + + encoder.fit_categories(np.array(["skin", "lung", "skin"], dtype=str)) + + assert encoder.categories == ["lung", "skin"] + assert encoder.output_dim == 2 + + +def test_transform_emits_one_hot_rows() -> None: + encoder = OneHotCategoryEncoder() + encoder.fit_categories(np.array(["lung", "skin"], dtype=str)) + + matrix = encoder.transform(np.array(["skin", "lung"], dtype=str)) + + np.testing.assert_allclose(matrix, [[0.0, 1.0], [1.0, 0.0]]) + assert matrix.dtype == np.float32 + + +def test_transform_before_fit_returns_a_zero_width_matrix() -> None: + encoder = OneHotCategoryEncoder() + + matrix = encoder.transform(np.array(["lung", "skin"], dtype=str)) + + assert matrix.shape == (2, 0) + + +def test_empty_vocabulary_has_no_categories() -> None: + encoder = OneHotCategoryEncoder() + + encoder.fit_categories(np.array([], dtype=str)) + + assert encoder.categories == [] + assert encoder.output_dim == 0 + + +def test_unknown_categories_default_to_an_all_zero_row() -> None: + encoder = OneHotCategoryEncoder() + encoder.fit_categories(np.array(["lung"], dtype=str)) + + matrix = encoder.transform(np.array(["skin"], dtype=str)) + + np.testing.assert_allclose(matrix, [[0.0]]) + + +def test_unknown_categories_raise_when_unknown_zero_is_disabled() -> None: + encoder = OneHotCategoryEncoder() + encoder.fit_categories(np.array(["lung"], dtype=str)) + + with pytest.raises(KeyError, match="Unknown category"): + encoder.transform(np.array(["skin"], dtype=str), unknown_zero=False) + + +def test_state_round_trip_restores_the_vocabulary() -> None: + encoder = OneHotCategoryEncoder() + encoder.fit_categories(np.array(["lung", "skin"], dtype=str)) + + restored = OneHotCategoryEncoder() + restored.set_state(encoder.get_state()) + + assert restored.categories == ["lung", "skin"] + np.testing.assert_allclose( + restored.transform(np.array(["skin"], dtype=str)), + encoder.transform(np.array(["skin"], dtype=str)), + ) + + +def test_set_state_ignores_a_non_list_categories_payload() -> None: + encoder = OneHotCategoryEncoder() + + encoder.set_state({"categories": "lung"}) + + assert encoder.categories == [] + + +def test_fit_categories_flattens_multi_dimensional_input() -> None: + encoder = OneHotCategoryEncoder() + + encoder.fit_categories(np.array([["lung"], ["skin"]], dtype=str)) + + assert encoder.categories == ["lung", "skin"] diff --git a/tests/components/featurizers/test_precompute_smoke.py b/tests/components/featurizers/test_precompute_smoke.py new file mode 100644 index 000000000..c1f837540 --- /dev/null +++ b/tests/components/featurizers/test_precompute_smoke.py @@ -0,0 +1,104 @@ +"""Smoke tests verifying all precomputable featurizers can compute from raw data.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry +from drevalpy.registry.drug_featurizer import drug_featurizer_registry +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import CellLineFeatureSource, DrugFeatureSource + +#: Featurizers that need a pretrained-weight or remote-annotation download, so +#: they cannot be derived from the raw data the fixture carries. Marked ``network`` +#: rather than skipped unconditionally, so ``-m "not network"`` keeps CI hermetic +#: while a developer with connectivity can still run them. +_NETWORK_FEATURIZERS = frozenset({"chemberta", "molgnet", "smilesvec"}) + +#: Featurizers the fixture cannot serve at all, mapped to why. +#: +#: ``bionic`` wants ``uns['dipk']``, which the real data builder writes but no +#: library code reads, and ``bionic.py`` reaches instead for an 83 MB S3 artifact +#: behind a named AWS profile CI can never assume. ``molgnet`` wants a ragged +#: ``response.varm['molgnet_features']`` that ``h5py`` cannot store, and +#: ``FeatureSource.get_entity_view`` raises ``KeyError`` when the varm key is +#: absent instead of returning ``None``, so its on-the-fly fallback is dead code. +_UNSUPPORTED = { + "bionic": "uns['dipk'], which the synthetic fixture omits", + "molgnet": "a ragged response.varm['molgnet_features'], which cannot be stored in a .h5mu", +} + + +def _collect_precomputable_featurizers(): + """Collect all registered featurizers with precompute=True.""" + items = [] + for registry, side in [ + (cell_line_featurizer_registry, "cell_line"), + (drug_featurizer_registry, "drug"), + ]: + for name in registry.list_names(): + cls = registry.get(name) + if not cls.precompute: + continue + marks = [pytest.mark.network] if name in _NETWORK_FEATURIZERS else [] + items.append(pytest.param(name, cls, side, marks=marks, id=f"{side}/{name}")) + return items + + +def _instantiate_featurizer(name, cls): + """Create a featurizer instance with default hyperparameters.""" + try: + default_hps = cls.get_default_hyperparameters() + except Exception: + default_hps = {} + + if cls.requires_view and "view" not in default_hps: + views = cls.input_views + if views: + default_hps["view"] = views[0] + else: + pytest.skip(f"{name} requires an explicit view but declares none") + + try: + return cls(**default_hps) + except ImportError as exc: + pytest.skip(f"{name} optional dependency missing: {exc}") + except TypeError: + return cls() + + +def _make_source_and_ids(side, ds): + """Build the appropriate FeatureSource and pick 2 entity IDs.""" + if side == "cell_line": + return CellLineFeatureSource(ds, ds.cell_line_ids), ds.cell_line_ids[:2] + return DrugFeatureSource(ds, ds.drug_ids), ds.drug_ids[:2] + + +@pytest.mark.parametrize("name, cls, side", _collect_precomputable_featurizers()) +def test_precompute_fit_transform(name, cls, side, synthetic_dataset: Dataset) -> None: + """Each precomputable featurizer derives 2 entities' features from raw data. + + Goes through ``_compute_from_source``, which is the path + ``Dataset.precompute`` takes for independent featurizers. Calling + ``fit``/``transform`` instead would silently read the already-stored view and + prove nothing about computation. + + :param name: Registry name of the featurizer. + :param cls: Featurizer class. + :param side: ``cell_line`` or ``drug``. + :param synthetic_dataset: Session-scoped synthetic raw-omics dataset. + """ + if name in _UNSUPPORTED: + pytest.skip(f"{name} requires {_UNSUPPORTED[name]}") + + featurizer = _instantiate_featurizer(name, cls) + source, ids = _make_source_and_ids(side, synthetic_dataset) + + try: + result = featurizer._compute_from_source(source, ids) + except ImportError as exc: + pytest.skip(f"{name} optional dependency missing: {exc}") + + assert isinstance(result, np.ndarray), f"{name}: expected ndarray, got {type(result)}" + assert result.shape[0] == 2, f"{name}: expected 2 rows, got {result.shape[0]}" diff --git a/tests/components/featurizers/test_side_binding.py b/tests/components/featurizers/test_side_binding.py new file mode 100644 index 000000000..effa35b7f --- /dev/null +++ b/tests/components/featurizers/test_side_binding.py @@ -0,0 +1,142 @@ +"""Tests for the per-side featurizer binding decorator. + +Mirrors :mod:`drevalpy.components.featurizers._side_binding`. The tests that register +names go through ``isolated_component_registries`` because the component registries +are process-global; ``register_builtin_components`` only adds, so a registration left +behind resurfaces much later as a duplicate-name ``ValueError``. +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterator + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers._side_binding import ( + derived_class_name, + known_sides, + register_for_sides, +) +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.components.featurizers.drug.base import DrugFeaturizer +from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry +from drevalpy.registry.cell_line_featurizer import metadata as cell_line_metadata +from drevalpy.registry.drug_featurizer import drug_featurizer_registry +from drevalpy.registry.drug_featurizer import metadata as drug_metadata +from tests.registry._helpers import isolated_component_registries + + +@pytest.fixture(autouse=True) +def _isolated_registries() -> Iterator[None]: + """Register into empty registries and restore the built-ins afterwards.""" + yield from isolated_component_registries() + + +class _Probe(Featurizer): + """Minimal side-agnostic implementation used as the decorator's input.""" + + entity_id_only = True + + def _fit(self, source, *, entity_ids=None, pair_expanded_ids=None, pair_expanded_es_ids=None): + """Fitting is a no-op.""" + return self + + def _transform_blocks(self, source, entity_ids: np.ndarray) -> dict: + """No blocks; the probe is only ever inspected as a class.""" + return {} + + @property + def output_dim(self) -> int: + """Fixed width.""" + return 1 + + +def _bind(name: str, **kwargs) -> type: + """Apply the decorator to a fresh copy of the probe under *name*.""" + probe = type(_Probe.__name__, (_Probe,), {"__module__": __name__, "__doc__": _Probe.__doc__}) + return register_for_sides(name, contract=FeatureFormat.NUMERIC_MATRIX, **kwargs)(probe) + + +def test_known_sides_lists_both_entity_sides() -> None: + assert known_sides() == ("cell_line", "drug") + + +@pytest.mark.parametrize( + ("implementation_name", "side", "expected"), + [ + ("SharedIdentityFeaturizer", "cell_line", "CellLineIdentityFeaturizer"), + ("SharedIdentityFeaturizer", "drug", "DrugIdentityFeaturizer"), + ("PlainFeaturizer", "drug", "DrugPlainFeaturizer"), + ], +) +def test_derived_class_name_inserts_the_side_prefix(implementation_name: str, side: str, expected: str) -> None: + assert derived_class_name(implementation_name, side) == expected + + +def test_derived_class_name_rejects_an_unknown_side() -> None: + with pytest.raises(ValueError, match="unknown featurizer side"): + derived_class_name("SharedProbeFeaturizer", "tissue") + + +def test_register_for_sides_registers_on_both_sides() -> None: + _bind("probeBoth", description="Probe.") + + assert "probeBoth" in cell_line_featurizer_registry.list_names() + assert "probeBoth" in drug_featurizer_registry.list_names() + + +def test_register_for_sides_gives_each_side_its_own_class_and_side_value() -> None: + _bind("probeSides", description="Probe.") + + cell_line_cls = cell_line_featurizer_registry.get("probeSides") + drug_cls = drug_featurizer_registry.get("probeSides") + + assert cell_line_cls is not drug_cls + assert cell_line_cls.side == "cell_line" + assert drug_cls.side == "drug" + + +def test_register_for_sides_binds_each_side_to_its_base_class() -> None: + _bind("probeBases", description="Probe.") + + assert issubclass(cell_line_featurizer_registry.get("probeBases"), CellLineFeaturizer) + assert issubclass(drug_featurizer_registry.get("probeBases"), DrugFeaturizer) + + +def test_register_for_sides_returns_the_unregistered_implementation() -> None: + implementation = _bind("probeReturn", description="Probe.") + + assert implementation.side == "" + assert not hasattr(implementation, "registry_name") + + +def test_register_for_sides_injects_the_derived_classes_into_the_module() -> None: + """``_reregister_from_module`` walks ``vars(module)``, so the classes must land there.""" + implementation = _bind("probeInject", description="Probe.") + module = sys.modules[implementation.__module__] + + for side in known_sides(): + assert hasattr(module, derived_class_name(implementation.__name__, side)) + + +def test_register_for_sides_accepts_a_per_side_description() -> None: + _bind("probeDescribed", description={"cell_line": "For cell lines.", "drug": "For drugs."}) + + assert cell_line_metadata("probeDescribed")["description"] == "For cell lines." + assert drug_metadata("probeDescribed")["description"] == "For drugs." + + +def test_register_for_sides_can_bind_a_single_side() -> None: + _bind("probeOneSide", description="Probe.", sides=("drug",)) + + assert "probeOneSide" in drug_featurizer_registry.list_names() + assert "probeOneSide" not in cell_line_featurizer_registry.list_names() + + +def test_register_for_sides_rejects_an_unknown_side() -> None: + with pytest.raises(ValueError, match="unknown featurizer side"): + _bind("probeBadSide", description="Probe.", sides=("tissue",)) diff --git a/tests/components/featurizers/test_storage.py b/tests/components/featurizers/test_storage.py new file mode 100644 index 000000000..501b96040 --- /dev/null +++ b/tests/components/featurizers/test_storage.py @@ -0,0 +1,291 @@ +"""Tests for featurizer variant storage helpers. + +Mirrors :mod:`drevalpy.components.featurizers.storage`. These are pure functions +over a MuData object; the only production writer is ``Dataset.precompute()``, so +the fixtures below build the 2x2 MuData by hand. +""" + +from __future__ import annotations + +import json + +import anndata as ad +import mudata as md +import numpy as np +import pandas as pd +import pytest + +from drevalpy.components.featurizers.base import Featurizer +from drevalpy.components.featurizers.storage import ( + VARIANTS_UNS_KEY_CELL_LINE, + VARIANTS_UNS_KEY_DRUG, + FeaturizerStorageMixin, + fetch_from_modality, + fetch_from_obsm, + fetch_from_varm, + find_variant_key, + list_variants, + make_variant_key, + next_variant_index, + register_variant, +) + +_CELL_LINES = np.array(["cl1", "cl2"]) +_DRUGS = np.array(["d1", "d2"]) + + +@pytest.fixture +def mdata() -> md.MuData: + """A 2x2 response MuData with one omics modality and pre-computed obsm/varm.""" + response = ad.AnnData( + X=np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32), + obs=pd.DataFrame(index=_CELL_LINES), + var=pd.DataFrame(index=_DRUGS), + ) + response.obsm["pca_expression_0"] = np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32) + response.varm["morgan_fingerprint_0"] = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32) + gene_expression = ad.AnnData( + X=np.array([[5.0, 6.0, 7.0], [8.0, 9.0, 10.0]], dtype=np.float32), + obs=pd.DataFrame(index=_CELL_LINES), + var=pd.DataFrame(index=[f"gene{i}" for i in range(3)]), + ) + return md.MuData({"response": response, "gene_expression": gene_expression}) + + +def test_find_variant_key_returns_none_without_a_registry(mdata: md.MuData) -> None: + assert find_variant_key(mdata, "pca_expression", {"n_components": 2}) is None + + +def test_register_then_find_matches_on_hyperparameters(mdata: md.MuData) -> None: + register_variant(mdata, "pca_expression", "pca_expression_0", {"n_components": 2}) + + assert find_variant_key(mdata, "pca_expression", {"n_components": 2}) == "pca_expression_0" + + +def test_find_variant_key_returns_none_for_unmatched_hyperparameters(mdata: md.MuData) -> None: + register_variant(mdata, "pca_expression", "pca_expression_0", {"n_components": 2}) + + assert find_variant_key(mdata, "pca_expression", {"n_components": 8}) is None + + +def test_find_variant_key_treats_none_as_the_default_empty_params(mdata: md.MuData) -> None: + register_variant(mdata, "chemberta", "chemberta_0", None) + + assert find_variant_key(mdata, "chemberta", None) == "chemberta_0" + + +def test_register_variant_writes_json_under_the_side_specific_uns_key(mdata: md.MuData) -> None: + register_variant(mdata, "smilesvec", "smilesvec_0", {"k": 8}, side="drug") + + assert VARIANTS_UNS_KEY_CELL_LINE not in mdata.uns + assert json.loads(mdata.uns[VARIANTS_UNS_KEY_DRUG]) == {"smilesvec": {"smilesvec_0": {"k": 8}}} + + +def test_registry_sides_are_independent(mdata: md.MuData) -> None: + register_variant(mdata, "shared", "shared_0", {"a": 1}, side="cell_line") + register_variant(mdata, "shared", "shared_9", {"a": 1}, side="drug") + + assert find_variant_key(mdata, "shared", {"a": 1}, side="cell_line") == "shared_0" + assert find_variant_key(mdata, "shared", {"a": 1}, side="drug") == "shared_9" + + +def test_list_variants_is_empty_for_an_unknown_storage_key(mdata: md.MuData) -> None: + register_variant(mdata, "pca_expression", "pca_expression_0", {"n_components": 2}) + + assert list_variants(mdata, "landmark_genes") == {} + + +def test_list_variants_reads_a_dict_valued_registry(mdata: md.MuData) -> None: + mdata.uns[VARIANTS_UNS_KEY_CELL_LINE] = {"pca": {"pca_0": {"n_components": 2}}} + + assert list_variants(mdata, "pca") == {"pca_0": {"n_components": 2}} + + +@pytest.mark.parametrize( + ("storage_key", "index", "expected"), + [ + pytest.param("pca", 0, "pca_0", id="plain"), + pytest.param("raw[gene_expression]", 1, "raw_gene_expression_1", id="bracketed-view"), + pytest.param("a:b", 2, "a_b_2", id="colon"), + ], +) +def test_make_variant_key_sanitizes_the_storage_key(storage_key: str, index: int, expected: str) -> None: + assert make_variant_key(storage_key, index) == expected + + +def test_next_variant_index_counts_registered_variants(mdata: md.MuData) -> None: + assert next_variant_index(mdata, "pca") == 0 + + register_variant(mdata, "pca", "pca_0", {"n_components": 2}) + register_variant(mdata, "pca", "pca_1", {"n_components": 8}) + + assert next_variant_index(mdata, "pca") == 2 + + +def test_fetch_from_modality_aligns_rows_to_entity_ids(mdata: md.MuData) -> None: + result = fetch_from_modality(mdata, "gene_expression", np.array(["cl2", "cl1"])) + + assert result is not None + np.testing.assert_allclose(result, [[8.0, 9.0, 10.0], [5.0, 6.0, 7.0]]) + + +def test_fetch_from_modality_returns_none_for_an_absent_modality(mdata: md.MuData) -> None: + assert fetch_from_modality(mdata, "methylation", _CELL_LINES) is None + + +def test_fetch_from_modality_fills_unknown_entities_with_nan(mdata: md.MuData) -> None: + result = fetch_from_modality(mdata, "gene_expression", np.array(["cl1", "ghost"])) + + assert result is not None + np.testing.assert_allclose(result[0], [5.0, 6.0, 7.0]) + assert np.all(np.isnan(result[1])) + + +def test_fetch_from_varm_aligns_rows_to_drug_ids(mdata: md.MuData) -> None: + result = fetch_from_varm(mdata, "morgan_fingerprint_0", np.array(["d2", "d1"])) + + assert result is not None + np.testing.assert_allclose(result, [[0.0, 1.0], [1.0, 0.0]]) + + +def test_fetch_from_varm_returns_none_for_an_absent_key(mdata: md.MuData) -> None: + assert fetch_from_varm(mdata, "chemberta_0", _DRUGS) is None + + +def test_fetch_from_varm_fills_unknown_drugs_with_nan(mdata: md.MuData) -> None: + result = fetch_from_varm(mdata, "morgan_fingerprint_0", np.array(["d1", "ghost"])) + + assert result is not None + np.testing.assert_allclose(result[0], [1.0, 0.0]) + assert np.all(np.isnan(result[1])) + + +def test_fetch_from_obsm_aligns_rows_to_cell_line_ids(mdata: md.MuData) -> None: + result = fetch_from_obsm(mdata, "pca_expression_0", np.array(["cl2", "cl1"])) + + assert result is not None + np.testing.assert_allclose(result, [[0.3, 0.4], [0.1, 0.2]], rtol=1e-6) + + +def test_fetch_from_obsm_returns_none_for_an_absent_key(mdata: md.MuData) -> None: + assert fetch_from_obsm(mdata, "bionic_0", _CELL_LINES) is None + + +def test_fetch_from_obsm_fills_unknown_cell_lines_with_nan(mdata: md.MuData) -> None: + result = fetch_from_obsm(mdata, "pca_expression_0", np.array(["cl1", "ghost"])) + + assert result is not None + np.testing.assert_allclose(result[0], [0.1, 0.2], rtol=1e-6) + assert np.all(np.isnan(result[1])) + + +def test_fetchers_return_none_without_a_response_modality() -> None: + only_omics = md.MuData( + { + "gene_expression": ad.AnnData( + X=np.zeros((2, 2), dtype=np.float32), + obs=pd.DataFrame(index=_CELL_LINES), + var=pd.DataFrame(index=["g0", "g1"]), + ) + } + ) + + assert fetch_from_varm(only_omics, "anything", _DRUGS) is None + assert fetch_from_obsm(only_omics, "anything", _CELL_LINES) is None + + +class _CellLineStore(FeaturizerStorageMixin): + """Cell-line-side storage user, standing in for a real featurizer.""" + + storage_key = "probe" + side = "cell_line" + + +class _DrugStore(FeaturizerStorageMixin): + """Drug-side storage user, standing in for a real featurizer.""" + + storage_key = "probe" + side = "drug" + + +def test_featurizer_inherits_the_storage_mixin() -> None: + """The mixin was extracted off ``Featurizer``; it has to still be mixed back in.""" + assert issubclass(Featurizer, FeaturizerStorageMixin) + for name in ("fetch", "_fetch_by_key", "store", "_store_by_key", "list_stored_variants"): + assert name not in vars(Featurizer), f"{name} should live only on the mixin" + + +def test_storage_mixin_fetch_returns_none_without_a_registered_variant(mdata: md.MuData) -> None: + assert _CellLineStore().fetch(mdata, _CELL_LINES) is None + + +def test_storage_mixin_store_then_fetch_round_trips_on_the_cell_line_side(mdata: md.MuData) -> None: + values = np.array([[1.5, 2.5], [3.5, 4.5]], dtype=np.float32) + store = _CellLineStore() + + store.store(mdata, _CELL_LINES, values, {"n": 3}) + fetched = store.fetch(mdata, _CELL_LINES, {"n": 3}) + + assert fetched is not None + np.testing.assert_allclose(fetched, values) + + +def test_storage_mixin_store_writes_the_drug_side_to_varm(mdata: md.MuData) -> None: + values = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32) + + _DrugStore().store(mdata, _DRUGS, values) + + assert "probe_0" in mdata.mod["response"].varm + assert "probe_0" not in mdata.mod["response"].obsm + + +def test_storage_mixin_store_indexes_successive_variants(mdata: md.MuData) -> None: + store = _CellLineStore() + values = np.zeros((2, 2), dtype=np.float32) + + store.store(mdata, _CELL_LINES, values, {"n": 1}) + store.store(mdata, _CELL_LINES, values, {"n": 2}) + + assert set(store.list_stored_variants(mdata)) == {"probe_0", "probe_1"} + + +def test_storage_mixin_list_stored_variants_is_a_classmethod(mdata: md.MuData) -> None: + """``list_stored_variants`` reads ``cls.side``, so it must work off the class.""" + _CellLineStore().store(mdata, _CELL_LINES, np.zeros((2, 2), dtype=np.float32), {"n": 1}) + + assert _CellLineStore.list_stored_variants(mdata) == {"probe_0": {"n": 1}} + assert _DrugStore.list_stored_variants(mdata) == {} + + +def test_storage_mixin_fetch_precomputed_tolerates_a_source_without_mdata() -> None: + class _NoMdata: + mdata = None + + assert _CellLineStore().fetch_precomputed(_NoMdata(), _CELL_LINES) is None + + +def test_storage_mixin_fetch_precomputed_reads_through_a_backed_source(mdata: md.MuData) -> None: + values = np.array([[7.0, 8.0], [9.0, 10.0]], dtype=np.float32) + store = _CellLineStore() + store.store(mdata, _CELL_LINES, values) + + class _Backed: + pass + + source = _Backed() + source.mdata = mdata + + fetched = store.fetch_precomputed(source, _CELL_LINES) + + assert fetched is not None + np.testing.assert_allclose(fetched, values) + + +def test_storage_mixin_prefers_a_modality_over_obsm(mdata: md.MuData) -> None: + """``_fetch_by_key`` tries modalities first; ``Dataset.precompute`` can write either.""" + register_variant(mdata, "probe", "gene_expression", None, side="cell_line") + + fetched = _CellLineStore().fetch(mdata, _CELL_LINES) + + assert fetched is not None + assert fetched.shape == (2, 3) diff --git a/tests/components/predictors/_helpers.py b/tests/components/predictors/_helpers.py new file mode 100644 index 000000000..d49a3c8d0 --- /dev/null +++ b/tests/components/predictors/_helpers.py @@ -0,0 +1,56 @@ +"""Shared batch builders for predictor unit tests.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import numpy as np + +from drevalpy.components.contracts.training_context import TrainingContext +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.batch.response_batch import ResponseBatch +from tests.models.synthetic_fixtures import ( + cell_line_gene_expression, + drug_fingerprints, + multi_drug_response, +) + + +def neural_batch(*, with_early_stopping: bool = False) -> ModelInputBatch: + """Build a dense matrix batch for predictors that train on flattened features. + + :param with_early_stopping: Attach a validation :class:`ResponseBatch` when true. + :returns: Featurized ``ModelInputBatch`` with a temporary checkpoint directory. + """ + response = multi_drug_response() + cell_line_features = np.vstack( + [ + cell_line_gene_expression().features["cl1"]["gene_expression"], + cell_line_gene_expression().features["cl2"]["gene_expression"], + ] + ) + drug_features = np.vstack( + [ + drug_fingerprints().features["d1"]["fingerprints"], + drug_fingerprints().features["d2"]["fingerprints"], + ] + ) + early_stopping = None + if with_early_stopping: + early_stopping = ResponseBatch( + response=np.array([1.5, 2.5]), + cell_line_ids=np.array(["cl1", "cl2"]), + drug_ids=np.array(["d1", "d2"]), + ) + return ModelInputBatch.from_response( + response, + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=cell_line_features, + drug_features=drug_features, + cell_line_pair_idx=np.array([0, 0, 1, 1]), + drug_pair_idx=np.array([0, 1, 0, 1]), + early_stopping_response=early_stopping, + training_context=TrainingContext(checkpoint_dir=Path(tempfile.mkdtemp())), + ) diff --git a/tests/components/predictors/abstract/test_base.py b/tests/components/predictors/abstract/test_base.py new file mode 100644 index 000000000..f89f7782b --- /dev/null +++ b/tests/components/predictors/abstract/test_base.py @@ -0,0 +1,135 @@ +"""Tests for the shared Predictor constructor contract and the input-interface taxonomy.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.components.predictors.sklearn_models import ElasticNetPredictor +from drevalpy.models.config import PredictorConfig +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.registry.predictor import list as list_predictors +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +class _StubPredictor(Predictor): + @classmethod + def get_hyperparameter_space(cls) -> dict[str, dict[str, object]]: + return {"alpha": {"type": "float", "default": 1.0}} + + def _fit(self, batch: ModelInputBatch) -> None: + _ = batch + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.zeros(batch.n_pairs, dtype=np.float64) + + +_StubPredictor.cell_line_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) +_StubPredictor.drug_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + +EXPECTED = { + "feature_free": {"naiveMean"}, + "matrix": { + "elasticNet", + "singleDrugElasticNet", + "lasso", + "ridge", + "randomForest", + "singleDrugRandomForest", + "svr", + "gradientBoosting", + "adaboost", + "knn", + "xgboost", + "lightgbm", + "neuralNetwork", + }, + "block": { + "naiveDrugMean", + "naiveCellLineMean", + "naiveTissueMean", + "naiveTissueDrugMean", + "naiveMeanEffects", + "precily", + "srmf", + "drugGNN", + "dipk", + "pharmaFormer", + "sparsego", + "molir", + "superfeltr", + }, +} +LEAF_BASES = (FeatureFreePredictor, MatrixPredictor, BlockPredictor) + + +@pytest.fixture(autouse=True) +def _register() -> None: + register_builtin_components() + + +def test_predictor_accepts_class_body_contracts() -> None: + class BodyContractPredictor(Predictor): # noqa: B903 + cell_line_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + assert BodyContractPredictor.cell_line_contract == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + +def test_predictor_normalizes_a_class_body_format_shorthand() -> None: + class ShorthandPredictor(Predictor): # noqa: B903 + drug_contract = FeatureFormat.GRAPH + + assert ShorthandPredictor.drug_contract == FeatureContract(format=FeatureFormat.GRAPH) + + +def test_predictor_rejects_an_invalid_class_body_contract() -> None: + with pytest.raises(TypeError, match="class-body drug_contract is invalid"): + + class BadPredictor(Predictor): # noqa: B903 + drug_contract = "graph_but_a_plain_string" + + +def test_predictor_init_merges_default_hyperparameters() -> None: + predictor = _StubPredictor(hyperparameters={"alpha": 0.5, "extra": True}) + assert predictor._hyperparameters["alpha"] == 0.5 + assert predictor._hyperparameters["extra"] is True + + +def test_predictor_has_no_public_build() -> None: + assert "build" not in Predictor.__dict__ + assert not hasattr(_StubPredictor(), "build") + + +def test_predictor_config_create_instance_passes_hyperparameters() -> None: + register_builtin_components() + predictor = PredictorConfig(name="elasticNet").create_instance({"alpha": 0.25}) + assert isinstance(predictor, ElasticNetPredictor) + assert predictor._hyperparameters["alpha"] == 0.25 + assert predictor._h["alpha"] == 0.25 + + +def test_interface_bases_declare_input_interface() -> None: + assert FeatureFreePredictor.input_interface == "feature_free" + assert MatrixPredictor.input_interface == "matrix" + assert BlockPredictor.input_interface == "block" + + +def test_builtin_predictor_interfaces_partition() -> None: + observed: dict[str, set[str]] = { + "feature_free": set(), + "matrix": set(), + "block": set(), + } + for name in list_predictors(): + cls = get_predictor(name) + matches = [base for base in LEAF_BASES if issubclass(cls, base)] + assert len(matches) == 1, (name, matches) + assert cls.input_interface == matches[0].input_interface + observed[cls.input_interface].add(name) + assert observed == EXPECTED diff --git a/tests/components/predictors/abstract/test_block.py b/tests/components/predictors/abstract/test_block.py new file mode 100644 index 000000000..882be7011 --- /dev/null +++ b/tests/components/predictors/abstract/test_block.py @@ -0,0 +1,81 @@ +"""Tests for the block-interface predictor base. + +The per-predictor interface assertions were carved out of +``literature/test_init.py``, which now keeps only the package-boundary policy +checks; the interface itself is a property of ``abstract/block.py``. +""" + +from __future__ import annotations + +import inspect + +import numpy as np +import pytest + +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + +BLOCK_PREDICTOR_NAMES = ( + "drugGNN", + "precily", + "srmf", + "molir", + "superfeltr", + "pharmaFormer", + "dipk", + "sparsego", +) + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_block_predictor_declares_the_block_input_interface() -> None: + assert BlockPredictor.input_interface == "block" + + +def test_block_predictor_derives_from_the_shared_predictor_base() -> None: + assert issubclass(BlockPredictor, Predictor) + + +def test_block_predictor_cannot_be_instantiated_directly() -> None: + with pytest.raises(TypeError): + BlockPredictor() + + +@pytest.mark.parametrize("method_name", ["_fit", "_predict"]) +def test_block_predictor_leaves_fit_and_predict_abstract(method_name: str) -> None: + assert method_name in BlockPredictor.__abstractmethods__ + assert getattr(BlockPredictor, method_name).__isabstractmethod__ is True + + +def test_block_predictor_does_not_flatten_batches_like_the_matrix_base() -> None: + assert not issubclass(BlockPredictor, MatrixPredictor) + assert BlockPredictor.input_interface != MatrixPredictor.input_interface + + +def test_block_predictor_subclass_supplying_both_hooks_is_concrete() -> None: + class _Constant(BlockPredictor): + def _fit(self, batch: ModelInputBatch) -> None: + self._value = 1.0 + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.full(len(batch.cell_line_ids), self._value) + + assert not inspect.isabstract(_Constant) + assert _Constant.input_interface == "block" + + +@pytest.mark.parametrize("name", BLOCK_PREDICTOR_NAMES) +def test_registered_block_predictors_use_only_the_block_interface(name: str) -> None: + cls = get_predictor(name) + + assert issubclass(cls, BlockPredictor) + assert not issubclass(cls, MatrixPredictor) + assert cls.input_interface == "block" diff --git a/tests/components/predictors/abstract/test_feature_free.py b/tests/components/predictors/abstract/test_feature_free.py new file mode 100644 index 000000000..a7f3c5879 --- /dev/null +++ b/tests/components/predictors/abstract/test_feature_free.py @@ -0,0 +1,11 @@ +"""Tests for FeatureFreePredictor.""" + +from __future__ import annotations + +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor + + +def test_feature_free_predictor_defaults() -> None: + assert issubclass(FeatureFreePredictor, Predictor) + assert FeatureFreePredictor.input_interface == "feature_free" diff --git a/tests/components/predictors/abstract/test_matrix.py b/tests/components/predictors/abstract/test_matrix.py new file mode 100644 index 000000000..a7cc615ca --- /dev/null +++ b/tests/components/predictors/abstract/test_matrix.py @@ -0,0 +1,91 @@ +"""Tests for the matrix predictor interface and the ``ModelInputBatch`` contract it relies on.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +def _ids(n: int) -> np.ndarray: + return np.array([f"id_{i}" for i in range(n)]) + + +def test_matrix_predictor_declares_the_matrix_input_interface() -> None: + assert MatrixPredictor.input_interface == "matrix" + assert issubclass(MatrixPredictor, Predictor) + + +@pytest.mark.parametrize("method_name", ["_fit_matrix", "_predict_matrix"]) +def test_matrix_predictor_leaves_the_matrix_hooks_abstract(method_name: str) -> None: + assert method_name in MatrixPredictor.__abstractmethods__ + + +def test_matrix_predictor_cannot_build_a_design_matrix_without_a_response() -> None: + class _Recording(MatrixPredictor): + def _fit_matrix(self, x: np.ndarray, y: np.ndarray) -> None: + raise AssertionError("should not be reached") + + def _predict_matrix(self, x: np.ndarray) -> np.ndarray: + return np.zeros(len(x)) + + batch = ModelInputBatch( + cell_line_ids=_ids(2), + drug_ids=_ids(2), + response=None, + cell_line_entity_ids=np.array(["e0", "e1"]), + drug_entity_ids=None, + cell_line_features=np.ones((2, 1)), + drug_features=None, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + drug_pair_idx=None, + ) + + with pytest.raises(ValueError, match="response is required to build a feature matrix"): + _Recording()._fit(batch) + + +def test_registered_neural_network_predictor_uses_only_the_matrix_interface() -> None: + register_builtin_components() + + cls = get_predictor("neuralNetwork") + + assert issubclass(cls, MatrixPredictor) + assert not issubclass(cls, BlockPredictor) + assert cls.input_interface == "matrix" + + +def test_batch_rejects_response_length_mismatch() -> None: + with pytest.raises(ValueError, match="response length"): + ModelInputBatch( + cell_line_ids=_ids(3), + drug_ids=_ids(3), + response=np.ones(2), + cell_line_entity_ids=np.empty(0), + drug_entity_ids=None, + cell_line_features=np.empty((0, 0)), + drug_features=None, + cell_line_pair_idx=np.zeros(3, dtype=np.int64), + drug_pair_idx=None, + ) + + +def test_batch_rejects_pair_idx_length_mismatch() -> None: + with pytest.raises(ValueError, match="cell_line_pair_idx length"): + ModelInputBatch( + cell_line_ids=_ids(3), + drug_ids=_ids(3), + response=np.ones(3), + cell_line_entity_ids=np.empty(0), + drug_entity_ids=None, + cell_line_features=np.empty((0, 0)), + drug_features=None, + cell_line_pair_idx=np.zeros(2, dtype=np.int64), + drug_pair_idx=None, + ) diff --git a/tests/components/predictors/literature/_helpers.py b/tests/components/predictors/literature/_helpers.py new file mode 100644 index 000000000..adb939e52 --- /dev/null +++ b/tests/components/predictors/literature/_helpers.py @@ -0,0 +1,84 @@ +"""Shared batch factory for the literature predictor tests. + +Every literature predictor is exercised against the same shape of input - two +cell lines, two drugs, the four pairs between them, and a checkpoint directory - +so each of those test files had written out the same twelve-keyword +``ModelInputBatch.from_response`` call. What actually differs per predictor is +which feature blocks it consumes, and that is all a caller passes here. + +Plain ``_``-prefixed module, per the test-layout rules in ``AGENTS.md``: the +underscore keeps it out of collection, and the mirror policy walks ``drevalpy/`` +only, so no mirrored test is demanded for it. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from drevalpy.components.contracts.training_context import TrainingContext +from drevalpy.types.data.batch.feature_block import FeatureBlock +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.batch.response_batch import ResponseBatch +from tests.models.synthetic_fixtures import multi_drug_response + +CELL_LINE_IDS = np.array(["cl1", "cl2"]) +DRUG_IDS = np.array(["d1", "d2"]) + +#: The four (cell line, drug) pairs of the 2x2 grid, in row-major order. +CELL_LINE_PAIR_IDX = np.array([0, 0, 1, 1]) +DRUG_PAIR_IDX = np.array([0, 1, 0, 1]) + +#: Distinguishes "use :data:`DRUG_PAIR_IDX`" from an explicit ``drug_pair_idx=None``, +#: which is how a single-drug-side predictor says it has no per-drug axis. +_DEFAULT = object() + + +def two_by_two_batch( + *, + cell_line_blocks: dict[str, FeatureBlock], + drug_blocks: dict[str, FeatureBlock], + response: ResponseBatch | None = None, + cell_line_pair_idx: np.ndarray | None = None, + drug_pair_idx: Any = _DEFAULT, + early_stopping_response: ResponseBatch | None = None, + checkpoint_dir: Any = ".", +) -> ModelInputBatch: + """Build a featurized batch over two cell lines and two drugs. + + :param cell_line_blocks: Cell-line blocks the predictor under test consumes. + :param drug_blocks: Drug blocks the predictor under test consumes. + :param response: Training responses; defaults to ``multi_drug_response()``. + :param cell_line_pair_idx: Per-pair cell-line row index; defaults to the 2x2 grid. + :param drug_pair_idx: Per-pair drug row index; defaults to the 2x2 grid. Pass + ``None`` explicitly for a predictor with no per-drug axis. + :param early_stopping_response: Optional early-stopping responses. + :param checkpoint_dir: Directory recorded on the ``TrainingContext``. + :returns: Featurized ``ModelInputBatch``. + """ + return ModelInputBatch.from_response( + multi_drug_response() if response is None else response, + cell_line_entity_ids=CELL_LINE_IDS, + drug_entity_ids=DRUG_IDS, + cell_line_features=np.empty((0, 0), dtype=np.float32), + drug_features=None, + cell_line_pair_idx=CELL_LINE_PAIR_IDX if cell_line_pair_idx is None else cell_line_pair_idx, + drug_pair_idx=DRUG_PAIR_IDX if drug_pair_idx is _DEFAULT else drug_pair_idx, + cell_line_blocks=cell_line_blocks, + drug_blocks=drug_blocks, + early_stopping_response=early_stopping_response, + training_context=TrainingContext(checkpoint_dir=checkpoint_dir), + ) + + +def early_stopping_response() -> ResponseBatch: + """Return a two-pair early-stopping response over the same entities. + + :returns: ``ResponseBatch`` for the early-stopping split. + """ + return ResponseBatch( + response=np.array([1.5, 2.5]), + cell_line_ids=CELL_LINE_IDS, + drug_ids=DRUG_IDS, + ) diff --git a/tests/components/predictors/literature/dipk/test_attention_utils.py b/tests/components/predictors/literature/dipk/test_attention_utils.py new file mode 100644 index 000000000..a8c38a5e5 --- /dev/null +++ b/tests/components/predictors/literature/dipk/test_attention_utils.py @@ -0,0 +1,83 @@ +"""Tests for the DIPK multi-head attention layer.""" + +from __future__ import annotations + +import pytest +import torch + +from drevalpy.components.predictors.literature.dipk.attention_utils import MultiHeadAttentionLayer + + +def _layer(*, hid_dim: int = 8, n_heads: int = 2, dropout: float = 0.0) -> MultiHeadAttentionLayer: + layer = MultiHeadAttentionLayer(hid_dim=hid_dim, n_heads=n_heads, dropout=dropout, device="cpu") + layer.eval() + return layer + + +def test_attention_layer_rejects_a_head_count_that_does_not_divide_the_hidden_dim() -> None: + with pytest.raises(ValueError, match="divisible by the number of heads"): + MultiHeadAttentionLayer(hid_dim=10, n_heads=4, dropout=0.0, device="cpu") + + +def test_attention_layer_splits_the_hidden_dim_evenly_across_heads() -> None: + layer = _layer(hid_dim=12, n_heads=3) + + assert layer.head_dim == 4 + + +def test_attention_layer_scale_is_the_square_root_of_the_head_dim() -> None: + layer = _layer(hid_dim=16, n_heads=4) + + assert layer.scale.item() == pytest.approx(2.0) + + +def test_attention_layer_output_keeps_the_query_sequence_length() -> None: + layer = _layer(hid_dim=8, n_heads=2) + query = torch.randn(3, 1, 8) + key = torch.randn(3, 5, 8) + + output, attention = layer(query, key, key) + + assert output.shape == (3, 1, 8) + assert attention.shape == (3, 2, 1, 5) + + +def test_attention_weights_sum_to_one_over_the_key_axis() -> None: + layer = _layer(hid_dim=8, n_heads=2) + query = torch.randn(2, 3, 8) + key = torch.randn(2, 3, 8) + + _, attention = layer(query, key, key) + + torch.testing.assert_close(attention.sum(dim=-1), torch.ones(2, 2, 3)) + + +def test_attention_mask_removes_masked_keys_from_the_weights() -> None: + layer = _layer(hid_dim=8, n_heads=2) + query = torch.randn(1, 1, 8) + key = torch.randn(1, 4, 8) + mask = torch.tensor([[[[1, 1, 0, 0]]]]) + + _, attention = layer(query, key, key, mask) + + assert attention[..., 2:].abs().max().item() == pytest.approx(0.0) + torch.testing.assert_close(attention[..., :2].sum(dim=-1), torch.ones(1, 2, 1)) + + +def test_attention_layer_is_differentiable() -> None: + layer = _layer(hid_dim=8, n_heads=2) + features = torch.randn(2, 3, 8) + + layer(features, features, features)[0].sum().backward() + + assert layer.fc_q.weight.grad is not None + + +def test_attention_layer_with_one_head_matches_its_hidden_dim() -> None: + layer = _layer(hid_dim=6, n_heads=1) + + output, attention = layer(torch.randn(2, 1, 6), torch.randn(2, 2, 6), torch.randn(2, 2, 6)) + + assert layer.head_dim == 6 + assert output.shape == (2, 1, 6) + assert attention.shape == (2, 1, 1, 2) diff --git a/tests/components/predictors/literature/dipk/test_gene_expression_encoder.py b/tests/components/predictors/literature/dipk/test_gene_expression_encoder.py new file mode 100644 index 000000000..d1700079c --- /dev/null +++ b/tests/components/predictors/literature/dipk/test_gene_expression_encoder.py @@ -0,0 +1,162 @@ +"""Tests for the DIPK gene-expression autoencoder helpers. + +The autoencoder trains on CPU here with a very small matrix and one or two +epochs; the point is to cover the training loop, the collate/dataset plumbing, +and the encode path rather than to reach a useful reconstruction. +""" + +from __future__ import annotations + +import numpy as np +import torch +from torch.utils.data import DataLoader + +from drevalpy.components.predictors.literature.dipk.gene_expression_encoder import ( + CollateFn, + DataSet, + GeneExpressionDecoder, + GeneExpressionEncoder, + encode_gene_expression, + train_gene_expession_autoencoder, +) + +INPUT_DIM = 6 +SMALL_HIDDEN = [8, 4] + + +def _matrix(n_rows: int = 4, seed: int = 0) -> np.ndarray: + return np.random.default_rng(seed).normal(size=(n_rows, INPUT_DIM)).astype(np.float32) + + +def test_encoder_projects_rows_to_the_latent_dim() -> None: + encoder = GeneExpressionEncoder(INPUT_DIM, latent_dim=5, h_dims=SMALL_HIDDEN) + encoder.eval() + + with torch.no_grad(): + embedding = encoder(torch.randn(3, INPUT_DIM)) + + assert embedding.shape == (3, 5) + assert encoder.latent_dim == 5 + + +def test_encoder_output_is_non_negative_after_the_relu_bottleneck() -> None: + encoder = GeneExpressionEncoder(INPUT_DIM, latent_dim=4, h_dims=SMALL_HIDDEN) + encoder.eval() + + with torch.no_grad(): + embedding = encoder(torch.randn(3, INPUT_DIM)) + + assert (embedding >= 0).all() + + +def test_encoder_builds_one_block_per_hidden_dim() -> None: + encoder = GeneExpressionEncoder(INPUT_DIM, latent_dim=4, h_dims=SMALL_HIDDEN) + + assert len(encoder.encoder) == len(SMALL_HIDDEN) + assert encoder.bottleneck.in_features == SMALL_HIDDEN[-1] + + +def test_encoder_does_not_mutate_the_hidden_dims_argument() -> None: + h_dims = [8, 4] + + GeneExpressionEncoder(INPUT_DIM, latent_dim=4, h_dims=h_dims) + + assert h_dims == [8, 4] + + +def test_decoder_restores_the_input_width() -> None: + decoder = GeneExpressionDecoder(INPUT_DIM, latent_dim=5, h_dims=SMALL_HIDDEN) + decoder.eval() + + with torch.no_grad(): + reconstruction = decoder(torch.randn(3, 5)) + + assert reconstruction.shape == (3, INPUT_DIM) + + +def test_encoder_and_decoder_compose_into_a_reconstruction() -> None: + encoder = GeneExpressionEncoder(INPUT_DIM, latent_dim=4, h_dims=SMALL_HIDDEN) + decoder = GeneExpressionDecoder(INPUT_DIM, latent_dim=4, h_dims=SMALL_HIDDEN) + encoder.eval() + decoder.eval() + features = torch.randn(3, INPUT_DIM) + + with torch.no_grad(): + reconstruction = decoder(encoder(features)) + + assert reconstruction.shape == features.shape + + +def test_collate_fn_stacks_row_tensors_into_a_batch() -> None: + rows = [torch.ones(INPUT_DIM), torch.zeros(INPUT_DIM)] + + batch = CollateFn()(rows) + + assert batch.shape == (2, INPUT_DIM) + + +def test_dataset_reports_its_length_and_indexes_rows() -> None: + tensor = torch.arange(12, dtype=torch.float32).reshape(4, 3) + + dataset = DataSet(tensor) + + assert len(dataset) == 4 + torch.testing.assert_close(dataset[2], tensor[2]) + + +def test_dataset_and_collate_fn_work_together_in_a_dataloader() -> None: + tensor = torch.arange(12, dtype=torch.float32).reshape(4, 3) + + loader = DataLoader(DataSet(tensor), batch_size=2, shuffle=False, collate_fn=CollateFn()) + batches = list(loader) + + assert len(batches) == 2 + assert batches[0].shape == (2, 3) + + +def test_encode_gene_expression_keeps_the_matrix_shape() -> None: + encoder = GeneExpressionEncoder(INPUT_DIM, latent_dim=4, h_dims=SMALL_HIDDEN) + + encoded = encode_gene_expression(_matrix(3), encoder) + + assert encoded.shape == (3, 4) + assert isinstance(encoded, np.ndarray) + + +def test_encode_gene_expression_squeezes_a_single_vector_back_to_one_dimension() -> None: + encoder = GeneExpressionEncoder(INPUT_DIM, latent_dim=4, h_dims=SMALL_HIDDEN) + encoder.eval() + + encoded = encode_gene_expression(_matrix(2)[0], encoder) + + assert encoded.shape == (4,) + + +def test_encode_gene_expression_leaves_the_encoder_in_eval_mode() -> None: + encoder = GeneExpressionEncoder(INPUT_DIM, latent_dim=4, h_dims=SMALL_HIDDEN) + encoder.train() + + encode_gene_expression(_matrix(3), encoder) + + assert encoder.training is False + + +def test_train_autoencoder_returns_an_encoder_in_eval_mode() -> None: + train = _matrix(4, seed=1) + validation = _matrix(4, seed=2) + + encoder = train_gene_expession_autoencoder(train, validation, epochs_autoencoder=1) + + assert isinstance(encoder, GeneExpressionEncoder) + assert encoder.training is False + + +def test_train_autoencoder_produces_an_encoder_usable_for_encoding() -> None: + train = _matrix(4, seed=3) + validation = _matrix(4, seed=4) + + encoder = train_gene_expession_autoencoder(train, validation, epochs_autoencoder=2) + encoded = encode_gene_expression(train, encoder) + + assert encoded.shape == (4, encoder.latent_dim) + assert np.isfinite(encoded).all() diff --git a/tests/components/predictors/literature/dipk/test_model_utils.py b/tests/components/predictors/literature/dipk/test_model_utils.py new file mode 100644 index 000000000..c8151a780 --- /dev/null +++ b/tests/components/predictors/literature/dipk/test_model_utils.py @@ -0,0 +1,144 @@ +"""Tests for the DIPK attention, dense, and combined predictor modules.""" + +from __future__ import annotations + +import pytest +import torch +from torch import nn + +from drevalpy.components.predictors.literature.dipk.model_utils import ( + DEVICE, + AttentionLayer, + DenseLayers, + Predictor, + features_dim_bionic, + features_dim_gene, +) + +FC_LAYER_DIM = [16, 8, 4, 4, 4, 4] +HIDDEN_DIM = 768 + + +def _molgnet(batch_size: int, seq_len: int) -> torch.Tensor: + return torch.randn(batch_size, seq_len, HIDDEN_DIM) + + +def test_device_follows_cuda_availability() -> None: + expected = "cuda" if torch.cuda.is_available() else "cpu" + + assert DEVICE.type == expected + + +def test_module_level_feature_dims_match_the_bionic_and_gene_encoders() -> None: + assert features_dim_gene == 512 + assert features_dim_bionic == 512 + + +def test_attention_layer_projects_molgnet_features_to_the_hidden_dim() -> None: + layer = AttentionLayer(heads=1) + layer.eval() + + output = layer( + _molgnet(2, 5), + torch.ones(2, 5), + torch.randn(2, features_dim_gene), + torch.randn(2, features_dim_bionic), + ) + + assert output.shape == (2, HIDDEN_DIM) + + +def test_attention_layer_squeezes_a_single_row_batch_to_one_dimension() -> None: + layer = AttentionLayer(heads=1) + layer.eval() + + output = layer( + _molgnet(1, 3), + torch.ones(1, 3), + torch.randn(1, features_dim_gene), + torch.randn(1, features_dim_bionic), + ) + + assert output.shape == (HIDDEN_DIM,) + + +def test_attention_layer_rejects_a_head_count_that_does_not_divide_the_hidden_dim() -> None: + with pytest.raises(ValueError, match="divisible by the number of heads"): + AttentionLayer(heads=5) + + +def test_dense_layers_reduce_attention_output_to_one_scalar_per_row() -> None: + dense = DenseLayers(fc_layer_num=3, fc_layer_dim=FC_LAYER_DIM, dropout_rate=0.0) + dense.eval() + + output = dense( + torch.randn(4, HIDDEN_DIM), + torch.randn(4, features_dim_gene), + torch.randn(4, features_dim_bionic), + ) + + assert output.shape == (4, 1) + + +def test_dense_layers_add_a_batch_axis_to_a_one_dimensional_input() -> None: + dense = DenseLayers(fc_layer_num=3, fc_layer_dim=FC_LAYER_DIM, dropout_rate=0.0) + dense.eval() + + output = dense( + torch.randn(HIDDEN_DIM), + torch.randn(1, features_dim_gene), + torch.randn(1, features_dim_bionic), + ) + + assert output.shape == (1, 1) + + +def test_dense_layers_build_one_dropout_per_requested_layer() -> None: + dense = DenseLayers(fc_layer_num=4, fc_layer_dim=FC_LAYER_DIM, dropout_rate=0.25) + + assert len(dense.dropout_layers) == 4 + assert {layer.p for layer in dense.dropout_layers} == {0.25} + + +def test_dense_layers_size_the_output_head_from_the_penultimate_layer_dim() -> None: + dense = DenseLayers(fc_layer_num=3, fc_layer_dim=FC_LAYER_DIM, dropout_rate=0.0) + + assert isinstance(dense.fc_output, nn.Linear) + assert dense.fc_output.in_features == FC_LAYER_DIM[1] + assert dense.fc_output.out_features == 1 + + +def test_predictor_scores_one_response_per_pair() -> None: + predictor = Predictor(heads=1, fc_layer_num=3, fc_layer_dim=FC_LAYER_DIM, dropout_rate=0.0) + predictor.eval() + + with torch.no_grad(): + output = predictor( + _molgnet(2, 4), + torch.randn(2, features_dim_gene), + torch.randn(2, features_dim_bionic), + torch.ones(2, 4), + ) + + assert output.shape == (2, 1) + assert torch.isfinite(output).all() + + +def test_predictor_composes_the_attention_and_dense_stages() -> None: + predictor = Predictor(heads=1, fc_layer_num=3, fc_layer_dim=FC_LAYER_DIM, dropout_rate=0.1) + + assert isinstance(predictor.attention_layer, AttentionLayer) + assert isinstance(predictor.dense_layers, DenseLayers) + + +def test_predictor_gradients_reach_the_attention_stage() -> None: + predictor = Predictor(heads=1, fc_layer_num=3, fc_layer_dim=FC_LAYER_DIM, dropout_rate=0.0) + + predictor( + _molgnet(2, 3), + torch.randn(2, features_dim_gene), + torch.randn(2, features_dim_bionic), + torch.ones(2, 3), + ).sum().backward() + + assert predictor.attention_layer.fc_layer_0.weight.grad is not None diff --git a/tests/components/predictors/literature/dipk/test_predictor.py b/tests/components/predictors/literature/dipk/test_predictor.py new file mode 100644 index 000000000..d0cab289f --- /dev/null +++ b/tests/components/predictors/literature/dipk/test_predictor.py @@ -0,0 +1,177 @@ +"""Tests for the DIPK block predictor. + +The training loop itself needs the network-gated BIONIC and MolGNet artifacts, so +what is exercised here is everything around it: sample construction, the collate +function, the list-backed dataset, and the state/guard paths. Those are also the +parts that carry the ``torch`` imports this module defers (see +``tests/test_import_cost_policy.py``), so they are worth pinning directly. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import numpy as np +import pytest +import torch + +from drevalpy.components.predictors.literature.dipk.predictor import ( + DIPKPredictor, + _CollateFn, + _DIPKDataset, +) +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.registry._builtins import ensure_predictor_registered +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.types.data.batch.feature_block import numeric_feature_block, ragged_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from tests.components.predictors.literature._helpers import two_by_two_batch + + +def _dipk_batch() -> ModelInputBatch: + """Build a batch with the three blocks DIPK consumes. + + :returns: Featurized ``ModelInputBatch`` with two cell lines and two drugs. + """ + molgnet = np.empty(2, dtype=object) + molgnet[:] = [ + np.arange(6, dtype=np.float32).reshape(3, 2), + np.arange(4, dtype=np.float32).reshape(2, 2), + ] + return two_by_two_batch( + cell_line_blocks={ + "gene_expression": numeric_feature_block(np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32)), + "bionic_features": numeric_feature_block(np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32)), + }, + drug_blocks={"molgnet_features": ragged_feature_block(molgnet)}, + checkpoint_dir=Path(tempfile.mkdtemp()), + ) + + +def test_dipk_predictor_registry_name() -> None: + ensure_predictor_registered("dipk") + assert get_predictor("dipk") is DIPKPredictor + + +def test_default_hyperparameters_cover_the_training_loop_knobs() -> None: + defaults = DIPKPredictor.get_default_hyperparameters() + + assert {"batch_size", "lr", "epochs", "patience"} <= set(defaults) + + +class TestBuildSamples: + def test_one_sample_per_pair_with_the_three_blocks(self) -> None: + predictor = DIPKPredictor() + batch = _dipk_batch() + + samples = predictor._build_samples(batch.cell_line_pair_idx, batch.drug_pair_idx, batch) + + assert len(samples) == 4 + assert set(samples[0]) == {"molgnet_features", "gene_expression", "bionic_features"} + + def test_pair_indices_select_the_entity_rows(self) -> None: + predictor = DIPKPredictor() + batch = _dipk_batch() + + samples = predictor._build_samples(batch.cell_line_pair_idx, batch.drug_pair_idx, batch) + + # Pair 2 is (cell line 1, drug 0). + torch.testing.assert_close(samples[2]["gene_expression"], torch.tensor([0.3, 0.4])) + assert samples[2]["molgnet_features"].shape == (3, 2) + + def test_a_response_adds_the_target_entry(self) -> None: + predictor = DIPKPredictor() + batch = _dipk_batch() + + samples = predictor._build_samples( + batch.cell_line_pair_idx, + batch.drug_pair_idx, + batch, + response=np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32), + ) + + assert samples[3]["ic50"].tolist() == [4.0] + + +class TestCollateFn: + def _samples(self) -> list[dict[str, torch.Tensor]]: + return [ + { + "molgnet_features": torch.ones(3, 2), + "gene_expression": torch.tensor([0.1, 0.2]), + "bionic_features": torch.tensor([1.0, 2.0]), + "ic50": torch.tensor([5.0]), + }, + { + "molgnet_features": torch.ones(1, 2), + "gene_expression": torch.tensor([0.3, 0.4]), + "bionic_features": torch.tensor([3.0, 4.0]), + "ic50": torch.tensor([6.0]), + }, + ] + + def test_ragged_atoms_are_zero_padded_to_the_longest_molecule(self) -> None: + collated = _CollateFn(train=False)(self._samples()) + + assert collated["molgnet_features"].shape == (2, 3, 2) + assert collated["molgnet_features"][1, 1:].abs().sum().item() == 0.0 + + def test_the_mask_marks_only_the_real_atoms(self) -> None: + collated = _CollateFn(train=False)(self._samples()) + + assert collated["molgnet_mask"].dtype is torch.bool + assert collated["molgnet_mask"][1].tolist() == [True, False, False] + + def test_targets_are_included_only_in_training_mode(self) -> None: + train = _CollateFn(train=True)(self._samples()) + predict = _CollateFn(train=False)(self._samples()) + + assert train["ic50_values"].flatten().tolist() == [5.0, 6.0] + assert "ic50_values" not in predict + + +class TestDIPKDataset: + def test_length_is_the_sample_count(self) -> None: + assert len(_DIPKDataset([{"a": torch.zeros(1)}, {"a": torch.ones(1)}])) == 2 + + def test_indexing_returns_the_stored_sample(self) -> None: + second = {"a": torch.ones(1)} + + assert _DIPKDataset([{"a": torch.zeros(1)}, second])[1] is second + + def test_it_drives_a_dataloader_without_subclassing_torch_dataset(self) -> None: + """The class deliberately has no ``torch.utils.data.Dataset`` base.""" + from torch.utils.data import DataLoader + + dataset = _DIPKDataset([{"a": torch.zeros(1)}, {"a": torch.ones(1)}]) + + loader = DataLoader(dataset, batch_size=2, shuffle=False, collate_fn=list) + + assert len(next(iter(loader))) == 2 + + +class TestGuards: + def test_predict_before_fit_returns_all_nan(self) -> None: + predictions = DIPKPredictor()._predict(_dipk_batch()) + + assert np.isnan(predictions).all() + assert len(predictions) == 4 + + def test_fit_without_early_stopping_data_is_rejected(self) -> None: + with pytest.raises(ValueError, match="early stopping data"): + DIPKPredictor()._fit(_dipk_batch()) + + def test_is_fitted_is_false_and_state_is_empty_before_training(self) -> None: + predictor = DIPKPredictor() + + assert predictor.is_fitted() is False + assert predictor.get_state() == {} + + def test_set_state_requires_hyperparameters(self) -> None: + with pytest.raises(PredictorStateError, match="hyperparameters dict"): + DIPKPredictor().set_state({"payload": b"x"}) + + def test_set_state_requires_payload_bytes(self) -> None: + with pytest.raises(PredictorStateError, match="payload bytes"): + DIPKPredictor().set_state({"hyperparameters": {}, "payload": "not-bytes"}) diff --git a/tests/components/predictors/literature/druggnn/test_algorithm.py b/tests/components/predictors/literature/druggnn/test_algorithm.py new file mode 100644 index 000000000..93e63abc0 --- /dev/null +++ b/tests/components/predictors/literature/druggnn/test_algorithm.py @@ -0,0 +1,166 @@ +"""Tests for the DrugGNN graph network and its LightningModule wrapper. + +``druggnn/test_predictor.py`` covers the predictor lifecycle end to end; this +file exercises ``algorithm.py`` directly so the network and module contracts are +asserted without a full Lightning fit. +""" + +from __future__ import annotations + +import pytest +import torch +from torch.optim import Adam +from torch_geometric.data import Data + +from drevalpy.components.predictors.literature.druggnn.algorithm import DrugGNNModule, DrugGraphNet + +NUM_NODE_FEATURES = 5 +NUM_CELL_FEATURES = 7 + + +def _drug_graph(*, n_graphs: int = 2, nodes_per_graph: int = 3) -> Data: + n_nodes = n_graphs * nodes_per_graph + edges = [ + [node, node + 1] + for graph in range(n_graphs) + for node in range(graph * nodes_per_graph, (graph + 1) * nodes_per_graph - 1) + ] + return Data( + x=torch.randn(n_nodes, NUM_NODE_FEATURES), + edge_index=torch.tensor(edges, dtype=torch.long).t().contiguous(), + batch=torch.arange(n_graphs).repeat_interleave(nodes_per_graph), + ) + + +def _net(*, hidden_dim: int = 8, dropout: float = 0.0) -> DrugGraphNet: + net = DrugGraphNet( + num_node_features=NUM_NODE_FEATURES, + num_cell_features=NUM_CELL_FEATURES, + hidden_dim=hidden_dim, + dropout=dropout, + ) + net.eval() + return net + + +def test_graph_net_returns_one_flat_prediction_per_graph() -> None: + net = _net() + + with torch.no_grad(): + output = net(_drug_graph(n_graphs=3), torch.randn(3, NUM_CELL_FEATURES)) + + assert output.shape == (3,) + assert torch.isfinite(output).all() + + +def test_graph_net_widens_the_convolution_stack_from_the_hidden_dim() -> None: + net = _net(hidden_dim=8) + + assert net.conv1.out_channels == 8 + assert net.conv2.out_channels == 16 + assert net.conv3.out_channels == 32 + assert net.drug_embed_fc.in_features == 32 + + +def test_graph_net_combines_equal_width_drug_and_cell_embeddings() -> None: + net = _net(hidden_dim=8) + + assert net.drug_embed_fc.out_features == 8 + assert net.cell_fc2.out_features == 8 + assert net.combiner_fc1.in_features == 16 + + +def test_graph_net_records_the_dropout_rate_for_functional_use() -> None: + net = _net(dropout=0.5) + + assert net.dropout == 0.5 + + +def test_graph_net_is_deterministic_in_eval_mode() -> None: + net = _net(dropout=0.5) + graph = _drug_graph() + cell_features = torch.randn(2, NUM_CELL_FEATURES) + + with torch.no_grad(): + first = net(graph, cell_features) + second = net(graph, cell_features) + + assert torch.allclose(first, second) + + +def test_graph_net_gradients_reach_the_first_convolution() -> None: + net = DrugGraphNet( + num_node_features=NUM_NODE_FEATURES, + num_cell_features=NUM_CELL_FEATURES, + hidden_dim=8, + dropout=0.0, + ) + + net(_drug_graph(), torch.randn(2, NUM_CELL_FEATURES)).sum().backward() + + assert net.conv1.lin.weight.grad is not None + + +def _module(**overrides: object) -> DrugGNNModule: + kwargs: dict[str, object] = { + "num_node_features": NUM_NODE_FEATURES, + "num_cell_features": NUM_CELL_FEATURES, + "hidden_dim": 8, + "dropout": 0.0, + } + kwargs.update(overrides) + return DrugGNNModule(**kwargs) # type: ignore[arg-type] + + +def test_module_saves_its_construction_hyperparameters() -> None: + module = _module(learning_rate=0.005) + + assert module.hparams["num_node_features"] == NUM_NODE_FEATURES + assert module.hparams["hidden_dim"] == 8 + assert module.hparams["learning_rate"] == pytest.approx(0.005) + + +def test_module_forward_unpacks_the_three_element_batch() -> None: + module = _module() + module.eval() + batch = (_drug_graph(), torch.randn(2, NUM_CELL_FEATURES), torch.randn(2)) + + with torch.no_grad(): + output = module(batch) + + assert output.shape == (2,) + + +def test_module_training_step_returns_a_scalar_mse_loss() -> None: + module = _module() + batch = (_drug_graph(), torch.randn(2, NUM_CELL_FEATURES), torch.randn(2)) + + loss = module.training_step(batch, batch_idx=0) + + assert loss.ndim == 0 + assert loss.item() >= 0.0 + + +def test_module_validation_step_returns_nothing() -> None: + module = _module() + batch = (_drug_graph(), torch.randn(2, NUM_CELL_FEATURES), torch.randn(2)) + + assert module.validation_step(batch, batch_idx=0) is None + + +def test_module_predict_step_matches_forward() -> None: + module = _module() + module.eval() + batch = (_drug_graph(), torch.randn(2, NUM_CELL_FEATURES), torch.randn(2)) + + with torch.no_grad(): + assert torch.allclose(module.predict_step(batch, batch_idx=0), module(batch)) + + +def test_module_configures_adam_at_the_requested_learning_rate() -> None: + module = _module(learning_rate=0.01) + + optimizer = module.configure_optimizers() + + assert isinstance(optimizer, Adam) + assert optimizer.param_groups[0]["lr"] == pytest.approx(0.01) diff --git a/tests/components/predictors/literature/druggnn/test_predictor.py b/tests/components/predictors/literature/druggnn/test_predictor.py new file mode 100644 index 000000000..f2ddbd651 --- /dev/null +++ b/tests/components/predictors/literature/druggnn/test_predictor.py @@ -0,0 +1,91 @@ +"""Smoke test mirror for druggnn predictor package.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import numpy as np +import pytest +import torch +from torch_geometric.data import Data + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.literature.druggnn.predictor import DrugGNNPredictor +from drevalpy.registry._builtins import ensure_predictor_registered, register_builtin_components +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.types.data.batch.feature_block import graph_feature_block, numeric_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from tests.components.predictors.literature._helpers import early_stopping_response, two_by_two_batch + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def _drug_graph(*, num_features: int = 9) -> Data: + return Data( + x=torch.randn(4, num_features), + edge_index=torch.tensor([[0, 1, 2], [1, 2, 3]], dtype=torch.long), + batch=torch.zeros(4, dtype=torch.long), + ) + + +def _druggnn_batch(*, with_early_stopping: bool = False) -> ModelInputBatch: + graphs = np.empty(2, dtype=object) + graphs[:] = [_drug_graph(), _drug_graph()] + return two_by_two_batch( + cell_line_blocks={ + "gene_expression": numeric_feature_block(np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]])), + }, + drug_blocks={"drug_graph": graph_feature_block(graphs)}, + cell_line_pair_idx=np.zeros(4, dtype=np.int64), + drug_pair_idx=None, + early_stopping_response=early_stopping_response() if with_early_stopping else None, + checkpoint_dir=Path(tempfile.mkdtemp()), + ) + + +def test_druggnn_predictor_registry_name() -> None: + ensure_predictor_registered("drugGNN") + assert get_predictor("drugGNN") is DrugGNNPredictor + + +def test_druggnn_requires_graph_drug_contract() -> None: + cls = get_predictor("drugGNN") + assert cls.drug_contract.format == FeatureFormat.GRAPH + assert cls.required_drug_blocks == ("drug_graph",) + assert cls.supports_early_stopping is True + + +def test_druggnn_delegates_training_to_lightning() -> None: + predictor = DrugGNNPredictor( + hyperparameters={"epochs": 1, "batch_size": 2, "num_workers": 0}, + ) + batch = _druggnn_batch(with_early_stopping=True) + predictor.fit(batch) + assert predictor.is_fitted() + assert predictor._model is not None + + +def test_druggnn_supports_early_stopping_flag() -> None: + assert DrugGNNPredictor.supports_early_stopping is True + + +def test_druggnn_round_trip_state() -> None: + predictor = DrugGNNPredictor( + hyperparameters={"epochs": 1, "batch_size": 2, "num_workers": 0}, + ) + batch = _druggnn_batch() + predictor.fit(batch) + assert predictor.is_fitted() + assert predictor._model is not None + original_weight = next(predictor._model.parameters()).detach().cpu() + + restored = DrugGNNPredictor() + restored.set_state(predictor.get_state()) + assert restored.is_fitted() + assert restored._model is not None + restored_weight = next(restored._model.parameters()).detach().cpu() + assert torch.allclose(original_weight, restored_weight) diff --git a/tests/components/predictors/literature/molir/test_omics.py b/tests/components/predictors/literature/molir/test_omics.py new file mode 100644 index 000000000..b4492d81d --- /dev/null +++ b/tests/components/predictors/literature/molir/test_omics.py @@ -0,0 +1,48 @@ +"""Tests for the lightning-free MOLIR omics helpers.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.predictors.literature.molir._omics import _realign_omic_matrix + + +def test_realign_omic_matrix_realigns_columns_and_fills_missing() -> None: + model_features = ["g1", "g2"] + incoming_features = ["g0", "g1", "g2"] + values = np.array([[1.0, 2.0, 3.0]]) + + result = _realign_omic_matrix(values, model_features, incoming_features) + + assert result.shape == (1, 2) + np.testing.assert_allclose(result, [[2.0, 3.0]]) + + +def test_realign_omic_matrix_reorders_columns() -> None: + model_features = ["c2", "c1"] + incoming_features = ["c0", "c1", "c2"] + values = np.array([[6.0, 7.0, 8.0]]) + + result = _realign_omic_matrix(values, model_features, incoming_features) + + assert result.shape == (1, 2) + np.testing.assert_allclose(result, [[8.0, 7.0]]) + + +def test_realign_omic_matrix_fills_missing_with_zeros() -> None: + model_features = ["a", "b", "c"] + incoming_features = ["b", "d"] + values = np.array([[10.0, 20.0]]) + + result = _realign_omic_matrix(values, model_features, incoming_features) + + assert result.shape == (1, 3) + np.testing.assert_allclose(result, [[0.0, 10.0, 0.0]]) + + +def test_realign_omic_matrix_passes_matching_width_through_unchanged() -> None: + values = np.array([[1.0, 2.0]]) + + result = _realign_omic_matrix(values, ["a", "b"], ["a", "b"]) + + assert result is values diff --git a/tests/components/predictors/literature/molir/test_predictor.py b/tests/components/predictors/literature/molir/test_predictor.py new file mode 100644 index 000000000..86bbd99c2 --- /dev/null +++ b/tests/components/predictors/literature/molir/test_predictor.py @@ -0,0 +1,12 @@ +"""Smoke test mirror for molir predictor package.""" + +from __future__ import annotations + +from drevalpy.components.predictors.literature.molir.predictor import MOLIRPredictor +from drevalpy.registry._builtins import ensure_predictor_registered +from drevalpy.registry.predictor import get as get_predictor + + +def test_molir_predictor_registry_name() -> None: + ensure_predictor_registered("molir") + assert get_predictor("molir") is MOLIRPredictor diff --git a/tests/components/predictors/literature/molir/test_utils.py b/tests/components/predictors/literature/molir/test_utils.py new file mode 100644 index 000000000..86180476d --- /dev/null +++ b/tests/components/predictors/literature/molir/test_utils.py @@ -0,0 +1,54 @@ +"""Tests for MOLIR utility helpers. + +The ``_realign_omic_matrix`` behaviour tests live in ``test_omics.py`` since the +helper was split into ``molir/_omics.py`` so the registered predictors could +reach it without importing ``pytorch_lightning``; what remains of it here is the +guard on the compatibility re-export ``utils.py`` still exposes. + +The loader construction that used to be tested here moved to the shared +``literature/_omics_loaders.py``, which MOLIR and SuperFELTR both call; its +behaviour is pinned through ``train_superfeltr_model`` in +``superfeltr/test_utils.py``, since that is the public entry point reaching it +without a Lightning fit per case. +""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.predictors.literature.molir import _omics, utils + + +def test_utils_reexports_the_realign_helper_from_omics() -> None: + assert utils._realign_omic_matrix is _omics._realign_omic_matrix + + +def test_generate_triplets_indices_picks_near_and_far_samples() -> None: + y = np.array([0.0, 0.05, 5.0]) + + positive, negative = utils.generate_triplets_indices(y, positive_range=0.1, negative_range=1.0, random_seed=0) + + assert positive.shape == negative.shape == (3,) + # The two clustered responses are each other's positive; the outlier is their negative. + assert positive[0] == 1 + assert positive[1] == 0 + assert negative[0] == negative[1] == 2 + + +def test_generate_triplets_indices_falls_back_to_the_sample_itself_for_a_single_response() -> None: + """A one-element validation split has no other sample to draw a triplet from.""" + positive, negative = utils.generate_triplets_indices( + np.array([2.0]), positive_range=0.1, negative_range=1.0, random_seed=0 + ) + + assert positive.tolist() == [0] + assert negative.tolist() == [0] + + +def test_generate_triplets_indices_falls_back_to_the_closest_sample() -> None: + """No response sits inside the positive range, so the nearest one is used.""" + y = np.array([0.0, 3.0, 10.0]) + + positive, _ = utils.generate_triplets_indices(y, positive_range=0.0, negative_range=1.0, random_seed=0) + + assert positive.tolist() == [1, 0, 1] diff --git a/tests/components/predictors/literature/pharmaformer/test_model_utils.py b/tests/components/predictors/literature/pharmaformer/test_model_utils.py new file mode 100644 index 000000000..00a492645 --- /dev/null +++ b/tests/components/predictors/literature/pharmaformer/test_model_utils.py @@ -0,0 +1,131 @@ +"""Tests for the PharmaFormer feature extractor and transformer regressor.""" + +from __future__ import annotations + +import torch +from torch import nn + +from drevalpy.components.predictors.literature.pharmaformer.model_utils import ( + CombinedModel, + FeatureExtractor, + TransModel, +) + +SMILES_DIM = 128 + + +def _combined(*, gene_input_size: int = 6) -> CombinedModel: + model = CombinedModel( + gene_input_size=gene_input_size, + gene_hidden_size=8, + drug_hidden_size=8, + feature_dim=4, + nhead=2, + num_layers=1, + dim_feedforward=8, + dropout=0.0, + ) + model.eval() + return model + + +def test_feature_extractor_concatenates_the_gene_and_drug_branches() -> None: + extractor = FeatureExtractor(gene_input_size=6, gene_hidden_size=8, drug_hidden_size=5) + extractor.eval() + + output = extractor(torch.randn(3, 6), torch.randn(3, SMILES_DIM)) + + assert output.shape == (3, 13) + + +def test_feature_extractor_output_is_non_negative_after_relu() -> None: + extractor = FeatureExtractor(gene_input_size=4, gene_hidden_size=4, drug_hidden_size=4) + extractor.eval() + + output = extractor(torch.randn(2, 4), torch.randn(2, SMILES_DIM)) + + assert (output >= 0).all() + + +def test_feature_extractor_expects_the_fixed_bpe_smiles_width() -> None: + extractor = FeatureExtractor(gene_input_size=4, gene_hidden_size=4, drug_hidden_size=3) + + assert extractor.smiles_fc.in_features == SMILES_DIM + + +def test_trans_model_reduces_a_sequence_to_one_scalar_per_row() -> None: + model = TransModel(feature_dim=4, nhead=2, seq_len=3, dim_feedforward=8, dropout=0.0, num_layers=1) + model.eval() + + output = model(torch.randn(5, 3, 4)) + + assert output.shape == (5, 1) + + +def test_trans_model_stacks_the_requested_number_of_encoder_layers() -> None: + model = TransModel(feature_dim=4, nhead=2, seq_len=3, dim_feedforward=8, dropout=0.0, num_layers=2) + + assert model.transformer_encoder.num_layers == 2 + + +def test_trans_model_head_consumes_the_flattened_sequence() -> None: + model = TransModel(feature_dim=4, nhead=2, seq_len=3, dim_feedforward=8, dropout=0.0, num_layers=1) + + first_linear = next(layer for layer in model.output if isinstance(layer, nn.Linear)) + + assert first_linear.in_features == 3 * 4 + + +def test_combined_model_derives_the_sequence_length_from_the_hidden_sizes() -> None: + model = _combined() + + assert model.seq_len == 4 + assert model.feature_dim == 4 + + +def test_combined_model_predicts_one_scalar_per_pair() -> None: + model = _combined() + + with torch.no_grad(): + output = model(torch.randn(3, 6), torch.randn(3, SMILES_DIM)) + + assert output.shape == (3, 1) + assert torch.isfinite(output).all() + + +def test_combined_model_handles_a_single_row_batch() -> None: + model = _combined() + + with torch.no_grad(): + output = model(torch.randn(1, 6), torch.randn(1, SMILES_DIM)) + + assert output.shape == (1, 1) + + +def test_combined_model_is_deterministic_in_eval_mode() -> None: + model = _combined() + gene = torch.randn(2, 6) + smiles = torch.randn(2, SMILES_DIM) + + with torch.no_grad(): + first = model(gene, smiles) + second = model(gene, smiles) + + assert torch.allclose(first, second) + + +def test_combined_model_gradients_reach_the_gene_branch() -> None: + model = CombinedModel( + gene_input_size=6, + gene_hidden_size=8, + drug_hidden_size=8, + feature_dim=4, + nhead=2, + num_layers=1, + dim_feedforward=8, + dropout=0.0, + ) + + model(torch.randn(2, 6), torch.randn(2, SMILES_DIM)).sum().backward() + + assert model.feature_extractor.gene_fc1.weight.grad is not None diff --git a/tests/components/predictors/literature/pharmaformer/test_predictor.py b/tests/components/predictors/literature/pharmaformer/test_predictor.py new file mode 100644 index 000000000..f7f6367c1 --- /dev/null +++ b/tests/components/predictors/literature/pharmaformer/test_predictor.py @@ -0,0 +1,12 @@ +"""Smoke test mirror for pharmaformer predictor package.""" + +from __future__ import annotations + +from drevalpy.components.predictors.literature.pharmaformer.predictor import PharmaFormerPredictor +from drevalpy.registry._builtins import ensure_predictor_registered +from drevalpy.registry.predictor import get as get_predictor + + +def test_pharmaformer_predictor_registry_name() -> None: + ensure_predictor_registered("pharmaFormer") + assert get_predictor("pharmaFormer") is PharmaFormerPredictor diff --git a/tests/components/predictors/literature/precily/test_model_utils.py b/tests/components/predictors/literature/precily/test_model_utils.py new file mode 100644 index 000000000..7fba76ded --- /dev/null +++ b/tests/components/predictors/literature/precily/test_model_utils.py @@ -0,0 +1,69 @@ +"""Tests for the Precily feed-forward regressor.""" + +from __future__ import annotations + +import torch +from torch import nn + +from drevalpy.components.predictors.literature.precily.model_utils import PrecilyNetwork + + +def test_precily_network_predicts_one_scalar_per_row() -> None: + network = PrecilyNetwork(input_dim=12) + + output = network(torch.randn(5, 12)) + + assert output.shape == (5,) + + +def test_precily_network_squeezes_a_single_row_batch_to_one_element() -> None: + network = PrecilyNetwork(input_dim=6) + network.eval() + + output = network(torch.randn(1, 6)) + + assert output.shape == (1,) + + +def test_precily_network_reproduces_the_reference_layer_widths() -> None: + network = PrecilyNetwork(input_dim=7) + + linear_shapes = [(layer.in_features, layer.out_features) for layer in network.net if isinstance(layer, nn.Linear)] + + assert linear_shapes == [(7, 1429), (1429, 512), (512, 140), (140, 200), (200, 1)] + + +def test_precily_network_uses_the_requested_dropout_probability() -> None: + network = PrecilyNetwork(input_dim=4, dropout=0.42) + + probabilities = {layer.p for layer in network.net if isinstance(layer, nn.Dropout)} + + assert probabilities == {0.42} + + +def test_precily_network_defaults_to_dropout_of_one_tenth() -> None: + network = PrecilyNetwork(input_dim=4) + + probabilities = {layer.p for layer in network.net if isinstance(layer, nn.Dropout)} + + assert probabilities == {0.1} + + +def test_precily_network_is_deterministic_in_eval_mode() -> None: + network = PrecilyNetwork(input_dim=8) + network.eval() + features = torch.randn(3, 8) + + with torch.no_grad(): + first = network(features) + second = network(features) + + assert torch.allclose(first, second) + + +def test_precily_network_output_is_differentiable() -> None: + network = PrecilyNetwork(input_dim=5) + + network(torch.randn(4, 5)).sum().backward() + + assert network.net[0].weight.grad is not None diff --git a/tests/components/predictors/literature/precily/test_predictor.py b/tests/components/predictors/literature/precily/test_predictor.py new file mode 100644 index 000000000..d1c26aa84 --- /dev/null +++ b/tests/components/predictors/literature/precily/test_predictor.py @@ -0,0 +1,12 @@ +"""Smoke test mirror for precily predictor package.""" + +from __future__ import annotations + +from drevalpy.components.predictors.literature.precily.predictor import PrecilyPredictor +from drevalpy.registry._builtins import ensure_predictor_registered +from drevalpy.registry.predictor import get as get_predictor + + +def test_precily_predictor_registry_name() -> None: + ensure_predictor_registered("precily") + assert get_predictor("precily") is PrecilyPredictor diff --git a/tests/components/predictors/literature/sparsego/test_algorithm.py b/tests/components/predictors/literature/sparsego/test_algorithm.py new file mode 100644 index 000000000..7e21cfc35 --- /dev/null +++ b/tests/components/predictors/literature/sparsego/test_algorithm.py @@ -0,0 +1,21 @@ +"""Tests for SparseGO layer modules.""" + +from __future__ import annotations + +import pytest +import torch + +from drevalpy.components.predictors.literature.sparsego.algorithm import SparseLinearNew + + +def test_sparse_linear_new_with_explicit_connectivity() -> None: + connectivity = torch.tensor([[0, 1], [0, 1]], dtype=torch.long) + layer = SparseLinearNew(in_features=2, out_features=2, connectivity=connectivity) + output = layer(torch.tensor([[1.0, 2.0]])) + assert output.shape == (1, 2) + + +def test_sparse_linear_new_rejects_invalid_connectivity_shape() -> None: + bad = torch.tensor([[0, 1, 2]], dtype=torch.long) + with pytest.raises(ValueError, match="connectivity should be"): + SparseLinearNew(in_features=2, out_features=2, connectivity=bad) diff --git a/tests/components/predictors/literature/sparsego/test_predictor.py b/tests/components/predictors/literature/sparsego/test_predictor.py new file mode 100644 index 000000000..46bf612bf --- /dev/null +++ b/tests/components/predictors/literature/sparsego/test_predictor.py @@ -0,0 +1,129 @@ +"""Tests for the SparseGO block predictor. + +Training the GO-structured network needs a real ontology, so what is pinned here +is the surface around it: ontology-metadata parsing, active-view resolution, and +the state/guard paths. Those paths carry the deferred ``torch`` and ``networkx`` +imports this module relies on (see ``tests/test_import_cost_policy.py``). +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.predictors.literature.sparsego.predictor import ( + SparseGOPredictor, + _parse_ontology_metadata, + _resolve_active_view, +) +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.registry._builtins import ensure_predictor_registered +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.types.data.batch.feature_block import numeric_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from tests.components.predictors.literature._helpers import two_by_two_batch + +_LAYER_CONNECTIONS = [np.array([[0, 1], [1, 2]])] +_GENE2ID = {"TP53": 0, "EGFR": 1} + + +def _batch(cell_line_block_names: tuple[str, ...], *, metadata: dict | None = None) -> ModelInputBatch: + """Build a batch carrying the named cell-line blocks plus fingerprints. + + :param cell_line_block_names: Cell-line block names to populate. + :param metadata: Optional metadata attached to every cell-line block. + :returns: Featurized ``ModelInputBatch``. + """ + values = np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32) + return two_by_two_batch( + cell_line_blocks={name: numeric_feature_block(values, metadata=metadata) for name in cell_line_block_names}, + drug_blocks={"fingerprints": numeric_feature_block(np.eye(2, dtype=np.float32))}, + ) + + +def test_sparsego_predictor_registry_name() -> None: + ensure_predictor_registered("sparsego") + assert get_predictor("sparsego") is SparseGOPredictor + + +def test_default_hyperparameters_describe_the_go_layer_widths() -> None: + defaults = SparseGOPredictor.get_default_hyperparameters() + + assert {"num_neurons_per_GO", "num_neurons_drug", "drug_dim", "epochs"} <= set(defaults) + + +def test_hyperparameter_space_is_empty_so_hpo_skips_this_model() -> None: + assert SparseGOPredictor.get_hyperparameter_space() == {} + + +class TestParseOntologyMetadata: + def test_precomputed_structures_are_passed_through(self) -> None: + connections, gene2id, order = _parse_ontology_metadata( + { + "layer_connections": _LAYER_CONNECTIONS, + "gene2id_mapping_ont": _GENE2ID, + "ontology_gene_order": ["EGFR", "TP53"], + } + ) + + assert len(connections) == 1 + assert gene2id == _GENE2ID + assert order == ["EGFR", "TP53"] + + def test_a_missing_gene_order_falls_back_to_the_mapping_keys(self) -> None: + _, _, order = _parse_ontology_metadata( + {"layer_connections": _LAYER_CONNECTIONS, "gene2id_mapping_ont": _GENE2ID} + ) + + assert order == list(_GENE2ID) + + def test_neither_structures_nor_file_paths_is_an_error(self) -> None: + with pytest.raises(ValueError, match="pre-computed ontology structures"): + _parse_ontology_metadata({}) + + def test_partial_structures_without_file_paths_is_an_error(self) -> None: + """``layer_connections`` alone is not enough to build the network.""" + with pytest.raises(ValueError, match="ontology_file"): + _parse_ontology_metadata({"layer_connections": _LAYER_CONNECTIONS}) + + +class TestResolveActiveView: + @pytest.mark.parametrize("view", ["gene_expression", "mutations"]) + def test_exactly_one_supported_block_resolves_to_it(self, view: str) -> None: + assert _resolve_active_view(_batch((view,))) == view + + def test_both_supported_blocks_is_ambiguous_and_rejected(self) -> None: + with pytest.raises(ValueError, match="exactly one cell-line block"): + _resolve_active_view(_batch(("gene_expression", "mutations"))) + + def test_no_supported_block_is_rejected(self) -> None: + with pytest.raises(ValueError, match="exactly one cell-line block"): + _resolve_active_view(_batch(("proteomics",))) + + +class TestGuards: + def test_building_the_network_without_ontology_metadata_is_rejected(self) -> None: + with pytest.raises(ValueError, match="ontology metadata"): + SparseGOPredictor()._build_network() + + def test_fit_requires_metadata_on_the_active_block(self) -> None: + with pytest.raises(ValueError, match="requires ontology metadata"): + SparseGOPredictor()._fit(_batch(("gene_expression",))) + + def test_predict_before_fit_is_rejected(self) -> None: + with pytest.raises(ValueError, match="must be fitted before predict"): + SparseGOPredictor()._predict(_batch(("gene_expression",))) + + def test_is_fitted_is_false_and_state_is_empty_before_training(self) -> None: + predictor = SparseGOPredictor() + + assert predictor.is_fitted() is False + assert predictor.get_state() == {} + + def test_set_state_requires_a_payload_blob(self) -> None: + with pytest.raises(PredictorStateError, match="payload byte blob"): + SparseGOPredictor().set_state({}) + + def test_set_state_rejects_an_undeserializable_payload(self) -> None: + with pytest.raises(PredictorStateError, match="could not be deserialized"): + SparseGOPredictor().set_state({"payload": b"not a torch checkpoint"}) diff --git a/tests/components/predictors/literature/sparsego/test_utils.py b/tests/components/predictors/literature/sparsego/test_utils.py new file mode 100644 index 000000000..bb0d26e70 --- /dev/null +++ b/tests/components/predictors/literature/sparsego/test_utils.py @@ -0,0 +1,24 @@ +"""Tests for SparseGO ontology utilities.""" + +from __future__ import annotations + +import networkx as nx + +from drevalpy.components.predictors.literature.sparsego.utils import load_ontology + + +def test_load_ontology_builds_graph_and_pair_arrays(tmp_path) -> None: + ont_path = tmp_path / "sparseGO_ont.txt" + ont_path.write_text( + "ROOT TERM default\nTERM GENE1 gene\n", + encoding="utf-8", + ) + gene2id = {"GENE1": 0} + + ontology_graph, terms_pairs, genes_terms_pairs = load_ontology(str(ont_path), gene2id) + + assert isinstance(ontology_graph, nx.DiGraph) + assert ontology_graph.has_edge("ROOT", "TERM") + assert terms_pairs.shape == (1, 2) + assert genes_terms_pairs.shape == (1, 2) + assert genes_terms_pairs[0].tolist() == ["TERM", "GENE1"] diff --git a/tests/components/predictors/literature/srmf/test_predictor.py b/tests/components/predictors/literature/srmf/test_predictor.py new file mode 100644 index 000000000..b600ece02 --- /dev/null +++ b/tests/components/predictors/literature/srmf/test_predictor.py @@ -0,0 +1,21 @@ +"""Smoke test mirror for srmf predictor package.""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.predictors.literature.srmf.predictor import SRMFPredictor +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.registry._builtins import ensure_predictor_registered +from drevalpy.registry.predictor import get as get_predictor + + +def test_srmf_predictor_registry_name() -> None: + ensure_predictor_registered("srmf") + assert get_predictor("srmf") is SRMFPredictor + + +def test_structured_predictor_set_state_raises_on_invalid_blob() -> None: + predictor = SRMFPredictor() + with pytest.raises(PredictorStateError): + predictor.set_state({"payload": b"invalid"}) diff --git a/tests/components/predictors/literature/superfeltr/test_predictor.py b/tests/components/predictors/literature/superfeltr/test_predictor.py new file mode 100644 index 000000000..d2d8d71b7 --- /dev/null +++ b/tests/components/predictors/literature/superfeltr/test_predictor.py @@ -0,0 +1,12 @@ +"""Smoke test mirror for superfeltr predictor package.""" + +from __future__ import annotations + +from drevalpy.components.predictors.literature.superfeltr.predictor import SuperFELTRPredictor +from drevalpy.registry._builtins import ensure_predictor_registered +from drevalpy.registry.predictor import get as get_predictor + + +def test_superfeltr_predictor_registry_name() -> None: + ensure_predictor_registered("superfeltr") + assert get_predictor("superfeltr") is SuperFELTRPredictor diff --git a/tests/components/predictors/literature/superfeltr/test_utils.py b/tests/components/predictors/literature/superfeltr/test_utils.py new file mode 100644 index 000000000..ddd1c2686 --- /dev/null +++ b/tests/components/predictors/literature/superfeltr/test_utils.py @@ -0,0 +1,463 @@ +"""Tests for the SuperFELTR encoders, regressor, and training entry point. + +Two constraints shape the fixtures here: the encoder's ``nn.BatchNorm1d`` +requires a batch of more than one row, and ``train_superfeltr_model`` builds its +training loader with ``drop_last=True``, so the pair count must be at least +``2 * mini_batch``. Every fit runs on CPU for a single epoch with +``wandb_project=None`` so no ``WandbLogger`` import is triggered. + +These fits are also what covers the shared ``literature/_omics_loaders.py`` and +``literature/_lightning_training.py`` through a public entry point. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import pytorch_lightning as pl +import torch +from torch import nn + +from drevalpy.components.predictors.literature._omics_loaders import OmicsSplit +from drevalpy.components.predictors.literature.superfeltr.utils import ( + SuperFELTEncoder, + SuperFELTRegressor, + train_superfeltr_model, +) + +EXPR_DIM = 6 +MUT_DIM = 5 +CNV_DIM = 4 +OUT_EXPR = 4 +OUT_MUT = 3 +OUT_CNV = 2 +MINI_BATCH = 2 +N_ENTITIES = 6 +N_PAIRS = 2 * MINI_BATCH + 2 + +BASE_HPAMS: dict[str, int | float | dict] = { + "dropout_rate": 0.1, + "margin": 1.0, + "learning_rate": 0.01, + "weight_decay": 0.01, + "out_dim_expr_encoder": OUT_EXPR, + "out_dim_mutation_encoder": OUT_MUT, + "out_dim_cnv_encoder": OUT_CNV, + "epochs": 1, + "mini_batch": MINI_BATCH, +} + +RANGES = (0.1, 1.0) + + +@pytest.fixture(autouse=True) +def _trainer_logs_in_tmp_path(monkeypatch, tmp_path) -> None: + """Keep Lightning's run logs inside ``tmp_path``. + + The trainer is deliberately *not* pinned to CPU: ``SuperFELTRegressor`` + registers its encoders in an ``nn.ModuleList``, so Lightning moves them along + with the regressor and the fit works on whatever accelerator is present. + """ + monkeypatch.chdir(tmp_path) + + +def _hpams(**overrides: object) -> dict[str, int | float | dict]: + merged = dict(BASE_HPAMS) + merged.update(overrides) # type: ignore[arg-type] + return merged + + +def _encoder(omic_type: str = "expression", input_size: int = EXPR_DIM, **overrides: object) -> SuperFELTEncoder: + return SuperFELTEncoder( + input_size=input_size, + hpams=_hpams(**overrides), + omic_type=omic_type, + ranges=RANGES, + ) + + +def _omics() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + rng = np.random.default_rng(0) + return ( + rng.normal(size=(N_ENTITIES, EXPR_DIM)).astype(np.float32), + rng.normal(size=(N_ENTITIES, MUT_DIM)).astype(np.float32), + rng.normal(size=(N_ENTITIES, CNV_DIM)).astype(np.float32), + ) + + +def _pairs() -> tuple[np.ndarray, np.ndarray]: + response = np.linspace(0.0, 1.0, N_PAIRS, dtype=np.float32) + pair_idx = np.arange(N_PAIRS, dtype=np.int64) % N_ENTITIES + return response, pair_idx + + +def _split(response: np.ndarray | None = None) -> OmicsSplit: + expr, mut, cnv = _omics() + default_response, pair_idx = _pairs() + return OmicsSplit( + gene_expression=expr, + mutations=mut, + copy_number=cnv, + response=default_response if response is None else response, + pair_idx=pair_idx, + ) + + +def _regressor(**overrides: object) -> SuperFELTRegressor: + encoders = ( + _encoder("expression", EXPR_DIM), + _encoder("mutation", MUT_DIM), + _encoder("copy_number_variation_gistic", CNV_DIM), + ) + return SuperFELTRegressor( + input_size=OUT_EXPR + OUT_MUT + OUT_CNV, + hpams=_hpams(**overrides), + encoders=encoders, + ) + + +@pytest.mark.parametrize( + "hyperparameter", + ["dropout_rate", "margin", "learning_rate", "weight_decay"], +) +def test_encoder_rejects_non_float_hyperparameters(hyperparameter: str) -> None: + with pytest.raises(ValueError, match="must be floats"): + _encoder(**{hyperparameter: 1}) + + +@pytest.mark.parametrize( + ("omic_type", "expected"), + [ + pytest.param("expression", OUT_EXPR, id="expression"), + pytest.param("mutation", OUT_MUT, id="mutation"), + pytest.param("copy_number_variation_gistic", OUT_CNV, id="cnv"), + ], +) +def test_encoder_output_size_branches_on_the_omic_type(omic_type: str, expected: int) -> None: + encoder = _encoder(omic_type, EXPR_DIM) + + assert encoder.encode[0].out_features == expected + + +def test_encoder_rejects_an_unknown_omic_type() -> None: + with pytest.raises(KeyError): + _encoder("proteomics", EXPR_DIM) + + +@pytest.mark.parametrize( + "hyperparameter", + ["out_dim_expr_encoder", "out_dim_mutation_encoder", "out_dim_cnv_encoder"], +) +def test_encoder_rejects_non_integer_output_sizes(hyperparameter: str) -> None: + with pytest.raises(ValueError, match="must be ints"): + _encoder(**{hyperparameter: 4.0}) + + +def test_encoder_places_batch_norm_before_the_activation() -> None: + encoder = _encoder() + + layer_types = [type(layer) for layer in encoder.encode] + + assert layer_types == [nn.Linear, nn.BatchNorm1d, nn.ReLU, nn.Dropout] + + +def test_encoder_stores_the_triplet_ranges() -> None: + encoder = _encoder() + + assert (encoder.positive_range, encoder.negative_range) == RANGES + + +def test_encoder_forward_projects_to_the_output_size() -> None: + encoder = _encoder() + encoder.eval() + + with torch.no_grad(): + encoded = encoder(torch.randn(3, EXPR_DIM)) + + assert encoded.shape == (3, OUT_EXPR) + + +def test_encoder_configures_adam_with_the_requested_learning_rate() -> None: + encoder = _encoder() + + optimizer = encoder.configure_optimizers() + + assert isinstance(optimizer, torch.optim.Adam) + assert optimizer.param_groups[0]["lr"] == pytest.approx(0.01) + assert optimizer.param_groups[0]["weight_decay"] == pytest.approx(0.01) + + +@pytest.mark.parametrize( + ("omic_type", "expected_index"), + [ + pytest.param("expression", 0, id="expression"), + pytest.param("mutation", 1, id="mutation"), + pytest.param("copy_number_variation_gistic", 2, id="cnv"), + ], +) +def test_encoder_selects_its_own_omic_from_the_batch(omic_type: str, expected_index: int) -> None: + encoder = _encoder(omic_type, EXPR_DIM) + tensors = (torch.zeros(2, 1), torch.ones(2, 1), torch.full((2, 1), 2.0)) + + selected = encoder._get_omic_data(*tensors) + + torch.testing.assert_close(selected, tensors[expected_index]) + + +def test_encoder_omic_selection_rejects_an_unrecognized_type() -> None: + encoder = _encoder() + encoder.omic_type = "proteomics" + + with pytest.raises(ValueError, match="not recognized"): + encoder._get_omic_data(torch.zeros(1, 1), torch.zeros(1, 1), torch.zeros(1, 1)) + + +def test_encoder_triplet_loss_is_a_non_negative_scalar() -> None: + encoder = _encoder() + + loss = encoder._compute_loss(torch.randn(4, OUT_EXPR), torch.tensor([0.0, 0.5, 1.0, 2.0])) + + assert loss.ndim == 0 + assert loss.item() >= 0.0 + + +def test_regressor_rejects_non_float_hyperparameters() -> None: + with pytest.raises(ValueError, match="must be floats"): + _regressor(learning_rate=1) + + +def test_regressor_puts_its_encoders_in_eval_mode() -> None: + regressor = _regressor() + + assert all(not encoder.training for encoder in regressor.encoders) + + +def test_regressor_registers_its_encoders_as_submodules() -> None: + regressor = _regressor() + + assert isinstance(regressor.encoders, nn.ModuleList) + assert [name for name, _ in regressor.named_children() if name == "encoders"] == ["encoders"] + + +def test_regressor_moves_its_encoders_with_the_rest_of_the_model() -> None: + regressor = _regressor() + + regressor.to(torch.float64) + + encoder_dtypes = {parameter.dtype for encoder in regressor.encoders for parameter in encoder.parameters()} + assert encoder_dtypes == {torch.float64} + assert next(regressor.regressor.parameters()).dtype == torch.float64 + + +def test_regressor_keeps_encoders_in_eval_mode_when_switched_to_train() -> None: + regressor = _regressor() + + regressor.train() + + assert regressor.training + assert all(not encoder.training for encoder in regressor.encoders) + + +def test_regressor_freezes_its_encoders() -> None: + regressor = _regressor() + + assert all(not parameter.requires_grad for encoder in regressor.encoders for parameter in encoder.parameters()) + + +def test_regressor_optimizes_only_the_regression_head() -> None: + regressor = _regressor() + + optimizer = regressor.configure_optimizers() + + optimized = {id(parameter) for group in optimizer.param_groups for parameter in group["params"]} + assert optimized == {id(parameter) for parameter in regressor.regressor.parameters()} + + +def test_regressor_forward_returns_one_scalar_per_row() -> None: + regressor = _regressor() + regressor.eval() + + with torch.no_grad(): + output = regressor(torch.randn(3, OUT_EXPR + OUT_MUT + OUT_CNV)) + + assert output.shape == (3, 1) + + +def test_regressor_concatenates_all_three_encoder_outputs() -> None: + regressor = _regressor() + + encoded = regressor._encode_and_concatenate( + torch.randn(3, EXPR_DIM), + torch.randn(3, MUT_DIM), + torch.randn(3, CNV_DIM), + ) + + assert encoded.shape == (3, OUT_EXPR + OUT_MUT + OUT_CNV) + + +def test_regressor_predict_returns_a_flat_numpy_array() -> None: + regressor = _regressor() + expr, mut, cnv = _omics() + + preds = regressor.predict(expr, mut, cnv) + + assert isinstance(preds, np.ndarray) + assert preds.shape == (N_ENTITIES,) + assert np.isfinite(preds).all() + + +def test_regressor_predict_leaves_the_module_in_eval_mode() -> None: + regressor = _regressor() + regressor.train() + expr, mut, cnv = _omics() + + regressor.predict(expr, mut, cnv) + + assert regressor.training is False + + +def test_regressor_configures_adagrad() -> None: + regressor = _regressor() + + optimizer = regressor.configure_optimizers() + + assert isinstance(optimizer, torch.optim.Adagrad) + assert optimizer.param_groups[0]["lr"] == pytest.approx(0.01) + + +def test_regressor_training_step_returns_a_scalar_loss() -> None: + regressor = _regressor() + batch = [ + torch.randn(3, EXPR_DIM), + torch.randn(3, MUT_DIM), + torch.randn(3, CNV_DIM), + torch.randn(3, 1), + ] + + loss = regressor.training_step(batch, 0) + + assert loss.ndim == 0 + + +def test_regressor_validation_step_returns_a_scalar_loss() -> None: + regressor = _regressor() + batch = [ + torch.randn(3, EXPR_DIM), + torch.randn(3, MUT_DIM), + torch.randn(3, CNV_DIM), + torch.randn(3, 1), + ] + + loss = regressor.validation_step(batch, 0) + + assert loss.ndim == 0 + + +@pytest.mark.parametrize("hyperparameter", ["epochs", "mini_batch"]) +def test_train_rejects_non_integer_epochs_and_mini_batch(hyperparameter: str, tmp_path) -> None: + with pytest.raises(ValueError, match="must be integers"): + train_superfeltr_model( + model=_encoder(), + hpams=_hpams(**{hyperparameter: 1.0}), + train=_split(), + model_checkpoint_dir=tmp_path, + wandb_project=None, + ) + + +def test_train_encoder_without_validation_monitors_the_training_loss(tmp_path) -> None: + checkpoint = train_superfeltr_model( + model=_encoder(), + hpams=_hpams(), + train=_split(), + patience=1, + model_checkpoint_dir=tmp_path, + wandb_project=None, + ) + + assert isinstance(checkpoint, pl.callbacks.ModelCheckpoint) + assert checkpoint.monitor == "train_loss" + + +def test_train_encoder_with_validation_monitors_the_validation_loss(tmp_path) -> None: + checkpoint = train_superfeltr_model( + model=_encoder(), + hpams=_hpams(), + train=_split(), + val=_split(), + patience=1, + model_checkpoint_dir=tmp_path, + wandb_project=None, + ) + + assert checkpoint.monitor == "val_loss" + + +def test_train_writes_a_checkpoint_under_a_unique_versioned_directory(tmp_path) -> None: + checkpoint = train_superfeltr_model( + model=_encoder(), + hpams=_hpams(), + train=_split(), + patience=1, + model_checkpoint_dir=tmp_path, + wandb_project=None, + ) + + assert checkpoint.best_model_path + assert checkpoint.best_model_path.endswith(".ckpt") + assert str(checkpoint.dirpath).startswith(str(tmp_path)) + assert "version-" in str(checkpoint.dirpath) + + +def test_train_regressor_accepts_a_two_dimensional_response(tmp_path) -> None: + response, _ = _pairs() + + checkpoint = train_superfeltr_model( + model=_regressor(), + hpams=_hpams(), + train=_split(response=response.reshape(-1, 1)), + patience=1, + model_checkpoint_dir=tmp_path, + wandb_project=None, + ) + + assert checkpoint.best_model_path + + +def test_train_drops_the_trailing_incomplete_training_batch(tmp_path) -> None: + """``drop_last=True`` for training, ``False`` for validation - the shared loaders' contract.""" + from drevalpy.components.predictors.literature._omics_loaders import make_omics_loaders + + train_loader, val_loader = make_omics_loaders(_split(), _split(), batch_size=MINI_BATCH) + + assert sum(batch[0].shape[0] for batch in train_loader) == N_PAIRS - N_PAIRS % MINI_BATCH + assert val_loader is not None + assert sum(batch[0].shape[0] for batch in val_loader) == N_PAIRS + + +def test_shared_loaders_reshape_a_one_dimensional_response_to_a_column() -> None: + from drevalpy.components.predictors.literature._omics_loaders import make_omics_loaders + + train_loader, _ = make_omics_loaders(_split(), None, batch_size=MINI_BATCH) + + expr, mut, cnv, response = next(iter(train_loader)) + assert expr.shape == (MINI_BATCH, EXPR_DIM) + assert mut.shape == (MINI_BATCH, MUT_DIM) + assert cnv.shape == (MINI_BATCH, CNV_DIM) + assert response.shape == (MINI_BATCH, 1) + + +def test_trained_encoder_checkpoint_is_loadable(tmp_path) -> None: + checkpoint = train_superfeltr_model( + model=_encoder(), + hpams=_hpams(), + train=_split(), + patience=1, + model_checkpoint_dir=tmp_path, + wandb_project=None, + ) + + restored = SuperFELTEncoder.load_from_checkpoint(checkpoint.best_model_path, map_location="cpu") + + assert restored.omic_type == "expression" + assert restored.encode[0].out_features == OUT_EXPR diff --git a/tests/components/predictors/literature/test_early_stopping.py b/tests/components/predictors/literature/test_early_stopping.py new file mode 100644 index 000000000..d427019c9 --- /dev/null +++ b/tests/components/predictors/literature/test_early_stopping.py @@ -0,0 +1,192 @@ +"""Tests for the shared checkpointed early-stopping loop. + +``literature/_early_stopping.py`` replaced the loop PharmaFormer and DIPK each ran by +hand around their own epoch functions. The behaviour that matters is when it stops: +patience counts *consecutive* non-improving epochs and resets on any improvement, and +whatever weights were best get reloaded at the end - including the case where the +first epoch was the best one and every later epoch was worse. + +The epoch callables here return scripted losses rather than training anything, so the +loop's decisions are observable without a real fit. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.predictors.literature._early_stopping import ( + EarlyStoppingRun, + train_with_early_stopping, +) + +#: ``save_torch_payload``/``load_state_dict`` round-trip real torch checkpoints. +pytestmark = pytest.mark.slow + + +class _CountingModel: + """A one-parameter torch module that records how it was driven.""" + + def __init__(self) -> None: + import torch + from torch import nn + + self._module = nn.Linear(1, 1, bias=False) + with torch.no_grad(): + self._module.weight.fill_(0.0) + self.saved_weights: list[float] = [] + self.reload_count = 0 + self.devices: list[object] = [] + + def set_weight(self, value: float) -> None: + import torch + + with torch.no_grad(): + self._module.weight.fill_(value) + + def weight(self) -> float: + return float(self._module.weight.item()) + + def state_dict(self): + self.saved_weights.append(self.weight()) + return self._module.state_dict() + + def load_state_dict(self, state): + self.reload_count += 1 + return self._module.load_state_dict(state) + + def to(self, device): + self.devices.append(device) + return self + + +def _run(tmp_path, *, epochs: int, patience: int, verbose: bool = False) -> EarlyStoppingRun: + return EarlyStoppingRun( + epochs=epochs, + patience=patience, + checkpoint_dir=tmp_path, + model_name="Scripted", + verbose=verbose, + ) + + +def _drive(model, run: EarlyStoppingRun, val_losses: list[float]) -> list[int]: + """Run the loop over scripted validation losses, recording the epochs reached. + + Each epoch stamps the model's weight with its index, so the reloaded weight + identifies which epoch's checkpoint won. + + :param model: The scripted model. + :param run: Run configuration. + :param val_losses: One validation loss per epoch. + :returns: The epoch indices the loop actually reached. + """ + import torch + + reached: list[int] = [] + + def train_epoch() -> float: + epoch = len(reached) + reached.append(epoch) + model.set_weight(float(epoch)) + return 1.0 + + def val_epoch() -> float: + return val_losses[len(reached) - 1] + + train_with_early_stopping(model, run, train_epoch, val_epoch, torch.device("cpu")) + return reached + + +def test_a_monotonically_improving_run_uses_the_whole_epoch_budget(tmp_path) -> None: + model = _CountingModel() + + reached = _drive(model, _run(tmp_path, epochs=4, patience=2), [4.0, 3.0, 2.0, 1.0]) + + assert reached == [0, 1, 2, 3] + assert model.saved_weights == [0.0, 1.0, 2.0, 3.0] + + +def test_patience_stops_the_run_early(tmp_path) -> None: + model = _CountingModel() + + reached = _drive(model, _run(tmp_path, epochs=10, patience=2), [1.0, 2.0, 3.0, 4.0]) + + # Epoch 0 is best; epochs 1 and 2 exhaust patience. + assert reached == [0, 1, 2] + + +def test_an_improvement_resets_the_patience_counter(tmp_path) -> None: + model = _CountingModel() + + reached = _drive(model, _run(tmp_path, epochs=10, patience=2), [5.0, 6.0, 4.0, 7.0, 8.0]) + + # Epoch 1 does not improve, epoch 2 does and resets, epochs 3-4 then exhaust patience. + assert reached == [0, 1, 2, 3, 4] + + +def test_only_improving_epochs_are_checkpointed(tmp_path) -> None: + model = _CountingModel() + + _drive(model, _run(tmp_path, epochs=4, patience=4), [5.0, 6.0, 4.0, 7.0]) + + assert model.saved_weights == [0.0, 2.0] + + +def test_the_best_epochs_weights_are_reloaded_at_the_end(tmp_path) -> None: + model = _CountingModel() + + _drive(model, _run(tmp_path, epochs=4, patience=4), [5.0, 6.0, 4.0, 7.0]) + + assert model.reload_count == 1 + assert model.weight() == pytest.approx(2.0) + + +def test_the_model_is_moved_back_onto_the_target_device(tmp_path) -> None: + import torch + + model = _CountingModel() + + _drive(model, _run(tmp_path, epochs=1, patience=1), [1.0]) + + assert model.devices == [torch.device("cpu")] + + +def test_the_checkpoint_directory_is_created_and_named_per_model(tmp_path) -> None: + model = _CountingModel() + directory = tmp_path / "nested" / "checkpoints" + + _drive(model, _run(directory, epochs=1, patience=1), [1.0]) + + written = list(directory.glob("*.pth")) + assert len(written) == 1 + assert written[0].name.startswith("version-") + assert written[0].name.endswith("_best_Scripted_model.pth") + + +def test_two_runs_in_one_directory_do_not_collide(tmp_path) -> None: + """The randomised ``version-`` prefix is what keeps concurrent fits apart.""" + for _ in range(2): + _drive(_CountingModel(), _run(tmp_path, epochs=1, patience=1), [1.0]) + + assert len(list(tmp_path.glob("*.pth"))) == 2 + + +def test_progress_is_silent_unless_the_run_is_verbose(tmp_path, capsys) -> None: + _drive(_CountingModel(), _run(tmp_path, epochs=1, patience=1), [1.0]) + + assert capsys.readouterr().out == "" + + +def test_a_verbose_run_reports_both_losses_and_the_reload(tmp_path, capsys) -> None: + _drive(_CountingModel(), _run(tmp_path, epochs=1, patience=1, verbose=True), [1.0]) + + output = capsys.readouterr().out + assert "Scripted: Epoch [1/1] Training Loss:" in output + assert "Scripted: Epoch [1/1] Validation Loss:" in output + assert "Scripted: Reloading the best model" in output + + +def test_a_verbose_run_announces_the_early_stop(tmp_path, capsys) -> None: + _drive(_CountingModel(), _run(tmp_path, epochs=10, patience=1, verbose=True), [1.0, 2.0]) + + assert "Scripted: Early stopping triggered at epoch 2" in capsys.readouterr().out diff --git a/tests/components/predictors/literature/test_init.py b/tests/components/predictors/literature/test_init.py new file mode 100644 index 000000000..006adc185 --- /dev/null +++ b/tests/components/predictors/literature/test_init.py @@ -0,0 +1,158 @@ +"""Tests for the literature predictor package boundary and policy. + +Mirrors :mod:`drevalpy.components.predictors.literature` (the package +``__init__``), which is the module that guarantees no lazy predictor +re-exports and no engine indirection anywhere in the literature tree. + +This file also holds the package-level invariants that span every literature +predictor package rather than any single one: that each is reachable as a +native ``drevalpy.models`` facade under a matching zoo name, and that each +declares exactly one input interface. +""" + +from __future__ import annotations + +import ast +import importlib +import sys +from pathlib import Path + +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import ModelConfig, from_spec, validate +from drevalpy.models.drp_model import DRPModel +from drevalpy.registry._builtins import ensure_predictor_registered, register_builtin_components +from drevalpy.registry.predictor import get as get_predictor + +REPO_ROOT = Path(__file__).resolve().parents[4] +PREDICTORS_ROOT = REPO_ROOT / "drevalpy" / "components" / "predictors" +LITERATURE_ROOT = PREDICTORS_ROOT / "literature" + +LITERATURE_FACTORY_NAMES = [ + "DrugGNN", + "DIPK", + "MOLIR", + "SuperFELTR", + "PharmaFormer", + "Precily", + "SRMF", + "SimpleNeuralNetwork", + "MultiViewNeuralNetwork", + "SparseGO", +] + +FORBIDDEN_TOKENS = ( + "literature.impl", + "LiteratureEngineBase", + "LiteratureEngineMixin", + "ENGINE_MODULES", + "_engine_class_name", + "raw_engine_adapter", + "block_engine_adapter", + "structured_engine_adapter", +) + +LITERATURE_PACKAGES = ( + "dipk", + "sparsego", + "molir", + "superfeltr", + "pharmaformer", + "precily", + "srmf", + "druggnn", +) + +LIFECYCLE_METHODS = ("_fit", "_predict", "get_state", "set_state", "is_fitted") + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def _python_files_under(root: Path) -> list[Path]: + return sorted(path for path in root.rglob("*.py") if path.is_file()) + + +def test_literature_package_has_no_lazy_predictor_reexports() -> None: + init_path = LITERATURE_ROOT / "__init__.py" + text = init_path.read_text(encoding="utf-8") + assert "_LAZY_EXPORTS" not in text + assert "__getattr__" not in text + + +def test_literature_tree_has_no_forbidden_engine_indirection() -> None: + offenders: list[str] = [] + for path in _python_files_under(LITERATURE_ROOT): + text = path.read_text(encoding="utf-8") + for token in FORBIDDEN_TOKENS: + if token in text: + offenders.append(f"{path.relative_to(REPO_ROOT)}: {token}") + assert not offenders, "\n".join(offenders) + + +def _defined_lifecycle_methods(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + defined_methods: set[str] = set() + for node in tree.body: + if isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + defined_methods.add(item.name) + return defined_methods + + +@pytest.mark.parametrize("package", LITERATURE_PACKAGES) +def test_literature_predictor_modules_own_lifecycle(package: str) -> None: + predictor_path = LITERATURE_ROOT / package / "predictor.py" + assert predictor_path.is_file() + defined_methods = _defined_lifecycle_methods(predictor_path) + missing = [name for name in LIFECYCLE_METHODS if name not in defined_methods] + assert not missing, f"{predictor_path} missing lifecycle methods: {missing}" + + +def test_literature_predictor_modules_avoid_removed_adapter_modules() -> None: + for module_name in ( + "drevalpy.components.predictors.literature.precily.predictor", + "drevalpy.components.predictors.literature.druggnn.predictor", + "drevalpy.components.predictors.neural_network.predictor", + "drevalpy.components.predictors.literature.dipk.predictor", + ): + module = importlib.import_module(module_name) + source_path = module.__file__ + assert source_path is not None + text = Path(source_path).read_text(encoding="utf-8") + assert "public_models" not in text + assert "literature.impl" not in text + assert "LiteratureEngineMixin" not in text + + +def test_literature_predictor_lazy_package_import() -> None: + ensure_predictor_registered("dipk") + precily_module = "drevalpy.components.predictors.literature.precily.predictor" + saved = sys.modules.pop(precily_module, None) + try: + cls = get_predictor("dipk") + assert cls.__name__ == "DIPKPredictor" + assert precily_module not in sys.modules + finally: + if saved is not None: + sys.modules[precily_module] = saved + + +@pytest.mark.parametrize("name", LITERATURE_FACTORY_NAMES) +def test_literature_factory_entries_are_native_facades(name: str) -> None: + cls = construct_model(name) + assert issubclass(cls, DRPModel) + assert cls.__module__ == "drevalpy.models" + + +@pytest.mark.parametrize("name", LITERATURE_FACTORY_NAMES) +def test_model_config_and_factory_share_zoo_name(name: str) -> None: + config = from_spec(name) + assert isinstance(config, ModelConfig) + model_cls = construct_model(name) + validate(config) + assert model_cls.get_model_name() == name diff --git a/tests/components/predictors/literature/test_lightning_training.py b/tests/components/predictors/literature/test_lightning_training.py new file mode 100644 index 000000000..5ccc24565 --- /dev/null +++ b/tests/components/predictors/literature/test_lightning_training.py @@ -0,0 +1,228 @@ +"""Tests for the shared Lightning fit wrapper. + +``literature/_lightning_training.py`` replaced the trainer MOLIR's ``MOLIModel.fit`` +and SuperFELTR's ``train_superfeltr_model`` each assembled by hand. What it decides, +and therefore what is pinned here: the monitored metric follows whether a validation +loader was supplied, the checkpoint lands in a randomised subdirectory of the caller's +directory, and MOLIR's two divergences (``save_weights_only`` and a pinned single +device) are honoured as fields rather than forks. + +Every test runs a one-epoch fit of a tiny module, so the whole file is extended tier. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import pytorch_lightning as pl +from upath import UPath + +from drevalpy.components.predictors.literature._lightning_training import ( + LightningRun, + _loggers, + _versioned_name, + run_lightning_fit, +) +from drevalpy.components.predictors.literature._omics_loaders import OmicsSplit, make_omics_loaders + +#: Every test fits a real Lightning trainer for one epoch. +pytestmark = pytest.mark.slow + +N_ENTITIES = 4 +N_PAIRS = 4 +FEATURE_DIM = 2 + + +@pytest.fixture(autouse=True) +def _trainer_logs_in_tmp_path(monkeypatch, tmp_path) -> None: + """Keep Lightning's default CSV logger out of the repository root.""" + monkeypatch.chdir(tmp_path) + + +def _split() -> OmicsSplit: + rng = np.random.default_rng(0) + return OmicsSplit( + gene_expression=rng.normal(size=(N_ENTITIES, FEATURE_DIM)).astype(np.float32), + mutations=rng.normal(size=(N_ENTITIES, FEATURE_DIM)).astype(np.float32), + copy_number=rng.normal(size=(N_ENTITIES, FEATURE_DIM)).astype(np.float32), + response=np.linspace(0.0, 1.0, N_PAIRS, dtype=np.float32), + pair_idx=np.arange(N_PAIRS, dtype=np.int64) % N_ENTITIES, + ) + + +def _loaders(*, with_validation: bool): + return make_omics_loaders(_split(), _split() if with_validation else None, batch_size=2) + + +class _TinyModule(pl.LightningModule): + """Logs both losses so either monitor resolves, and records which ran.""" + + def __init__(self) -> None: + import torch + from torch import nn + + super().__init__() + self.layer = nn.Linear(FEATURE_DIM, 1) + self.loss = nn.MSELoss() + self.validated = False + self._torch = torch + + def forward(self, x): + return self.layer(x) + + def training_step(self, batch, batch_idx): + expression, _, _, response = batch + loss = self.loss(self.layer(expression), response) + self.log("train_loss", loss, on_step=False, on_epoch=True) + return loss + + def validation_step(self, batch, batch_idx): + expression, _, _, response = batch + loss = self.loss(self.layer(expression), response) + self.log("val_loss", loss, on_step=False, on_epoch=True) + self.validated = True + return loss + + def configure_optimizers(self): + return self._torch.optim.SGD(self.parameters(), lr=0.01) + + +def _run(tmp_path, **overrides) -> LightningRun: + settings: dict[str, object] = {"max_epochs": 1, "patience": 1, "checkpoint_dir": tmp_path} + settings.update(overrides) + return LightningRun(**settings) # type: ignore[arg-type] + + +def test_without_a_validation_loader_the_training_loss_is_monitored(tmp_path) -> None: + train_loader, val_loader = _loaders(with_validation=False) + + checkpoint = run_lightning_fit(_TinyModule(), train_loader, val_loader, _run(tmp_path)) + + assert isinstance(checkpoint, pl.callbacks.ModelCheckpoint) + assert checkpoint.monitor == "train_loss" + + +def test_with_a_validation_loader_the_validation_loss_is_monitored(tmp_path) -> None: + train_loader, val_loader = _loaders(with_validation=True) + + checkpoint = run_lightning_fit(_TinyModule(), train_loader, val_loader, _run(tmp_path)) + + assert checkpoint.monitor == "val_loss" + + +def test_the_validation_loader_is_actually_consumed(tmp_path) -> None: + """Passing ``val_loader`` must reach ``trainer.fit``, not just switch the monitor.""" + module = _TinyModule() + train_loader, val_loader = _loaders(with_validation=True) + + run_lightning_fit(module, train_loader, val_loader, _run(tmp_path)) + + assert module.validated is True + + +def test_no_validation_loop_runs_without_a_validation_loader(tmp_path) -> None: + module = _TinyModule() + train_loader, val_loader = _loaders(with_validation=False) + + run_lightning_fit(module, train_loader, val_loader, _run(tmp_path)) + + assert module.validated is False + + +def test_the_checkpoint_lands_under_a_randomised_subdirectory(tmp_path) -> None: + train_loader, val_loader = _loaders(with_validation=False) + + checkpoint = run_lightning_fit(_TinyModule(), train_loader, val_loader, _run(tmp_path / "runs")) + + assert checkpoint.best_model_path + assert checkpoint.best_model_path.endswith(".ckpt") + assert str(checkpoint.dirpath).startswith(str(tmp_path / "runs")) + assert "version-" in str(checkpoint.dirpath) + + +def test_two_fits_into_one_directory_do_not_share_a_checkpoint_path(tmp_path) -> None: + paths = set() + for _ in range(2): + train_loader, val_loader = _loaders(with_validation=False) + checkpoint = run_lightning_fit(_TinyModule(), train_loader, val_loader, _run(tmp_path)) + paths.add(str(checkpoint.dirpath)) + + assert len(paths) == 2 + + +def test_only_the_best_epoch_is_kept(tmp_path) -> None: + train_loader, val_loader = _loaders(with_validation=False) + + checkpoint = run_lightning_fit(_TinyModule(), train_loader, val_loader, _run(tmp_path, max_epochs=3)) + + assert checkpoint.save_top_k == 1 + assert len(list(UPath(checkpoint.dirpath).glob("*.ckpt"))) == 1 + + +def test_save_weights_only_produces_a_checkpoint_without_optimizer_state(tmp_path) -> None: + """MOLIR sets this; SuperFELTR does not, so the default must stay ``False``.""" + from drevalpy.utils.torch_io import load_trusted_mapping + + train_loader, val_loader = _loaders(with_validation=False) + + checkpoint = run_lightning_fit(_TinyModule(), train_loader, val_loader, _run(tmp_path, save_weights_only=True)) + + payload = load_trusted_mapping(checkpoint.best_model_path, map_location="cpu") + assert "state_dict" in payload + assert "optimizer_states" not in payload + + +def test_the_default_keeps_optimizer_state(tmp_path) -> None: + from drevalpy.utils.torch_io import load_trusted_mapping + + train_loader, val_loader = _loaders(with_validation=False) + + checkpoint = run_lightning_fit(_TinyModule(), train_loader, val_loader, _run(tmp_path)) + + payload = load_trusted_mapping(checkpoint.best_model_path, map_location="cpu") + assert "optimizer_states" in payload + + +def test_an_explicit_device_count_is_honoured(tmp_path) -> None: + """MOLIR pins ``devices=1``; leaving it ``None`` must not pass the argument at all.""" + train_loader, val_loader = _loaders(with_validation=False) + + checkpoint = run_lightning_fit(_TinyModule(), train_loader, val_loader, _run(tmp_path, devices=1)) + + assert checkpoint.best_model_path + + +def test_the_progress_bar_is_silenced(tmp_path, capsys) -> None: + train_loader, val_loader = _loaders(with_validation=False) + + run_lightning_fit(_TinyModule(), train_loader, val_loader, _run(tmp_path)) + + assert "it/s" not in capsys.readouterr().err + + +class TestLoggerSelection: + """``wandb_project`` is threaded through from the predictors' hyperparameters.""" + + def test_no_project_leaves_lightning_its_default_logger(self) -> None: + assert _loggers(None) is True + + def test_a_project_yields_a_single_wandb_logger(self) -> None: + from pytorch_lightning.loggers import WandbLogger + + loggers = _loggers("drevalpy-test") + + assert isinstance(loggers, list) + assert len(loggers) == 1 + assert isinstance(loggers[0], WandbLogger) + + +class TestVersionedName: + def test_the_name_is_prefixed_and_fixed_width(self) -> None: + name = _versioned_name() + + assert name.startswith("version-") + assert len(name) == len("version-") + 20 + assert set(name.removeprefix("version-")) <= set("0123456789abcdef") + + def test_names_do_not_repeat(self) -> None: + assert len({_versioned_name() for _ in range(50)}) == 50 diff --git a/tests/components/predictors/literature/test_metadata.py b/tests/components/predictors/literature/test_metadata.py new file mode 100644 index 000000000..b81c61e76 --- /dev/null +++ b/tests/components/predictors/literature/test_metadata.py @@ -0,0 +1,91 @@ +"""Tests for the frozen literature reference constants. + +Mirrors the private module +``drevalpy.components.predictors.literature._metadata`` with the leading +underscore stripped. The module holds data only, so the assertions are +data-shape assertions rather than behaviour. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.predictors.literature._metadata import ( + DIPK_REFERENCE, + DRUGGNN_REFERENCE, + LITERATURE_INTEGRATION_DEVIATIONS, + MOLIR_REFERENCE, + PHARMAFORMER_REFERENCE, + PRECILY_REFERENCE, + SPARSEGO_REFERENCE, + SRMF_REFERENCE, + SUPERFELTR_REFERENCE, +) +from drevalpy.types.enums.literature_reference import LiteratureReference + +ALL_REFERENCES = ( + pytest.param(DRUGGNN_REFERENCE, id="druggnn"), + pytest.param(PRECILY_REFERENCE, id="precily"), + pytest.param(SRMF_REFERENCE, id="srmf"), + pytest.param(MOLIR_REFERENCE, id="molir"), + pytest.param(SUPERFELTR_REFERENCE, id="superfeltr"), + pytest.param(PHARMAFORMER_REFERENCE, id="pharmaformer"), + pytest.param(DIPK_REFERENCE, id="dipk"), + pytest.param(SPARSEGO_REFERENCE, id="sparsego"), +) + +WITH_DOI = ( + pytest.param(MOLIR_REFERENCE, "10.1186/s12859-023-05166-7", id="molir"), + pytest.param(SUPERFELTR_REFERENCE, "10.1186/s12859-023-05166-7", id="superfeltr"), + pytest.param(PHARMAFORMER_REFERENCE, "10.1038/s41698-025-01082-6", id="pharmaformer"), + pytest.param(SPARSEGO_REFERENCE, "10.1016/j.ebiom.2023.104767", id="sparsego"), +) + + +@pytest.mark.parametrize("reference", ALL_REFERENCES) +def test_every_constant_is_a_literature_reference(reference: LiteratureReference) -> None: + assert isinstance(reference, LiteratureReference) + + +@pytest.mark.parametrize("reference", ALL_REFERENCES) +def test_every_reference_points_at_a_github_repository(reference: LiteratureReference) -> None: + assert reference.repo_url.startswith("https://github.com/") + + +@pytest.mark.parametrize("reference", ALL_REFERENCES) +def test_every_reference_has_citation_text(reference: LiteratureReference) -> None: + assert reference.citation_text + + +@pytest.mark.parametrize("reference", ALL_REFERENCES) +def test_every_reference_shares_the_integration_deviation_note(reference: LiteratureReference) -> None: + assert reference.deviations == LITERATURE_INTEGRATION_DEVIATIONS.strip() + + +@pytest.mark.parametrize(("reference", "doi"), WITH_DOI) +def test_references_with_a_published_doi_record_it(reference: LiteratureReference, doi: str) -> None: + assert reference.citation_doi == doi + + +@pytest.mark.parametrize( + "reference", + [ + pytest.param(DRUGGNN_REFERENCE, id="druggnn"), + pytest.param(PRECILY_REFERENCE, id="precily"), + pytest.param(SRMF_REFERENCE, id="srmf"), + pytest.param(DIPK_REFERENCE, id="dipk"), + ], +) +def test_code_only_references_leave_the_doi_empty(reference: LiteratureReference) -> None: + assert reference.citation_doi == "" + + +def test_repo_urls_are_unique_across_references() -> None: + urls = [param.values[0].repo_url for param in ALL_REFERENCES] + + assert len(set(urls)) == len(urls) + + +def test_every_reference_is_immutable() -> None: + with pytest.raises(AttributeError): + DIPK_REFERENCE.repo_url = "https://example.invalid" diff --git a/tests/components/predictors/literature/test_omics_loaders.py b/tests/components/predictors/literature/test_omics_loaders.py new file mode 100644 index 000000000..00b0d2e8c --- /dev/null +++ b/tests/components/predictors/literature/test_omics_loaders.py @@ -0,0 +1,99 @@ +"""Tests for the shared three-omic loader construction. + +``literature/_omics_loaders.py`` is what MOLIR's ``MOLIModel.fit`` and SuperFELTR's +``train_superfeltr_model`` both call; the two used to carry byte-identical copies of +this code. The contract worth pinning is the asymmetry between the two loaders - +``drop_last=True`` for training, ``False`` for validation - and the response column +reshape, because a regression in either silently changes what the models see. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.predictors.literature._omics_loaders import OmicsSplit, make_omics_loaders + +#: ``make_pair_loader`` pulls in torch, which costs more than the fast tier's budget. +pytestmark = pytest.mark.slow + +N_ENTITIES = 4 +EXPR_DIM = 3 +MUT_DIM = 2 +CNV_DIM = 1 + + +def _split(n_pairs: int = 5, *, two_dimensional_response: bool = False) -> OmicsSplit: + response = np.linspace(0.0, 1.0, n_pairs, dtype=np.float32) + return OmicsSplit( + gene_expression=np.arange(N_ENTITIES * EXPR_DIM, dtype=np.float32).reshape(N_ENTITIES, EXPR_DIM), + mutations=np.ones((N_ENTITIES, MUT_DIM), dtype=np.float32), + copy_number=np.zeros((N_ENTITIES, CNV_DIM), dtype=np.float32), + response=response.reshape(-1, 1) if two_dimensional_response else response, + pair_idx=np.arange(n_pairs, dtype=np.int64) % N_ENTITIES, + ) + + +def test_without_validation_only_a_train_loader_is_built() -> None: + train_loader, val_loader = make_omics_loaders(_split(), None, batch_size=2) + + assert val_loader is None + assert len(train_loader.dataset) == 5 + + +def test_each_batch_carries_the_three_views_and_a_response_column() -> None: + train_loader, _ = make_omics_loaders(_split(), None, batch_size=2) + + expression, mutations, copy_number, response = next(iter(train_loader)) + + assert expression.shape == (2, EXPR_DIM) + assert mutations.shape == (2, MUT_DIM) + assert copy_number.shape == (2, CNV_DIM) + assert response.shape == (2, 1) + + +def test_all_three_views_are_indexed_by_the_same_pair_index() -> None: + split = _split(n_pairs=4) + train_loader, _ = make_omics_loaders(split, None, batch_size=4) + + expression, _, _, _ = next(iter(train_loader)) + + np.testing.assert_allclose(expression.numpy(), split.gene_expression[split.pair_idx]) + + +def test_training_drops_the_trailing_incomplete_batch() -> None: + train_loader, _ = make_omics_loaders(_split(n_pairs=5), None, batch_size=2) + + assert sum(batch[0].shape[0] for batch in train_loader) == 4 + + +def test_validation_keeps_the_trailing_incomplete_batch() -> None: + _, val_loader = make_omics_loaders(_split(), _split(n_pairs=5), batch_size=2) + + assert val_loader is not None + assert sum(batch[0].shape[0] for batch in val_loader) == 5 + + +def test_an_already_two_dimensional_response_is_passed_through() -> None: + train_loader, _ = make_omics_loaders(_split(two_dimensional_response=True), None, batch_size=2) + + _, _, _, response = next(iter(train_loader)) + + assert response.shape == (2, 1) + + +def test_neither_loader_shuffles_so_pair_order_is_reproducible() -> None: + split = _split(n_pairs=4) + train_loader, _ = make_omics_loaders(split, None, batch_size=1) + + seen = [float(batch[3].item()) for batch in train_loader] + + assert seen == pytest.approx(split.response.tolist()) + + +def test_the_split_is_frozen_so_a_loader_cannot_be_repointed() -> None: + """``OmicsSplit`` replaced five separately-optional validation arguments.""" + split = _split() + + with pytest.raises(AttributeError): + split.pair_idx = np.zeros(1, dtype=np.int64) # type: ignore[misc] diff --git a/tests/components/predictors/literature/test_pair_predict.py b/tests/components/predictors/literature/test_pair_predict.py new file mode 100644 index 000000000..3134ee5c4 --- /dev/null +++ b/tests/components/predictors/literature/test_pair_predict.py @@ -0,0 +1,179 @@ +"""Tests for the shared eval-time pair inference helper. + +``literature/_pair_predict.py`` replaced the ``_predict`` skeleton PharmaFormer, +Precily and SparseGO each carried: resolve the pair indices, refuse a batch without +``drug_pair_idx``, build a non-shuffled ``make_pair_loader``, and accumulate +predictions under ``torch.no_grad()``. What matters is that the output stays in pair +order, that the model is left in eval mode, and that ``concatenated_forward`` +reproduces the two-block concatenation Precily and SparseGO do by hand. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.predictors.literature._pair_predict import ( + PairEvalSpec, + concatenated_forward, + predict_pairs, + require_drug_pair_idx, +) +from drevalpy.types.data.batch.feature_block import numeric_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.batch.response_batch import ResponseBatch +from tests.components.predictors.literature._helpers import two_by_two_batch + +#: Every test below builds torch tensors through ``make_pair_loader``. +pytestmark = pytest.mark.slow + +CELL_LINE_VALUES = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) +DRUG_VALUES = np.array([[10.0], [20.0]], dtype=np.float32) + + +def _batch(*, with_drug_pair_idx: bool = True) -> ModelInputBatch: + """Build a four-pair batch over two cell lines and two drugs. + + :param with_drug_pair_idx: Whether to populate ``drug_pair_idx``. + :returns: Featurized ``ModelInputBatch``. + """ + return two_by_two_batch( + cell_line_blocks={"view": numeric_feature_block(CELL_LINE_VALUES)}, + drug_blocks={"view": numeric_feature_block(DRUG_VALUES)}, + drug_pair_idx=np.array([0, 1, 0, 1]) if with_drug_pair_idx else None, + ) + + +def _spec(batch_size: int = 2): + """Build a spec over the two blocks of :func:`_batch`, pinned to the CPU. + + :param batch_size: Mini-batch size for the eval pass. + :returns: A ``PairEvalSpec``. + """ + import torch + + return PairEvalSpec( + cell_line_blocks=(CELL_LINE_VALUES,), + drug_blocks=(DRUG_VALUES,), + batch_size=batch_size, + device=torch.device("cpu"), + ) + + +class _SumModel: + """Records eval-mode transitions and sums each block's features per row.""" + + def __init__(self) -> None: + self.training = True + self.seen_shapes: list[tuple[int, ...]] = [] + + def eval(self) -> None: + self.training = False + + def __call__(self, *tensors): + import torch + + self.seen_shapes.append(tuple(tensors[0].shape)) + return torch.stack([tensor.sum(dim=1) for tensor in tensors]).sum(dim=0) + + +class _WidthModel: + """Returns the feature width of the single tensor it is handed.""" + + def eval(self) -> None: + return None + + def __call__(self, tensor): + import torch + + return torch.full((tensor.shape[0],), float(tensor.shape[1])) + + +class TestRequireDrugPairIdx: + def test_an_array_passes_through_unchanged(self) -> None: + indices = np.array([0, 1]) + + assert require_drug_pair_idx(indices) is indices + + def test_none_is_the_documented_runtime_error(self) -> None: + with pytest.raises(RuntimeError, match="drug_pair_idx is required for this predictor"): + require_drug_pair_idx(None) + + +class TestPredictPairs: + def test_one_prediction_per_pair_in_pair_order(self) -> None: + # Pairs are (cl0,d0), (cl0,d1), (cl1,d0), (cl1,d1); the model sums both blocks. + predictions = predict_pairs(_SumModel(), _batch(), _spec()) + + np.testing.assert_allclose(predictions, [13.0, 23.0, 17.0, 27.0]) + + def test_the_result_is_float64_regardless_of_the_model_dtype(self) -> None: + assert predict_pairs(_SumModel(), _batch(), _spec()).dtype == np.float64 + + def test_the_model_is_switched_into_eval_mode(self) -> None: + model = _SumModel() + + predict_pairs(model, _batch(), _spec()) + + assert model.training is False + + def test_a_batch_size_below_the_pair_count_still_covers_every_pair(self) -> None: + model = _SumModel() + + predictions = predict_pairs(model, _batch(), _spec(batch_size=3)) + + assert len(predictions) == 4 + # 4 pairs at batch_size 3: no drop_last, so the trailing single pair survives. + assert [shape[0] for shape in model.seen_shapes] == [3, 1] + + def test_a_batch_without_drug_pair_indices_is_rejected(self) -> None: + with pytest.raises(RuntimeError, match="drug_pair_idx is required"): + predict_pairs(_SumModel(), _batch(with_drug_pair_idx=False), _spec()) + + def test_a_multi_element_output_row_is_flattened(self) -> None: + """A model returning ``[batch, 1]`` must not yield a nested result.""" + + class _ColumnModel(_SumModel): + def __call__(self, *tensors): + return super().__call__(*tensors).reshape(-1, 1) + + assert predict_pairs(_ColumnModel(), _batch(), _spec()).shape == (4,) + + def test_a_batch_with_no_pairs_yields_an_empty_float64_array(self) -> None: + """``np.concatenate`` rejects an empty list, so the no-batch case is separate.""" + empty = two_by_two_batch( + response=ResponseBatch( + response=np.empty(0), + cell_line_ids=np.empty(0, dtype=str), + drug_ids=np.empty(0, dtype=str), + ), + cell_line_blocks={"view": numeric_feature_block(CELL_LINE_VALUES)}, + drug_blocks={"view": numeric_feature_block(DRUG_VALUES)}, + cell_line_pair_idx=np.empty(0, dtype=np.intp), + drug_pair_idx=np.empty(0, dtype=np.intp), + ) + + predictions = predict_pairs(_SumModel(), empty, _spec()) + + assert predictions.shape == (0,) + assert predictions.dtype == np.float64 + + +class TestConcatenatedForward: + def test_the_blocks_arrive_concatenated_feature_wise(self) -> None: + predictions = predict_pairs( + _WidthModel(), + _batch(), + _spec(), + forward=concatenated_forward(_WidthModel()), + ) + + # 2 cell-line columns + 1 drug column. + np.testing.assert_allclose(predictions, np.full(4, 3.0)) + + def test_without_it_each_block_is_passed_separately(self) -> None: + model = _SumModel() + + predict_pairs(model, _batch(), _spec(batch_size=4)) + + assert model.seen_shapes == [(4, 2)] diff --git a/tests/components/predictors/literature/test_single_drug_omics.py b/tests/components/predictors/literature/test_single_drug_omics.py new file mode 100644 index 000000000..0bde90f0a --- /dev/null +++ b/tests/components/predictors/literature/test_single_drug_omics.py @@ -0,0 +1,289 @@ +"""Tests for the per-drug three-omic plumbing shared by MOLIR and SuperFELTR. + +``literature/_single_drug_omics.py`` collects what both single-drug predictors used to +duplicate: the per-drug checkpoint directory, the record of which feature names a model +was trained on, the predict-time column realignment, the early-stopping index lookup, +and the feature-name (de)serialization. + +The interesting cases are the degenerate ones - a missing or too-short early-stopping +response, and a predict-time batch whose omic columns are reordered or narrower than +what the model was trained on. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from upath import UPath + +from drevalpy.components.predictors.literature._single_drug_omics import ( + OMIC_BLOCK_NAMES, + OmicFeatureNames, + OmicMatrices, + aligned_pair_matrices, + checkpoint_dir_for_drug, + early_stopping_indices, + feature_names_from_payload, + feature_names_payload, + iter_drug_subsets, + omic_feature_names, + omic_matrices, + validation_split, +) +from drevalpy.types.data.batch.feature_block import numeric_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.batch.response_batch import ResponseBatch +from tests.components.predictors.literature._helpers import two_by_two_batch + +EXPRESSION = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=np.float32) +MUTATIONS = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32) +CNV = np.array([[0.5], [1.5]], dtype=np.float32) +EXPRESSION_FEATURES = ("geneA", "geneB", "geneC") +MUTATION_FEATURES = ("mutA", "mutB") +CNV_FEATURES = ("cnvA",) + + +def _identity_blocks() -> dict: + """Build the drug identity blocks per-drug routing needs. + + :returns: Identity and identity-categories blocks for drugs ``d1``/``d2``. + """ + return { + "identity": numeric_feature_block(np.eye(2, dtype=np.float32)), + "identity_categories": numeric_feature_block(np.array([["d1", "d2"]], dtype=object)), + } + + +def _batch( + *, + early_stopping_response: ResponseBatch | None = None, + expression: np.ndarray = EXPRESSION, + expression_features: tuple[str, ...] = EXPRESSION_FEATURES, + checkpoint_dir: str = "checkpoints", +) -> ModelInputBatch: + """Build a four-pair batch carrying all three omic views. + + :param early_stopping_response: Optional validation pairs. + :param expression: Gene-expression matrix to use. + :param expression_features: Feature names for *expression*. + :param checkpoint_dir: Base checkpoint directory. + :returns: Featurized ``ModelInputBatch``. + """ + response = ResponseBatch( + response=np.array([1.0, 2.0, 3.0, 4.0]), + cell_line_ids=np.array(["cl1", "cl1", "cl2", "cl2"]), + drug_ids=np.array(["d1", "d2", "d1", "d2"]), + ) + return two_by_two_batch( + response=response, + cell_line_blocks={ + "gene_expression": numeric_feature_block(expression, feature_names=expression_features), + "mutations": numeric_feature_block(MUTATIONS, feature_names=MUTATION_FEATURES), + "copy_number_variation_gistic": numeric_feature_block(CNV, feature_names=CNV_FEATURES), + }, + drug_blocks=_identity_blocks(), + early_stopping_response=early_stopping_response, + checkpoint_dir=checkpoint_dir, + ) + + +def _matrices() -> OmicMatrices: + return OmicMatrices(gene_expression=EXPRESSION, mutations=MUTATIONS, copy_number_variation=CNV) + + +def _trained_names() -> OmicFeatureNames: + return OmicFeatureNames( + gene_expression=EXPRESSION_FEATURES, + mutations=MUTATION_FEATURES, + copy_number_variation=CNV_FEATURES, + ) + + +class TestBlockNames: + def test_the_three_views_are_in_encoder_order(self) -> None: + assert OMIC_BLOCK_NAMES == ("gene_expression", "mutations", "copy_number_variation_gistic") + + +class TestOmicMatrices: + def test_matrices_are_read_out_of_the_batch_as_float32(self) -> None: + matrices = omic_matrices(_batch()) + + assert matrices.gene_expression.dtype == np.float32 + np.testing.assert_allclose(matrices.gene_expression, EXPRESSION) + np.testing.assert_allclose(matrices.mutations, MUTATIONS) + np.testing.assert_allclose(matrices.copy_number_variation, CNV) + + def test_widths_report_one_feature_count_per_view(self) -> None: + assert _matrices().widths() == (3, 2, 1) + + def test_a_split_reuses_the_entity_matrices_and_takes_pair_level_data(self) -> None: + pair_idx = np.array([0, 1, 1]) + response = np.array([1.0, 2.0, 3.0], dtype=np.float32) + + split = _matrices().split(pair_idx, response) + + assert split.gene_expression is EXPRESSION + assert split.copy_number is CNV + np.testing.assert_array_equal(split.pair_idx, pair_idx) + np.testing.assert_allclose(split.response, response) + + +class TestFeatureNames: + def test_names_are_recorded_per_view(self) -> None: + assert omic_feature_names(_batch()).as_tuple() == ( + EXPRESSION_FEATURES, + MUTATION_FEATURES, + CNV_FEATURES, + ) + + def test_the_payload_round_trips(self) -> None: + restored = feature_names_from_payload(feature_names_payload(_trained_names())) + + assert restored == _trained_names() + + def test_the_payload_is_json_shaped_lists(self) -> None: + payload = feature_names_payload(_trained_names()) + + assert payload == { + "gene_expression_features": list(EXPRESSION_FEATURES), + "mutations_features": list(MUTATION_FEATURES), + "copy_number_variation_features": list(CNV_FEATURES), + } + + def test_unknown_names_serialize_as_none(self) -> None: + assert feature_names_payload(None) == { + "gene_expression_features": None, + "mutations_features": None, + "copy_number_variation_features": None, + } + + def test_a_payload_from_an_older_model_deserializes_to_none(self) -> None: + assert feature_names_from_payload({}) == OmicFeatureNames(None, None, None) + + +class TestCheckpointDir: + def test_the_directory_is_derived_from_the_drug_id(self) -> None: + directory = checkpoint_dir_for_drug(UPath("/base"), "drug-1") + + assert directory.parent == UPath("/base") + assert directory.name.startswith("drug_") + + def test_the_same_drug_always_maps_to_the_same_directory(self) -> None: + assert checkpoint_dir_for_drug(UPath("/base"), "d") == checkpoint_dir_for_drug(UPath("/base"), "d") + + def test_different_drugs_do_not_share_a_directory(self) -> None: + assert checkpoint_dir_for_drug(UPath("/base"), "d1") != checkpoint_dir_for_drug(UPath("/base"), "d2") + + def test_a_filesystem_hostile_drug_id_still_yields_one_path_segment(self) -> None: + directory = checkpoint_dir_for_drug(UPath("/base"), "a/b c:*?") + + assert directory.parent == UPath("/base") + + +class TestIterDrugSubsets: + def test_one_subset_per_drug_with_only_that_drugs_pairs(self) -> None: + subsets = dict(iter_drug_subsets(_batch())) + + assert sorted(subsets) == ["d1", "d2"] + assert subsets["d1"].n_pairs == 2 + np.testing.assert_array_equal(subsets["d1"].drug_ids, ["d1", "d1"]) + + def test_each_subset_gets_its_own_checkpoint_directory(self) -> None: + subsets = dict(iter_drug_subsets(_batch(checkpoint_dir="/base"))) + + directories = {str(sub.training_context.checkpoint_dir) for sub in subsets.values()} + assert len(directories) == 2 + assert all(directory.startswith("/base/drug_") for directory in directories) + + +class TestEarlyStopping: + def test_no_early_stopping_response_yields_no_split(self) -> None: + assert early_stopping_indices(_batch()) == (None, None) + assert validation_split(_matrices(), _batch()) is None + + def test_a_single_validation_pair_is_refused(self) -> None: + """One point cannot support an early-stopping decision.""" + response = ResponseBatch( + response=np.array([1.0]), + cell_line_ids=np.array(["cl1"]), + drug_ids=np.array(["d1"]), + ) + + assert early_stopping_indices(_batch(early_stopping_response=response)) == (None, None) + + def test_validation_cell_lines_map_onto_entity_rows(self) -> None: + response = ResponseBatch( + response=np.array([7.0, 8.0]), + cell_line_ids=np.array(["cl2", "cl1"]), + drug_ids=np.array(["d1", "d1"]), + ) + + pair_idx, values = early_stopping_indices(_batch(early_stopping_response=response)) + + np.testing.assert_array_equal(pair_idx, [1, 0]) + np.testing.assert_allclose(values, [7.0, 8.0]) + + def test_the_validation_split_shares_the_training_entity_matrices(self) -> None: + response = ResponseBatch( + response=np.array([7.0, 8.0]), + cell_line_ids=np.array(["cl2", "cl1"]), + drug_ids=np.array(["d1", "d1"]), + ) + matrices = _matrices() + + split = validation_split(matrices, _batch(early_stopping_response=response)) + + assert split is not None + assert split.gene_expression is matrices.gene_expression + np.testing.assert_array_equal(split.pair_idx, [1, 0]) + + +class TestAlignedPairMatrices: + def test_matching_features_are_expanded_to_pair_level_unchanged(self) -> None: + expression, mutations, cnv = aligned_pair_matrices(_batch(), _trained_names()) + + np.testing.assert_allclose(expression, EXPRESSION[[0, 0, 1, 1]]) + np.testing.assert_allclose(mutations, MUTATIONS[[0, 0, 1, 1]]) + np.testing.assert_allclose(cnv, CNV[[0, 0, 1, 1]]) + + def test_unknown_training_features_leave_the_matrix_alone(self) -> None: + expression, _, _ = aligned_pair_matrices(_batch(), OmicFeatureNames(None, None, None)) + + np.testing.assert_allclose(expression, EXPRESSION[[0, 0, 1, 1]]) + + def test_a_narrower_batch_is_realigned_onto_the_trained_columns(self) -> None: + # The batch only carries geneA and geneC; geneB must come back as zeros. + narrow = np.array([[1.0, 3.0], [4.0, 6.0]], dtype=np.float32) + batch = _batch(expression=narrow, expression_features=("geneA", "geneC")) + + expression, _, _ = aligned_pair_matrices(batch, _trained_names()) + + assert expression.shape == (4, 3) + np.testing.assert_allclose(expression[0], [1.0, 0.0, 3.0]) + np.testing.assert_allclose(expression[2], [4.0, 0.0, 6.0]) + + def test_a_same_width_reordering_is_left_alone(self) -> None: + """Pre-existing semantics: realignment triggers on a width change, not a rename. + + ``_realign_omic_matrix`` short-circuits when the incoming width already matches, + so equally wide but differently ordered columns pass through. Pinned here because + the extraction had to preserve it, not because it is desirable. + """ + reordered = EXPRESSION[:, [2, 0, 1]] + batch = _batch(expression=reordered, expression_features=("geneC", "geneA", "geneB")) + + expression, _, _ = aligned_pair_matrices(batch, _trained_names()) + + np.testing.assert_allclose(expression[0], reordered[0]) + + def test_matching_views_keep_their_float32_dtype(self) -> None: + for matrix in aligned_pair_matrices(_batch(), _trained_names()): + assert matrix.dtype == np.float32 + + +def test_the_recorded_feature_names_are_frozen() -> None: + """A trained model's feature record must not drift after the fit.""" + names = _trained_names() + + with pytest.raises(AttributeError): + names.gene_expression = ("other",) # type: ignore[misc] diff --git a/tests/components/predictors/naive/_helpers.py b/tests/components/predictors/naive/_helpers.py new file mode 100644 index 000000000..42917cba2 --- /dev/null +++ b/tests/components/predictors/naive/_helpers.py @@ -0,0 +1,127 @@ +"""Shared helpers for naive predictor unit tests.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.types.data.batch.feature_block import FeatureBlock, numeric_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +def one_hot(labels: list[str] | np.ndarray, categories: list[str]) -> np.ndarray: + """Build a dense float64 one-hot matrix for *labels* over *categories*. + + :param labels: Category label per row. + :param categories: Ordered category vocabulary (column order). + :returns: Dense one-hot matrix with shape ``(len(labels), len(categories))``. + """ + index = {category: i for i, category in enumerate(categories)} + matrix = np.zeros((len(labels), len(categories)), dtype=np.float64) + for row, label in enumerate(labels): + col = index.get(str(label)) + if col is not None: + matrix[row, col] = 1.0 + return matrix + + +def _as_feature_blocks(blocks: dict[str, np.ndarray | FeatureBlock] | None) -> dict[str, FeatureBlock]: + if not blocks: + return {} + wrapped: dict[str, FeatureBlock] = {} + for name, value in blocks.items(): + if isinstance(value, FeatureBlock): + wrapped[name] = value + else: + wrapped[name] = numeric_feature_block(np.asarray(value, dtype=np.float32)) + return wrapped + + +def _infer_n_pairs( + *, + n_pairs: int | None, + response: np.ndarray | None, + cell_line_pair_idx: np.ndarray | None, + drug_pair_idx: np.ndarray | None, + cell_line_features: np.ndarray | None, + drug_features: np.ndarray | None, +) -> int: + if n_pairs is not None: + return n_pairs + if response is not None: + return len(response) + if cell_line_pair_idx is not None: + return len(cell_line_pair_idx) + if drug_pair_idx is not None: + return len(drug_pair_idx) + if cell_line_features is not None and cell_line_features.ndim == 2: + return int(cell_line_features.shape[0]) + if drug_features is not None and drug_features.ndim == 2: + return int(drug_features.shape[0]) + msg = "n_pairs, response, or features are required" + raise ValueError(msg) + + +def naive_batch( + *, + n_pairs: int | None = None, + response: np.ndarray | None = None, + cell_line_features: np.ndarray | None = None, + drug_features: np.ndarray | None = None, + cell_line_blocks: dict[str, np.ndarray | FeatureBlock] | None = None, + drug_blocks: dict[str, np.ndarray | FeatureBlock] | None = None, + cell_line_pair_idx: np.ndarray | None = None, + drug_pair_idx: np.ndarray | None = None, + cell_line_ids: np.ndarray | None = None, + drug_ids: np.ndarray | None = None, +) -> ModelInputBatch: + """Build a matrix-native batch for naive predictor tests. + + Pair IDs default to deliberate decoys so tests prove predictors ignore them. + + :param n_pairs: Number of pairs when not implied by other arguments. + :param response: Optional response values aligned with the pairs. + :param cell_line_features: Entity-level cell-line feature matrix. + :param drug_features: Entity-level drug feature matrix. + :param cell_line_blocks: Optional named cell-line feature blocks. + :param drug_blocks: Optional named drug feature blocks. + :param cell_line_pair_idx: Pair-to-cell-line row index. + :param drug_pair_idx: Pair-to-drug row index. + :param cell_line_ids: Decoy pair cell-line IDs (ignored by predictors). + :param drug_ids: Decoy pair drug IDs (ignored by predictors). + :returns: Minimal ``ModelInputBatch`` for naive predictor unit tests. + """ + n_pairs = _infer_n_pairs( + n_pairs=n_pairs, + response=response, + cell_line_pair_idx=cell_line_pair_idx, + drug_pair_idx=drug_pair_idx, + cell_line_features=cell_line_features, + drug_features=drug_features, + ) + + if cell_line_features is None: + cell_line_features = np.ones((n_pairs, 1), dtype=np.float64) + if drug_features is None: + drug_features = np.ones((n_pairs, 1), dtype=np.float64) + if cell_line_pair_idx is None: + cell_line_pair_idx = np.arange(n_pairs, dtype=np.int64) + if drug_pair_idx is None: + drug_pair_idx = np.arange(n_pairs, dtype=np.int64) + if cell_line_ids is None: + cell_line_ids = np.array([f"decoy_cl_{i}" for i in range(n_pairs)], dtype=str) + if drug_ids is None: + drug_ids = np.array([f"decoy_d_{i}" for i in range(n_pairs)], dtype=str) + + return ModelInputBatch( + cell_line_ids=cell_line_ids, + drug_ids=drug_ids, + response=response, + cell_line_entity_ids=np.array([f"entity_cl_{i}" for i in range(len(cell_line_features))], dtype=str), + drug_entity_ids=np.array([f"entity_d_{i}" for i in range(len(drug_features))], dtype=str), + cell_line_features=np.asarray(cell_line_features, dtype=np.float64), + drug_features=np.asarray(drug_features, dtype=np.float64), + cell_line_pair_idx=np.asarray(cell_line_pair_idx, dtype=np.int64), + drug_pair_idx=np.asarray(drug_pair_idx, dtype=np.int64), + cell_line_blocks=_as_feature_blocks(cell_line_blocks), + drug_blocks=_as_feature_blocks(drug_blocks), + ) diff --git a/tests/components/predictors/naive/test_effects.py b/tests/components/predictors/naive/test_effects.py new file mode 100644 index 000000000..9a34f7eb1 --- /dev/null +++ b/tests/components/predictors/naive/test_effects.py @@ -0,0 +1,147 @@ +"""Tests for naive mean-effects predictor.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.predictors.naive.effects import NaiveMeanEffectsPredictor +from tests.components.predictors.naive._helpers import naive_batch, one_hot + + +def test_naive_mean_effects_without_tissue_decomposes_cell_and_drug() -> None: + cell_cats = ["cl1", "cl2"] + drug_cats = ["d1", "d2"] + cell = one_hot(cell_cats, cell_cats) + drugs = one_hot(drug_cats, drug_cats) + response = np.array([1.0, 2.0, 5.0, 8.0]) + predictor = NaiveMeanEffectsPredictor() + predictor.fit( + naive_batch( + response=response, + cell_line_features=cell, + drug_features=drugs, + cell_line_pair_idx=np.array([0, 0, 1, 1], dtype=np.int64), + drug_pair_idx=np.array([0, 1, 0, 1], dtype=np.int64), + cell_line_blocks={"identity": cell}, + ) + ) + preds = predictor.predict( + naive_batch( + cell_line_features=cell, + drug_features=drugs, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + drug_pair_idx=np.array([0, 1], dtype=np.int64), + cell_line_blocks={"identity": cell}, + cell_line_ids=np.array(["wrong_cl", "also_wrong"]), + drug_ids=np.array(["wrong_d", "also_wrong_d"]), + ) + ) + dataset_mean = float(np.mean(response)) + expected = np.array( + [ + dataset_mean + (np.mean([1.0, 2.0]) - dataset_mean) + (np.mean([1.0, 5.0]) - dataset_mean), + dataset_mean + (np.mean([5.0, 8.0]) - dataset_mean) + (np.mean([2.0, 8.0]) - dataset_mean), + ] + ) + np.testing.assert_allclose(preds, expected) + + +def test_naive_mean_effects_with_tissue_blocks() -> None: + cell_cats = ["cl1", "cl2", "cl3"] + drug_cats = ["d1", "d2"] + cell = one_hot(cell_cats, cell_cats) + # cl1,cl2 -> lung; cl3 -> blood + tissue = np.array([[1.0, 0.0], [1.0, 0.0], [0.0, 1.0]], dtype=np.float64) + drugs = one_hot(drug_cats, drug_cats) + response = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) + pair_cl = np.array([0, 0, 1, 1, 2, 2], dtype=np.int64) + pair_d = np.array([0, 1, 0, 1, 0, 1], dtype=np.int64) + predictor = NaiveMeanEffectsPredictor() + predictor.fit( + naive_batch( + response=response, + cell_line_features=np.concatenate([cell, tissue], axis=1), + drug_features=drugs, + cell_line_pair_idx=pair_cl, + drug_pair_idx=pair_d, + cell_line_blocks={"identity": cell, "tissue": tissue}, + ) + ) + preds = predictor.predict( + naive_batch( + cell_line_features=np.concatenate([cell, tissue], axis=1), + drug_features=drugs, + cell_line_pair_idx=np.array([0, 1, 2], dtype=np.int64), + drug_pair_idx=np.array([0, 1, 0], dtype=np.int64), + cell_line_blocks={"identity": cell, "tissue": tissue}, + ) + ) + dataset_mean = float(np.mean(response)) + lung_mean = float(np.mean([1.0, 2.0, 3.0, 4.0])) + blood_mean = float(np.mean([5.0, 6.0])) + cl1_mean = float(np.mean([1.0, 2.0])) + cl2_mean = float(np.mean([3.0, 4.0])) + cl3_mean = float(np.mean([5.0, 6.0])) + d1_effect = float(np.mean([1.0, 3.0, 5.0]) - dataset_mean) + d2_effect = float(np.mean([2.0, 4.0, 6.0]) - dataset_mean) + expected = np.array( + [ + dataset_mean + (lung_mean - dataset_mean) + (cl1_mean - lung_mean) + d1_effect, + dataset_mean + (lung_mean - dataset_mean) + (cl2_mean - lung_mean) + d2_effect, + dataset_mean + (blood_mean - dataset_mean) + (cl3_mean - blood_mean) + d1_effect, + ] + ) + np.testing.assert_allclose(preds, expected) + + +def test_naive_mean_effects_empty_optional_tissue() -> None: + cell = one_hot(["cl1", "cl2"], ["cl1", "cl2"]) + drugs = one_hot(["d1", "d2"], ["d1", "d2"]) + response = np.array([1.0, 2.0, 5.0, 8.0]) + empty_tissue = np.empty((2, 0), dtype=np.float64) + predictor = NaiveMeanEffectsPredictor() + predictor.fit( + naive_batch( + response=response, + cell_line_features=cell, + drug_features=drugs, + cell_line_pair_idx=np.array([0, 0, 1, 1], dtype=np.int64), + drug_pair_idx=np.array([0, 1, 0, 1], dtype=np.int64), + cell_line_blocks={"identity": cell, "tissue": empty_tissue}, + ) + ) + preds = predictor.predict( + naive_batch( + cell_line_features=cell, + drug_features=drugs, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + drug_pair_idx=np.array([0, 1], dtype=np.int64), + cell_line_blocks={"identity": cell, "tissue": empty_tissue}, + ) + ) + dataset_mean = float(np.mean(response)) + expected = np.array( + [ + dataset_mean + (np.mean([1.0, 2.0]) - dataset_mean) + (np.mean([1.0, 5.0]) - dataset_mean), + dataset_mean + (np.mean([5.0, 8.0]) - dataset_mean) + (np.mean([2.0, 8.0]) - dataset_mean), + ] + ) + np.testing.assert_allclose(preds, expected) + + +def test_naive_mean_effects_state_roundtrip() -> None: + cell = one_hot(["cl1"], ["cl1"]) + drugs = one_hot(["d1"], ["d1"]) + predictor = NaiveMeanEffectsPredictor() + batch = naive_batch( + response=np.array([4.0]), + cell_line_features=cell, + drug_features=drugs, + cell_line_pair_idx=np.array([0], dtype=np.int64), + drug_pair_idx=np.array([0], dtype=np.int64), + cell_line_blocks={"identity": cell}, + ) + predictor.fit(batch) + restored = NaiveMeanEffectsPredictor() + restored.set_state(predictor.get_state()) + np.testing.assert_allclose(restored.predict(batch), predictor.predict(batch)) diff --git a/tests/components/predictors/naive/test_entity_mean.py b/tests/components/predictors/naive/test_entity_mean.py new file mode 100644 index 000000000..12347c7f1 --- /dev/null +++ b/tests/components/predictors/naive/test_entity_mean.py @@ -0,0 +1,73 @@ +"""Tests for per-entity naive mean predictors.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.predictors.naive.entity_mean import ( + NaiveCellLineMeanPredictor, + NaiveDrugMeanPredictor, +) +from tests.components.predictors.naive._helpers import naive_batch, one_hot + + +def test_naive_drug_mean_follows_matrix_not_ids() -> None: + categories = ["d1", "d2"] + # Entity matrix rows are [d1, d2]; pair index maps pairs onto those rows. + drug_features = one_hot(categories, categories) + predictor = NaiveDrugMeanPredictor() + predictor.fit( + naive_batch( + response=np.array([1.0, 3.0, 5.0]), + drug_features=drug_features, + drug_pair_idx=np.array([0, 1, 0], dtype=np.int64), + cell_line_ids=np.array(["noise1", "noise2", "noise3"]), + drug_ids=np.array(["not_d1", "not_d2", "still_not_d1"]), + ) + ) + # Predictions must follow matrix columns, not the decoy drug_ids. + preds = predictor.predict( + naive_batch( + drug_features=drug_features, + drug_pair_idx=np.array([0, 1], dtype=np.int64), + drug_ids=np.array(["decoy_a", "decoy_b"]), + ) + ) + assert preds.tolist() == [3.0, 3.0] + + +def test_naive_cell_line_mean_unseen_zero_row_falls_back() -> None: + categories = ["cl1", "cl2"] + cell_features = one_hot(categories, categories) + predictor = NaiveCellLineMeanPredictor() + predictor.fit( + naive_batch( + response=np.array([2.0, 6.0]), + cell_line_features=cell_features, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + ) + ) + unseen = np.zeros((1, 2), dtype=np.float64) + preds = predictor.predict( + naive_batch( + cell_line_features=np.vstack([cell_features, unseen]), + cell_line_pair_idx=np.array([0, 1, 2], dtype=np.int64), + ) + ) + assert preds.tolist() == [2.0, 6.0, 4.0] + + +def test_naive_drug_mean_state_roundtrip() -> None: + categories = ["d1", "d2"] + drug_features = one_hot(categories, categories) + predictor = NaiveDrugMeanPredictor() + fit_batch = naive_batch( + response=np.array([1.0, 5.0]), + drug_features=drug_features, + drug_pair_idx=np.array([0, 1], dtype=np.int64), + ) + predictor.fit(fit_batch) + restored = NaiveDrugMeanPredictor() + restored.set_state(predictor.get_state()) + preds = restored.predict(fit_batch) + assert preds.tolist() == [1.0, 5.0] diff --git a/tests/components/predictors/naive/test_matrix_means.py b/tests/components/predictors/naive/test_matrix_means.py new file mode 100644 index 000000000..ca1c1b5c5 --- /dev/null +++ b/tests/components/predictors/naive/test_matrix_means.py @@ -0,0 +1,204 @@ +"""Tests for the matrix helpers shared by the naive mean predictors. + +Mirrors the private module +``drevalpy.components.predictors.naive._matrix_means`` with the leading +underscore stripped (``AGENTS.md`` rule 4: the ``naive`` package exposes no +public module for these helpers). All eight functions are exercised here +directly rather than only indirectly through ``effects.py`` / ``mean.py``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.predictors.naive._matrix_means import ( + additive_effects, + block_pair_matrix, + category_means, + pair_align, + predict_with_effects, + require_pair_matrix, + state_float_matrix, + state_float_vector, +) +from tests.components.predictors.naive._helpers import naive_batch, one_hot + + +def test_pair_align_expands_entity_rows_to_pair_rows() -> None: + entity_matrix = np.array([[1.0, 2.0], [3.0, 4.0]]) + + result = pair_align(entity_matrix, np.array([1, 0, 1])) + + np.testing.assert_allclose(result, [[3.0, 4.0], [1.0, 2.0], [3.0, 4.0]]) + + +def test_pair_align_promotes_a_one_dimensional_matrix_to_a_column() -> None: + result = pair_align(np.array([5.0, 6.0]), np.array([0, 1, 1])) + + assert result.shape == (3, 1) + np.testing.assert_allclose(result, [[5.0], [6.0], [6.0]]) + + +def test_pair_align_requires_a_pair_index() -> None: + with pytest.raises(ValueError, match="pair index is required"): + pair_align(np.ones((2, 2)), None) + + +def test_require_pair_matrix_aligns_the_cell_line_side() -> None: + batch = naive_batch( + cell_line_features=one_hot(["cl1", "cl2"], ["cl1", "cl2"]), + cell_line_pair_idx=np.array([0, 1, 0], dtype=np.int64), + n_pairs=3, + ) + + result = require_pair_matrix(batch, side="cell_line") + + np.testing.assert_allclose(result, [[1.0, 0.0], [0.0, 1.0], [1.0, 0.0]]) + + +def test_require_pair_matrix_aligns_the_drug_side() -> None: + batch = naive_batch( + drug_features=one_hot(["d1", "d2"], ["d1", "d2"]), + drug_pair_idx=np.array([1, 1], dtype=np.int64), + n_pairs=2, + ) + + result = require_pair_matrix(batch, side="drug") + + np.testing.assert_allclose(result, [[0.0, 1.0], [0.0, 1.0]]) + + +def test_require_pair_matrix_rejects_an_unknown_side() -> None: + with pytest.raises(ValueError, match="Unknown feature side"): + require_pair_matrix(naive_batch(n_pairs=1), side="tissue") + + +def test_block_pair_matrix_aligns_a_named_cell_line_block() -> None: + identity = one_hot(["cl1", "cl2"], ["cl1", "cl2"]) + batch = naive_batch( + cell_line_features=identity, + cell_line_pair_idx=np.array([1, 0], dtype=np.int64), + cell_line_blocks={"identity": identity}, + n_pairs=2, + ) + + result = block_pair_matrix(batch, "identity") + + np.testing.assert_allclose(result, [[0.0, 1.0], [1.0, 0.0]]) + + +def test_block_pair_matrix_rejects_a_missing_block() -> None: + batch = naive_batch(n_pairs=1) + + with pytest.raises(ValueError, match="Required cell-line block 'tissue' is missing"): + block_pair_matrix(batch, "tissue") + + +def test_category_means_averages_the_response_per_one_hot_column() -> None: + design = one_hot(["a", "b", "a"], ["a", "b"]) + + result = category_means(design, np.array([1.0, 10.0, 3.0])) + + np.testing.assert_allclose(result, [2.0, 10.0]) + + +def test_category_means_returns_zero_for_unobserved_columns() -> None: + design = one_hot(["a", "a"], ["a", "b"]) + + result = category_means(design, np.array([4.0, 6.0])) + + np.testing.assert_allclose(result, [5.0, 0.0]) + + +def test_category_means_returns_an_empty_vector_for_a_zero_width_design() -> None: + result = category_means(np.empty((3, 0)), np.array([1.0, 2.0, 3.0])) + + assert result.shape == (0,) + + +def test_category_means_rejects_a_non_two_dimensional_design() -> None: + with pytest.raises(ValueError, match="must be 2-dimensional"): + category_means(np.array([1.0, 2.0]), np.array([1.0, 2.0])) + + +def test_category_means_rejects_a_response_length_mismatch() -> None: + with pytest.raises(ValueError, match="must match response length"): + category_means(np.ones((3, 2)), np.array([1.0, 2.0])) + + +def test_additive_effects_are_category_means_minus_the_baseline() -> None: + design = one_hot(["a", "b"], ["a", "b"]) + + result = additive_effects(design, np.array([2.0, 6.0]), baseline=4.0) + + np.testing.assert_allclose(result, [-2.0, 2.0]) + + +def test_additive_effects_are_zero_for_unobserved_columns() -> None: + design = one_hot(["a", "a"], ["a", "b"]) + + result = additive_effects(design, np.array([2.0, 6.0]), baseline=4.0) + + np.testing.assert_allclose(result, [0.0, 0.0]) + + +def test_additive_effects_falls_back_to_category_means_for_a_zero_width_design() -> None: + result = additive_effects(np.empty((2, 0)), np.array([1.0, 2.0]), baseline=1.5) + + assert result.shape == (0,) + + +def test_predict_with_effects_adds_the_selected_effects_to_the_baseline() -> None: + design = one_hot(["a", "b", "a"], ["a", "b"]) + + result = predict_with_effects(design, np.array([-1.0, 2.0]), baseline=5.0) + + np.testing.assert_allclose(result, [4.0, 7.0, 4.0]) + + +def test_predict_with_effects_returns_the_baseline_for_a_zero_width_design() -> None: + result = predict_with_effects(np.empty((3, 0)), np.empty(0), baseline=2.5) + + np.testing.assert_allclose(result, [2.5, 2.5, 2.5]) + + +def test_predict_with_effects_rejects_a_non_two_dimensional_design() -> None: + with pytest.raises(ValueError, match="must be 2-dimensional"): + predict_with_effects(np.array([1.0, 0.0]), np.array([1.0, 2.0]), baseline=0.0) + + +def test_predict_with_effects_rejects_an_effect_length_mismatch() -> None: + with pytest.raises(ValueError, match="must match design columns"): + predict_with_effects(np.ones((2, 3)), np.array([1.0, 2.0]), baseline=0.0) + + +def test_state_float_vector_flattens_nested_lists() -> None: + result = state_float_vector({"effects": [[1.0], [2.0]]}, "effects") + + assert result is not None + np.testing.assert_allclose(result, [1.0, 2.0]) + assert result.dtype == np.float64 + + +def test_state_float_vector_returns_none_when_absent() -> None: + assert state_float_vector({}, "effects") is None + + +def test_state_float_matrix_promotes_a_flat_list_to_a_column() -> None: + result = state_float_matrix({"table": [1.0, 2.0, 3.0]}, "table") + + assert result is not None + assert result.shape == (3, 1) + + +def test_state_float_matrix_preserves_two_dimensional_payloads() -> None: + result = state_float_matrix({"table": [[1.0, 2.0], [3.0, 4.0]]}, "table") + + assert result is not None + assert result.shape == (2, 2) + assert result.dtype == np.float64 + + +def test_state_float_matrix_returns_none_when_absent() -> None: + assert state_float_matrix({"table": None}, "table") is None diff --git a/tests/components/predictors/naive/test_mean.py b/tests/components/predictors/naive/test_mean.py new file mode 100644 index 000000000..181a094df --- /dev/null +++ b/tests/components/predictors/naive/test_mean.py @@ -0,0 +1,43 @@ +"""Tests for global naive mean predictor.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.predictors.naive.mean import NaiveMeanPredictor +from tests.components.predictors.naive._helpers import naive_batch + + +def test_naive_mean_predictor_returns_dataset_mean() -> None: + predictor = NaiveMeanPredictor() + batch = naive_batch(response=np.array([2.0, 4.0])) + predictor.fit(batch) + preds = predictor.predict(batch) + assert preds.tolist() == [3.0, 3.0] + + +def test_naive_mean_predictor_ignores_ids_and_matrices() -> None: + predictor = NaiveMeanPredictor() + fit_batch = naive_batch( + response=np.array([1.0, 5.0]), + cell_line_ids=np.array(["wrong_a", "wrong_b"]), + drug_ids=np.array(["wrong_x", "wrong_y"]), + ) + predictor.fit(fit_batch) + preds = predictor.predict( + naive_batch( + n_pairs=3, + cell_line_ids=np.array(["a", "b", "c"]), + drug_ids=np.array(["x", "y", "z"]), + ) + ) + assert preds.tolist() == [3.0, 3.0, 3.0] + + +def test_naive_mean_state_roundtrip() -> None: + predictor = NaiveMeanPredictor() + predictor.fit(naive_batch(response=np.array([2.0, 6.0]))) + restored = NaiveMeanPredictor() + restored.set_state(predictor.get_state()) + preds = restored.predict(naive_batch(n_pairs=2)) + assert preds.tolist() == [4.0, 4.0] diff --git a/tests/components/predictors/naive/test_single_entity.py b/tests/components/predictors/naive/test_single_entity.py new file mode 100644 index 000000000..23fad381d --- /dev/null +++ b/tests/components/predictors/naive/test_single_entity.py @@ -0,0 +1,190 @@ +"""Tests for the shared entity-level naive predictor base. + +Mirrors the private module +``drevalpy.components.predictors.naive._single_entity`` with the leading +underscore stripped (``AGENTS.md`` rule 4). ``entity_mean.py`` derives both of +its predictors from this base and adds nothing but a ``_feature_side``, so the +lifecycle behaviour is asserted here and ``test_entity_mean.py`` keeps only the +per-side wiring. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.naive._single_entity import SingleEntityNaivePredictor +from tests.components.predictors.naive._helpers import naive_batch, one_hot + + +class _CellLineSide(SingleEntityNaivePredictor): + """Concrete probe fixing the feature side to the cell-line matrix.""" + + +class _DrugSide(SingleEntityNaivePredictor): + """Concrete probe fixing the feature side to the drug matrix.""" + + _feature_side = "drug" + + +def test_single_entity_predictor_reads_named_blocks_not_flat_matrices() -> None: + assert issubclass(SingleEntityNaivePredictor, BlockPredictor) + assert SingleEntityNaivePredictor.input_interface == "block" + + +def test_single_entity_predictor_defaults_to_the_cell_line_side() -> None: + assert SingleEntityNaivePredictor._feature_side == "cell_line" + + +def test_single_entity_predictor_is_not_fitted_before_fit() -> None: + assert _CellLineSide().is_fitted() is False + + +def test_single_entity_predictor_reports_fitted_after_fit() -> None: + categories = ["cl1", "cl2"] + predictor = _CellLineSide() + + predictor.fit( + naive_batch( + response=np.array([2.0, 6.0]), + cell_line_features=one_hot(categories, categories), + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + ) + ) + + assert predictor.is_fitted() is True + + +def test_single_entity_predictor_recovers_per_entity_means() -> None: + categories = ["cl1", "cl2"] + features = one_hot(categories, categories) + predictor = _CellLineSide() + + predictor.fit( + naive_batch( + response=np.array([1.0, 3.0, 5.0, 7.0]), + cell_line_features=features, + cell_line_pair_idx=np.array([0, 0, 1, 1], dtype=np.int64), + ) + ) + preds = predictor.predict( + naive_batch( + cell_line_features=features, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + ) + ) + + np.testing.assert_allclose(preds, [2.0, 6.0]) + + +def test_single_entity_predictor_falls_back_to_the_dataset_mean_for_an_all_zero_row() -> None: + categories = ["cl1", "cl2"] + features = one_hot(categories, categories) + predictor = _CellLineSide() + predictor.fit( + naive_batch( + response=np.array([2.0, 6.0]), + cell_line_features=features, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + ) + ) + + preds = predictor.predict( + naive_batch( + cell_line_features=np.vstack([features, np.zeros((1, 2))]), + cell_line_pair_idx=np.array([0, 1, 2], dtype=np.int64), + ) + ) + + np.testing.assert_allclose(preds, [2.0, 6.0, 4.0]) + + +def test_single_entity_predictor_honours_the_drug_feature_side() -> None: + categories = ["d1", "d2"] + features = one_hot(categories, categories) + predictor = _DrugSide() + + predictor.fit( + naive_batch( + response=np.array([1.0, 9.0]), + drug_features=features, + drug_pair_idx=np.array([0, 1], dtype=np.int64), + ) + ) + preds = predictor.predict( + naive_batch( + drug_features=features, + drug_pair_idx=np.array([1, 0], dtype=np.int64), + ) + ) + + np.testing.assert_allclose(preds, [9.0, 1.0]) + + +def test_single_entity_predictor_rejects_fit_without_a_response() -> None: + predictor = _CellLineSide() + + with pytest.raises(ValueError, match="require response values during fit"): + predictor.fit(naive_batch(n_pairs=2)) + + +def test_single_entity_predictor_inner_fit_guards_against_a_missing_response() -> None: + predictor = _CellLineSide() + + with pytest.raises(RuntimeError, match="batch.response is required"): + predictor._fit(naive_batch(n_pairs=2)) + + +def test_single_entity_predictor_requires_fit_before_predict() -> None: + predictor = _CellLineSide() + + with pytest.raises(RuntimeError, match="Call fit before predict"): + predictor.predict(naive_batch(n_pairs=2)) + + +def test_single_entity_predictor_get_state_is_empty_before_fit() -> None: + assert _CellLineSide().get_state() == {} + + +def test_single_entity_predictor_state_round_trip_reproduces_predictions() -> None: + categories = ["cl1", "cl2"] + features = one_hot(categories, categories) + predictor = _CellLineSide() + batch = naive_batch( + response=np.array([2.0, 8.0]), + cell_line_features=features, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + ) + predictor.fit(batch) + + restored = _CellLineSide() + restored.set_state(predictor.get_state()) + + assert restored.is_fitted() is True + np.testing.assert_allclose(restored.predict(batch), predictor.predict(batch)) + + +def test_single_entity_predictor_set_state_ignores_an_empty_payload() -> None: + predictor = _CellLineSide() + + predictor.set_state({}) + + assert predictor.is_fitted() is False + + +def test_single_entity_predictor_state_is_json_friendly() -> None: + categories = ["cl1", "cl2"] + predictor = _CellLineSide() + predictor.fit( + naive_batch( + response=np.array([2.0, 8.0]), + cell_line_features=one_hot(categories, categories), + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + ) + ) + + state = predictor.get_state() + + assert isinstance(state["dataset_mean"], float) + assert isinstance(state["effects"], list) diff --git a/tests/components/predictors/naive/test_state_mixin.py b/tests/components/predictors/naive/test_state_mixin.py new file mode 100644 index 000000000..1cd943343 --- /dev/null +++ b/tests/components/predictors/naive/test_state_mixin.py @@ -0,0 +1,219 @@ +"""Tests for :mod:`drevalpy.components.predictors.naive._state_mixin`. + +Mirrors the private module with the underscore stripped. The five naive +predictors get their persistence from this mixin, and each asserts its own round +trip; what is pinned here is the mixin's own contract - key naming, the +vector/matrix restore switch and the ``is_fitted`` transitions - on throwaway +subclasses, so no predictor lifecycle is involved. +""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np + +from drevalpy.components.predictors.naive._state_mixin import MeanEffectsStateMixin + + +class _Host: + """Stand-in for the predictor base the mixin cooperates with.""" + + def __init__(self, hyperparameters: dict | None = None) -> None: + self.hyperparameters = hyperparameters + + +class _MeanOnly(MeanEffectsStateMixin, _Host): + """Holds nothing but the dataset mean, like ``NaiveMeanPredictor``.""" + + +class _OneVector(MeanEffectsStateMixin, _Host): + """One 1-D effect array, like the per-entity and per-tissue means.""" + + state_effects: ClassVar[tuple[str, ...]] = ("effects",) + + +class _ThreeVectors(MeanEffectsStateMixin, _Host): + """Three 1-D effect arrays, like ``NaiveMeanEffectsPredictor``.""" + + state_effects: ClassVar[tuple[str, ...]] = ("tissue_effects", "cell_line_effects", "drug_effects") + + +class _OneMatrix(MeanEffectsStateMixin, _Host): + """One 2-D effect array, like ``NaiveTissueDrugMeanPredictor``.""" + + state_effects: ClassVar[tuple[str, ...]] = ("effects",) + state_effects_ndim: ClassVar[int] = 2 + + +class TestInitialisation: + def test_forwards_hyperparameters_to_the_host(self): + assert _OneVector({"a": 1}).hyperparameters == {"a": 1} + + def test_starts_unfitted(self): + assert _OneVector().is_fitted() is False + + def test_declares_every_effect_attribute_as_none(self): + model = _ThreeVectors() + + assert (model._tissue_effects, model._cell_line_effects, model._drug_effects) == (None, None, None) + + def test_an_effect_free_host_is_unfitted_until_the_mean_is_set(self): + assert _MeanOnly().is_fitted() is False + + +class TestIsFitted: + def test_needs_the_mean(self): + model = _OneVector() + model._effects = np.zeros(2) + + assert model.is_fitted() is False + + def test_needs_every_effect_array(self): + model = _ThreeVectors() + model._dataset_mean = 0.5 + model._tissue_effects = np.zeros(1) + model._cell_line_effects = np.zeros(1) + + assert model.is_fitted() is False + + model._drug_effects = np.zeros(1) + + assert model.is_fitted() is True + + def test_the_mean_alone_fits_an_effect_free_host(self): + model = _MeanOnly() + model._dataset_mean = 0.5 + + assert model.is_fitted() is True + + def test_an_empty_effect_array_still_counts_as_fitted(self): + """``NaiveMeanEffectsPredictor`` stores an empty tissue vector when no tissue block is present.""" + model = _OneVector() + model._dataset_mean = 0.5 + model._effects = np.empty((0,)) + + assert model.is_fitted() is True + + +class TestGetState: + def test_is_empty_while_unfitted(self): + assert _OneVector().get_state() == {} + + def test_is_empty_when_one_effect_array_is_missing(self): + model = _ThreeVectors() + model._dataset_mean = 0.5 + model._tissue_effects = np.zeros(1) + + assert model.get_state() == {} + + def test_keys_are_the_attribute_names_without_the_underscore(self): + model = _ThreeVectors() + model._dataset_mean = 0.5 + model._tissue_effects = np.zeros(1) + model._cell_line_effects = np.zeros(1) + model._drug_effects = np.zeros(1) + + assert list(model.get_state()) == [ + "dataset_mean", + "tissue_effects", + "cell_line_effects", + "drug_effects", + ] + + def test_arrays_are_serialized_as_plain_lists(self): + model = _OneVector() + model._dataset_mean = 0.5 + model._effects = np.array([1.0, 2.0]) + + assert model.get_state()["effects"] == [1.0, 2.0] + + def test_effect_free_host_reports_only_the_mean(self): + model = _MeanOnly() + model._dataset_mean = 0.25 + + assert model.get_state() == {"dataset_mean": 0.25} + + +class TestSetState: + def test_round_trips_a_vector(self): + model = _OneVector() + model._dataset_mean = 0.5 + model._effects = np.array([1.0, -2.0, 3.0]) + + restored = _OneVector() + restored.set_state(model.get_state()) + + assert restored._dataset_mean == 0.5 + np.testing.assert_allclose(restored._effects, model._effects) + + def test_round_trips_every_effect_array(self): + model = _ThreeVectors() + model._dataset_mean = -1.5 + model._tissue_effects = np.array([0.1]) + model._cell_line_effects = np.array([0.2, 0.3]) + model._drug_effects = np.array([0.4, 0.5, 0.6]) + + restored = _ThreeVectors() + restored.set_state(model.get_state()) + + assert restored.is_fitted() is True + np.testing.assert_allclose(restored._cell_line_effects, [0.2, 0.3]) + np.testing.assert_allclose(restored._drug_effects, [0.4, 0.5, 0.6]) + + def test_restores_a_matrix_as_two_dimensional(self): + model = _OneMatrix() + model._dataset_mean = 0.0 + model._effects = np.array([[1.0, 2.0], [3.0, 4.0]]) + + restored = _OneMatrix() + restored.set_state(model.get_state()) + + assert restored._effects.shape == (2, 2) + np.testing.assert_allclose(restored._effects, model._effects) + + def test_a_matrix_host_keeps_a_flat_payload_two_dimensional(self): + restored = _OneMatrix() + + restored.set_state({"dataset_mean": 0.0, "effects": [1.0, 2.0]}) + + assert restored._effects.shape == (2, 1) + + def test_a_vector_host_flattens_its_payload(self): + restored = _OneVector() + + restored.set_state({"dataset_mean": 0.0, "effects": [[1.0], [2.0]]}) + + assert restored._effects.shape == (2,) + + def test_an_empty_state_leaves_the_model_unfitted(self): + restored = _OneVector() + + restored.set_state({}) + + assert restored.is_fitted() is False + + def test_a_partial_state_does_not_clobber_what_is_absent(self): + model = _OneVector() + model._dataset_mean = 0.5 + model._effects = np.array([1.0]) + + model.set_state({"dataset_mean": 2.0}) + + assert model._dataset_mean == 2.0 + np.testing.assert_allclose(model._effects, [1.0]) + + def test_reads_a_stringified_mean(self): + """``state_float`` accepts the string a JSON/YAML checkpoint may carry.""" + model = _MeanOnly() + + model.set_state({"dataset_mean": "0.75"}) + + assert model._dataset_mean == 0.75 + + def test_coerces_restored_arrays_to_float(self): + model = _OneVector() + + model.set_state({"dataset_mean": 0.0, "effects": [1, 2]}) + + assert model._effects.dtype == np.float64 diff --git a/tests/components/predictors/naive/test_tissue.py b/tests/components/predictors/naive/test_tissue.py new file mode 100644 index 000000000..69db0377a --- /dev/null +++ b/tests/components/predictors/naive/test_tissue.py @@ -0,0 +1,133 @@ +"""Tests for tissue-aware naive predictors.""" + +from __future__ import annotations + +import tempfile + +import numpy as np +import pytest + +from drevalpy.components.predictors.naive.tissue import ( + NaiveTissueDrugMeanPredictor, + NaiveTissueMeanPredictor, +) +from drevalpy.models import construct_model +from drevalpy.registry._builtins import register_builtin_components +from tests.components.predictors.naive._helpers import naive_batch, one_hot + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_naive_tissue_mean_requires_tissue_features() -> None: + predictor = NaiveTissueMeanPredictor() + batch = naive_batch( + response=np.array([1.0]), + cell_line_features=np.empty((1, 0), dtype=np.float64), + ) + with pytest.raises(ValueError, match="requires tissue"): + predictor.fit(batch) + + +def test_naive_tissue_mean_predicts_from_matrix_not_ids() -> None: + tissues = ["lung", "blood"] + tissue_features = one_hot(tissues, tissues) + predictor = NaiveTissueMeanPredictor() + predictor.fit( + naive_batch( + response=np.array([2.0, 4.0]), + cell_line_features=tissue_features, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + cell_line_ids=np.array(["ignore_a", "ignore_b"]), + ) + ) + preds = predictor.predict( + naive_batch( + cell_line_features=tissue_features, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + cell_line_ids=np.array(["other_a", "other_b"]), + ) + ) + assert preds.tolist() == [2.0, 4.0] + + +def test_naive_tissue_drug_mean_interaction_table() -> None: + tissue_cats = ["lung", "blood"] + drug_cats = ["d1", "d2"] + # Pairs: lung-d1=1, lung-d2=3, blood-d1=5 (blood-d2 unseen) + tissue_entity = one_hot(tissue_cats, tissue_cats) + drug_entity = one_hot(drug_cats, drug_cats) + predictor = NaiveTissueDrugMeanPredictor() + predictor.fit( + naive_batch( + response=np.array([1.0, 3.0, 5.0]), + cell_line_features=tissue_entity, + drug_features=drug_entity, + cell_line_pair_idx=np.array([0, 0, 1], dtype=np.int64), + drug_pair_idx=np.array([0, 1, 0], dtype=np.int64), + ) + ) + dataset_mean = 3.0 + preds = predictor.predict( + naive_batch( + cell_line_features=tissue_entity, + drug_features=drug_entity, + cell_line_pair_idx=np.array([0, 0, 1, 1], dtype=np.int64), + drug_pair_idx=np.array([0, 1, 0, 1], dtype=np.int64), + ) + ) + # Unseen blood-d2 falls back to dataset mean. + assert preds.tolist() == [1.0, 3.0, 5.0, dataset_mean] + + +def test_naive_tissue_mean_state_roundtrip() -> None: + tissues = ["lung", "blood"] + tissue_features = one_hot(tissues, tissues) + predictor = NaiveTissueMeanPredictor() + batch = naive_batch( + response=np.array([2.0, 8.0]), + cell_line_features=tissue_features, + cell_line_pair_idx=np.array([0, 1], dtype=np.int64), + ) + predictor.fit(batch) + restored = NaiveTissueMeanPredictor() + restored.set_state(predictor.get_state()) + np.testing.assert_allclose(restored.predict(batch), [2.0, 8.0]) + + +def test_naive_tissue_round_trip() -> None: + import anndata as ad + import mudata as md + import pandas as pd + + from drevalpy.types import SplitMask, SplitMasks + from drevalpy.types.data.dataset import Dataset + + cl_ids = np.array(["cl1", "cl2"]) + drug_ids = np.array(["d1", "d2"]) + response_matrix = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + response_ad = ad.AnnData( + X=response_matrix, + obs=pd.DataFrame({"cell_line_name": cl_ids, "tissue": ["Lung", "Blood"]}, index=cl_ids), + var=pd.DataFrame(index=drug_ids), + ) + mdata = md.MuData({"response": response_ad}) + mdata.obs["tissue"] = ["Lung", "Blood"] + mudataset = Dataset(mdata, name="test") + split = SplitMasks( + train=SplitMask(np.array([[True, True], [False, False]])), + test=SplitMask(np.array([[False, False], [True, True]])), + val=SplitMask(np.zeros((2, 2), dtype=bool)), + ) + model = construct_model("NaiveTissueMeanPredictor")() + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert np.isfinite(preds).all() + with tempfile.TemporaryDirectory() as tmp: + checkpoint = f"{tmp}/model" + model.save(checkpoint) + loaded = type(model).load(checkpoint) + loaded_preds = loaded.predict(mudataset, split) + assert np.allclose(preds, loaded_preds) diff --git a/tests/components/predictors/neural_network/test_network.py b/tests/components/predictors/neural_network/test_network.py new file mode 100644 index 000000000..38ef4aa3a --- /dev/null +++ b/tests/components/predictors/neural_network/test_network.py @@ -0,0 +1,130 @@ +"""Tests for the dense feed-forward Lightning network.""" + +from __future__ import annotations + +import pytest +import torch +from torch import nn + +from drevalpy.components.predictors.neural_network.network import FeedForwardNetwork + +HPAMS = {"units_per_layer": [8, 4, 2], "dropout_prob": 0.0} + + +def _network(input_dim: int = 6, **overrides: object) -> FeedForwardNetwork: + hyperparameters = {**HPAMS, **overrides} + network = FeedForwardNetwork(hyperparameters, input_dim) + network.eval() + return network + + +def test_network_rejects_non_list_units_per_layer() -> None: + with pytest.raises(TypeError, match="units_per_layer must be a list of integers"): + FeedForwardNetwork({"units_per_layer": 8, "dropout_prob": 0.1}, 4) + + +def test_network_rejects_non_integer_layer_widths() -> None: + with pytest.raises(TypeError, match="units_per_layer must be a list of integers"): + FeedForwardNetwork({"units_per_layer": [8, 4.5], "dropout_prob": 0.1}, 4) + + +def test_network_rejects_an_integer_dropout_probability() -> None: + with pytest.raises(TypeError, match="dropout_prob must be a float"): + FeedForwardNetwork({"units_per_layer": [8, 4], "dropout_prob": 0}, 4) + + +def test_network_builds_one_linear_per_layer_plus_an_output_head() -> None: + network = _network(input_dim=6) + + assert len(network.fully_connected_layers) == 4 + assert network.fully_connected_layers[0].in_features == 6 + assert network.fully_connected_layers[-1].out_features == 1 + + +def test_network_chains_the_requested_layer_widths() -> None: + network = _network(input_dim=6) + + widths = [(layer.in_features, layer.out_features) for layer in network.fully_connected_layers] + + assert widths == [(6, 8), (8, 4), (4, 2), (2, 1)] + + +def test_network_builds_one_batch_norm_per_hidden_layer() -> None: + network = _network() + + assert len(network.batch_norm_layers) == 3 + assert all(isinstance(layer, nn.BatchNorm1d) for layer in network.batch_norm_layers) + + +def test_network_uses_the_requested_dropout_probability() -> None: + network = _network(dropout_prob=0.35) + + assert network.dropout_layer is not None + assert network.dropout_layer.p == pytest.approx(0.35) + + +def test_network_predicts_one_flat_scalar_per_row() -> None: + network = _network(input_dim=6) + + with torch.no_grad(): + output = network(torch.randn(5, 6)) + + assert output.shape == (5,) + assert torch.isfinite(output).all() + + +def test_network_supports_a_single_hidden_layer() -> None: + network = _network(input_dim=4, units_per_layer=[3]) + + with torch.no_grad(): + output = network(torch.randn(2, 4)) + + assert output.shape == (2,) + + +def test_unpack_batch_concatenates_all_but_the_last_tensor() -> None: + cell = torch.zeros(3, 2) + drug = torch.ones(3, 4) + response = torch.arange(3, dtype=torch.float32) + + features, unpacked_response = FeedForwardNetwork._unpack_batch((cell, drug, response)) + + assert features.shape == (3, 6) + assert torch.allclose(unpacked_response, response) + + +def test_unpack_batch_handles_a_cell_line_only_batch() -> None: + cell = torch.zeros(2, 5) + response = torch.ones(2) + + features, _ = FeedForwardNetwork._unpack_batch((cell, response)) + + assert features.shape == (2, 5) + + +def test_training_step_returns_a_scalar_mse_loss() -> None: + network = FeedForwardNetwork(HPAMS, 6) + batch = (torch.randn(4, 4), torch.randn(4, 2), torch.randn(4)) + + loss = network.training_step(batch, 0) + + assert loss.ndim == 0 + assert loss.item() >= 0.0 + + +def test_validation_step_returns_a_scalar_mse_loss() -> None: + network = FeedForwardNetwork(HPAMS, 6) + batch = (torch.randn(4, 4), torch.randn(4, 2), torch.randn(4)) + + loss = network.validation_step(batch, 0) + + assert loss.ndim == 0 + + +def test_configure_optimizers_returns_adam_over_all_parameters() -> None: + network = _network() + + optimizer = network.configure_optimizers() + + assert isinstance(optimizer, torch.optim.Adam) + assert sum(len(group["params"]) for group in optimizer.param_groups) == len(list(network.parameters())) diff --git a/tests/components/predictors/neural_network/test_predictor.py b/tests/components/predictors/neural_network/test_predictor.py new file mode 100644 index 000000000..fb939f154 --- /dev/null +++ b/tests/components/predictors/neural_network/test_predictor.py @@ -0,0 +1,179 @@ +"""Smoke tests for the neural_network predictor package.""" + +from __future__ import annotations + +from unittest.mock import patch + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.contracts.training_context import TrainingContext +from drevalpy.components.predictors.neural_network.predictor import NeuralNetworkPredictor +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models import construct_model +from drevalpy.models.config import from_spec +from drevalpy.registry._builtins import ensure_predictor_registered, register_builtin_components +from drevalpy.registry.predictor import get as get_predictor +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.data.batch.response_batch import ResponseBatch +from tests.components.predictors._helpers import neural_batch + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_neural_network_predictor_registry_name() -> None: + ensure_predictor_registered("neuralNetwork") + assert get_predictor("neuralNetwork") is NeuralNetworkPredictor + + +def test_neural_network_requires_numeric_contracts() -> None: + cls = get_predictor("neuralNetwork") + assert cls.cell_line_contract.format == FeatureFormat.NUMERIC_MATRIX + assert cls.drug_contract.format == FeatureFormat.NUMERIC_MATRIX + + +def test_neural_network_zoo_trains_on_synthetic_data() -> None: + register_builtin_components() + import anndata as ad + import mudata as md + import pandas as pd + + from drevalpy.types import SplitMask, SplitMasks + from drevalpy.types.data.dataset import Dataset + + cl_ids = np.array(["cl1", "cl2"]) + drug_ids = np.array(["d1", "d2"]) + response_matrix = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + response_ad = ad.AnnData( + X=response_matrix, + obs=pd.DataFrame({"cell_line_name": cl_ids, "tissue": ["Lung", "Blood"]}, index=cl_ids), + var=pd.DataFrame(index=drug_ids), + ) + ge_matrix = np.array([[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]], dtype=np.float32) + gene_expression_ad = ad.AnnData( + X=ge_matrix, + obs=pd.DataFrame(index=cl_ids), + var=pd.DataFrame(index=[f"gene{i}" for i in range(4)]), + ) + response_ad.varm["morgan_fingerprint"] = np.array([[1.0, 0.0, 0.5, 0.2], [0.0, 1.0, 0.3, 0.7]], dtype=np.float32) + mdata = md.MuData({"response": response_ad, "gene_expression": gene_expression_ad}) + mudataset = Dataset(mdata, name="test") + split = SplitMasks( + train=SplitMask(np.array([[True, True], [False, False]])), + test=SplitMask(np.array([[False, False], [True, True]])), + val=SplitMask(np.zeros((2, 2), dtype=bool)), + ) + + config = from_spec( + "SimpleNeuralNetwork", + hyperparameters={"max_epochs": 1, "batch_size": 2}, + ) + model = construct_model("SimpleNeuralNetwork", config)() + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert preds.shape[0] > 0 + assert np.isfinite(preds).all() + + +def test_neural_network_configured_is_not_fitted_before_training() -> None: + register_builtin_components() + predictor = NeuralNetworkPredictor( + hyperparameters={"max_epochs": 1, "units_per_layer": [4, 2]}, + ) + assert predictor._model is None + assert predictor.is_fitted() is False + + +def test_neural_network_configured_is_not_fitted() -> None: + predictor = NeuralNetworkPredictor( + hyperparameters={"max_epochs": 1, "units_per_layer": [4, 2]}, + ) + assert predictor._model is None + assert predictor.is_fitted() is False + + +def _matrix_batch() -> ModelInputBatch: + response = ResponseBatch( + response=np.array([1.0, 2.0, 3.0, 4.0]), + cell_line_ids=np.array(["cl1", "cl1", "cl2", "cl2"]), + drug_ids=np.array(["d1", "d2", "d1", "d2"]), + ) + cell_line_features = np.vstack( + [ + np.array([0.1, 0.2, 0.3, 0.4]), + np.array([0.5, 0.6, 0.7, 0.8]), + ] + ) + drug_features = np.vstack( + [ + np.array([1.0, 0.0, 0.5, 0.2]), + np.array([0.0, 1.0, 0.3, 0.7]), + ] + ) + return ModelInputBatch.from_response( + response, + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=cell_line_features, + drug_features=drug_features, + cell_line_pair_idx=np.array([0, 0, 1, 1]), + drug_pair_idx=np.array([0, 1, 0, 1]), + training_context=TrainingContext(), + ) + + +def test_neural_network_state_round_trip() -> None: + predictor = NeuralNetworkPredictor(hyperparameters={"max_epochs": 1, "units_per_layer": [4, 2]}) + predictor.fit(_matrix_batch()) + restored = NeuralNetworkPredictor(hyperparameters={"max_epochs": 1, "units_per_layer": [4, 2]}) + restored.set_state(predictor.get_state()) + assert restored.is_fitted() + assert restored._input_dim == predictor._input_dim + + +def test_neural_network_set_state_rejects_invalid_checkpoint() -> None: + predictor = NeuralNetworkPredictor() + with pytest.raises(PredictorStateError): + predictor.set_state({"checkpoint": b"invalid"}) + + +def test_neural_network_early_stopping_wires_validation_loader() -> None: + predictor = NeuralNetworkPredictor( + hyperparameters={"max_epochs": 1, "batch_size": 2, "units_per_layer": [4, 2]}, + ) + batch = neural_batch(with_early_stopping=True) + captured: dict[str, object] = {} + + def _capture_fit(self, model, train_dataloaders, val_dataloaders=None): + captured["val_loader"] = val_dataloaders + return None + + with patch("pytorch_lightning.Trainer.fit", _capture_fit): + predictor.fit(batch) + assert captured["val_loader"] is not None + + +def test_neural_network_round_trip_state() -> None: + predictor = NeuralNetworkPredictor( + hyperparameters={"max_epochs": 1, "batch_size": 2, "units_per_layer": [4, 2]}, + ) + predictor.fit(neural_batch()) + preds = predictor.predict(neural_batch()) + assert preds.shape == (4,) + assert np.isfinite(preds).all() + + restored = NeuralNetworkPredictor() + restored.set_state(predictor.get_state()) + assert restored.is_fitted() + restored_preds = restored.predict(neural_batch()) + assert np.allclose(preds, restored_preds) + + +def test_neural_network_set_state_raises_on_invalid_payload() -> None: + predictor = NeuralNetworkPredictor() + with pytest.raises(PredictorStateError): + predictor.set_state({"checkpoint": b"not-a-torch-checkpoint"}) diff --git a/tests/components/predictors/test_boosted_trees.py b/tests/components/predictors/test_boosted_trees.py new file mode 100644 index 000000000..ae94fa437 --- /dev/null +++ b/tests/components/predictors/test_boosted_trees.py @@ -0,0 +1,101 @@ +"""Tests for :mod:`drevalpy.components.predictors._boosted_trees`. + +Mirrors the private module with the underscore stripped. The two shipped +subclasses are asserted in their own modules; what is pinned here is the sharing +mechanism itself - default resolution, coercion and space assembly - on throwaway +subclasses, so neither library has to be installed. +""" + +from __future__ import annotations + +from typing import Any, ClassVar + +import pytest + +from drevalpy.components.predictors._boosted_trees import SHARED_DEFAULTS, SHARED_SPACE, BoostedTreesPredictor +from drevalpy.components.predictors.sklearn_tabular import SklearnTabularPredictor + + +class _Bare(BoostedTreesPredictor): + """Neither overrides nor tunes anything.""" + + def _make_estimator(self) -> Any: + return object() + + +class _Tweaked(BoostedTreesPredictor): + """Overrides one default, adds one library-only knob, narrows one bound.""" + + boosting_default_overrides: ClassVar[dict[str, Any]] = {"subsample": 0.8} + boosting_extra_defaults: ClassVar[dict[str, Any]] = {"num_leaves": 63} + boosting_space_overrides: ClassVar[dict[str, dict[str, Any]]] = {"max_depth": {"high": 8}} + tuned_hyperparameters: ClassVar[tuple[str, ...]] = ("max_depth", "num_leaves") + + def _make_estimator(self) -> Any: + return object() + + +class TestSharedTables: + def test_every_default_is_also_a_declarable_spec(self): + assert set(SHARED_DEFAULTS) - {"random_state"} <= set(SHARED_SPACE) + + def test_learning_rate_carries_no_log_flag(self): + """The two libraries disagree, so each declares it rather than the table.""" + assert "log" not in SHARED_SPACE["learning_rate"] + + def test_every_spec_brackets_its_default(self): + assert all(spec["low"] <= spec["default"] <= spec["high"] for spec in SHARED_SPACE.values()) + + +class TestEstimatorParams: + def test_defaults_to_the_shared_table(self): + assert _Bare()._estimator_params() == SHARED_DEFAULTS + + def test_applies_the_library_override(self): + assert _Tweaked()._estimator_params()["subsample"] == pytest.approx(0.8) + + def test_leaves_the_shared_table_untouched(self): + _Tweaked()._estimator_params() + + assert SHARED_DEFAULTS["subsample"] == pytest.approx(1.0) + + def test_includes_the_library_only_knobs(self): + assert _Tweaked()._estimator_params()["num_leaves"] == 63 + + def test_forwards_a_hyperparameter_override(self): + assert _Bare(hyperparameters={"n_estimators": 7})._estimator_params()["n_estimators"] == 7 + + def test_coerces_to_the_type_of_the_default(self): + params = _Bare(hyperparameters={"n_estimators": "7", "learning_rate": "0.05"})._estimator_params() + + assert params["n_estimators"] == 7 + assert params["learning_rate"] == pytest.approx(0.05) + assert isinstance(params["n_estimators"], int) + + def test_ignores_hyperparameters_the_library_does_not_take(self): + assert "num_leaves" not in _Bare(hyperparameters={"num_leaves": 5})._estimator_params() + + +class TestHyperparameterSpace: + def test_is_empty_when_nothing_is_declared_as_tunable(self): + assert _Bare.get_hyperparameter_space() == {} + + def test_exposes_exactly_the_declared_names(self): + assert set(_Tweaked.get_hyperparameter_space()) == {"max_depth", "num_leaves"} + + def test_merges_the_override_onto_the_shared_spec(self): + spec = _Tweaked.get_hyperparameter_space()["max_depth"] + + assert spec == {**SHARED_SPACE["max_depth"], "high": 8} + + def test_does_not_mutate_the_shared_spec(self): + _Tweaked.get_hyperparameter_space() + + assert SHARED_SPACE["max_depth"]["high"] == 12 + + def test_reuses_the_shared_spec_verbatim_without_an_override(self): + assert _Tweaked.get_hyperparameter_space()["num_leaves"] == SHARED_SPACE["num_leaves"] + + +def test_keeps_the_sklearn_tabular_lifecycle() -> None: + assert issubclass(BoostedTreesPredictor, SklearnTabularPredictor) diff --git a/tests/components/predictors/test_lightgbm_pred.py b/tests/components/predictors/test_lightgbm_pred.py new file mode 100644 index 000000000..7a0df56c3 --- /dev/null +++ b/tests/components/predictors/test_lightgbm_pred.py @@ -0,0 +1,149 @@ +"""Tests for the LightGBM tabular predictor.""" + +from __future__ import annotations + +import lightgbm as lgb +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors._boosted_trees import BoostedTreesPredictor +from drevalpy.components.predictors.lightgbm_pred import LightGBMPredictor +from drevalpy.components.predictors.sklearn_tabular import SklearnTabularPredictor +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.registry._builtins import ensure_predictor_registered, register_builtin_components +from drevalpy.registry.predictor import get as get_predictor +from tests.components.predictors._helpers import neural_batch + +#: ``n_jobs=1`` keeps LightGBM single-threaded: its OpenMP runtime clashes with +#: the one torch loads, which the rest of this suite pulls in. +TINY_HPAMS = {"n_estimators": 3, "max_depth": 2, "num_leaves": 3, "n_jobs": 1} + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_lightgbm_predictor_registry_name() -> None: + ensure_predictor_registered("lightgbm") + + assert get_predictor("lightgbm") is LightGBMPredictor + + +def test_lightgbm_predictor_requires_numeric_matrix_contracts() -> None: + cls = get_predictor("lightgbm") + + assert cls.cell_line_contract.format == FeatureFormat.NUMERIC_MATRIX + assert cls.drug_contract.format == FeatureFormat.NUMERIC_MATRIX + + +def test_lightgbm_predictor_reuses_the_sklearn_tabular_lifecycle() -> None: + assert issubclass(LightGBMPredictor, SklearnTabularPredictor) + + +def test_lightgbm_predictor_shares_the_boosting_base_with_xgboost() -> None: + assert issubclass(LightGBMPredictor, BoostedTreesPredictor) + + +def test_lightgbm_make_estimator_returns_an_unfitted_regressor() -> None: + estimator = LightGBMPredictor(hyperparameters=TINY_HPAMS)._make_estimator() + + assert isinstance(estimator, lgb.LGBMRegressor) + assert not hasattr(estimator, "booster_") + + +def test_lightgbm_make_estimator_forwards_hyperparameters() -> None: + estimator = LightGBMPredictor( + hyperparameters={"n_estimators": 7, "learning_rate": 0.05, "max_depth": 3, "num_leaves": 5} + )._make_estimator() + + assert estimator.n_estimators == 7 + assert estimator.learning_rate == pytest.approx(0.05) + assert estimator.max_depth == 3 + assert estimator.num_leaves == 5 + + +def test_lightgbm_make_estimator_uses_documented_defaults() -> None: + estimator = LightGBMPredictor()._make_estimator() + + assert estimator.n_estimators == 100 + assert estimator.learning_rate == pytest.approx(0.1) + assert estimator.max_depth == 6 + assert estimator.num_leaves == 63 + assert estimator.random_state == 42 + + +def test_lightgbm_make_estimator_silences_training_output() -> None: + estimator = LightGBMPredictor()._make_estimator() + + assert estimator.get_params()["verbosity"] == -1 + + +def test_lightgbm_hyperparameter_space_defaults_match_the_estimator() -> None: + space = LightGBMPredictor.get_hyperparameter_space() + estimator = LightGBMPredictor()._make_estimator() + + assert space["n_estimators"]["default"] == estimator.n_estimators + assert space["num_leaves"]["default"] == estimator.num_leaves + + +def test_lightgbm_hyperparameter_space_declares_bounded_specs() -> None: + space = LightGBMPredictor.get_hyperparameter_space() + + assert set(space) == { + "n_estimators", + "learning_rate", + "max_depth", + "num_leaves", + "subsample", + "colsample_bytree", + "reg_alpha", + "reg_lambda", + } + assert all(spec["low"] <= spec["default"] <= spec["high"] for spec in space.values()) + + +def test_lightgbm_samples_the_learning_rate_log_uniformly() -> None: + assert LightGBMPredictor.get_hyperparameter_space()["learning_rate"]["log"] is True + + +def test_lightgbm_predictor_is_not_fitted_before_fit() -> None: + assert LightGBMPredictor(hyperparameters=TINY_HPAMS).is_fitted() is False + + +def test_lightgbm_predictor_fits_and_predicts_one_value_per_pair() -> None: + predictor = LightGBMPredictor(hyperparameters=TINY_HPAMS) + + predictor.fit(neural_batch()) + preds = predictor.predict(neural_batch()) + + assert predictor.is_fitted() is True + assert preds.shape == (4,) + assert np.isfinite(preds).all() + + +def test_lightgbm_predictor_state_round_trip_reproduces_predictions() -> None: + predictor = LightGBMPredictor(hyperparameters=TINY_HPAMS) + predictor.fit(neural_batch()) + expected = predictor.predict(neural_batch()) + + restored = LightGBMPredictor() + restored.set_state(predictor.get_state()) + + assert restored.is_fitted() is True + np.testing.assert_allclose(restored.predict(neural_batch()), expected) + + +def test_lightgbm_predictor_set_state_rejects_a_missing_estimator() -> None: + with pytest.raises(PredictorStateError): + LightGBMPredictor().set_state({"hyperparameters": dict(TINY_HPAMS), "mode": "regression"}) + + +def test_lightgbm_predictor_empty_training_matrix_leaves_it_unfitted() -> None: + predictor = LightGBMPredictor(hyperparameters=TINY_HPAMS) + + predictor._fit_matrix(np.empty((0, 3)), np.empty(0)) + + assert predictor.is_fitted() is False + assert np.isnan(predictor._predict_matrix(np.zeros((2, 3)))).all() diff --git a/tests/components/predictors/test_single_drug_routing.py b/tests/components/predictors/test_single_drug_routing.py new file mode 100644 index 000000000..5ccde391d --- /dev/null +++ b/tests/components/predictors/test_single_drug_routing.py @@ -0,0 +1,64 @@ +"""Tests for shared single-drug routing helpers.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.single_drug_routing import ( + iter_drug_masks, + require_known_training_keys, + routing_keys, +) +from drevalpy.types.data.batch.feature_block import FeatureBlock +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +def _identity_batch() -> ModelInputBatch: + return ModelInputBatch( + cell_line_ids=np.array(["cl1", "cl2", "cl1", "cl2"]), + drug_ids=np.array(["d1", "d1", "d2", "d2"]), + response=np.array([1.0, 2.0, 3.0, 4.0]), + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=np.empty((0, 0), dtype=np.float32), + drug_features=None, + cell_line_pair_idx=np.array([0, 1, 0, 1]), + drug_pair_idx=np.array([0, 0, 1, 1]), + drug_blocks={ + "identity": FeatureBlock( + values=np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32), + format=FeatureFormat.NUMERIC_MATRIX, + ), + "identity_categories": FeatureBlock( + values=np.array(["d1", "d2"]), + format=FeatureFormat.NUMERIC_MATRIX, + ), + }, + ) + + +def test_routing_keys_decode_known_drugs() -> None: + keys = routing_keys(_identity_batch()) + assert keys.tolist() == ["d1", "d1", "d2", "d2"] + + +def test_iter_drug_masks_returns_stable_masks() -> None: + batch = _identity_batch() + routed = list(iter_drug_masks(batch)) + assert {drug_id for drug_id, _ in routed} == {"d1", "d2"} + for drug_id, mask in routed: + assert routing_keys(batch.subset_pairs(mask)).tolist() == [drug_id] * int(mask.sum()) + + +def test_require_known_training_keys_rejects_unknown() -> None: + with pytest.raises(ValueError, match="unknown drug identities"): + require_known_training_keys(np.array(["d1", ""])) + + +def test_routing_keys_requires_identity_blocks() -> None: + batch = _identity_batch() + batch.drug_blocks = {} + with pytest.raises(ValueError, match="require drug identity features"): + routing_keys(batch) diff --git a/tests/components/predictors/test_single_drug_sklearn.py b/tests/components/predictors/test_single_drug_sklearn.py new file mode 100644 index 000000000..792343763 --- /dev/null +++ b/tests/components/predictors/test_single_drug_sklearn.py @@ -0,0 +1,126 @@ +"""Tests for per-drug sklearn predictor routing.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.predictors.single_drug_sklearn import SingleDrugSklearnPredictor +from drevalpy.components.predictors.sklearn_models import SingleDrugElasticNetPredictor +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.models.component_stack import build_component_stack +from drevalpy.models.config import from_spec +from drevalpy.types.data.batch.response_batch import ResponseBatch +from drevalpy.types.enums.prediction_mode import PredictionMode +from tests.conftest import MockFeatureSource + + +def _cell_line_input() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([0.1, 0.2, 0.3])}, + "cl2": {"gene_expression": np.array([0.4, 0.5, 0.6])}, + } + ) + + +def _fitted_stack(cell_line_input: MockFeatureSource) -> tuple[object, SingleDrugSklearnPredictor]: + """Fit a per-drug elastic-net stack on two drugs over three genes. + + :param cell_line_input: Feature source the stack fits its featurizers on. + :returns: ``(stack, predictor)``, with the predictor narrowed to the per-drug type. + """ + config = from_spec( + "scaledGeneExpression:identity:singleDrugElasticNet", + hyperparameters={"alpha": 0.1, "l1_ratio": 0.5}, + ) + stack = build_component_stack(config) + response = ResponseBatch( + response=np.array([1.0, 1.0, 10.0, 10.0]), + cell_line_ids=np.array(["cl1", "cl2", "cl1", "cl2"]), + drug_ids=np.array(["d1", "d1", "d2", "d2"]), + ) + stack._fit_featurizers_and_predictor(response, cell_line_input) + predictor = stack._predictor + assert isinstance(predictor, SingleDrugSklearnPredictor) + return stack, predictor + + +def _fitted_predictor() -> SingleDrugSklearnPredictor: + """Return a per-drug predictor fitted on two drugs over three genes. + + :returns: The fitted predictor. + """ + return _fitted_stack(_cell_line_input())[1] + + +def test_identity_routes_estimators_without_entering_design_matrix() -> None: + cell_line_input = _cell_line_input() + + stack, predictor = _fitted_stack(cell_line_input) + + assert set(predictor._estimators) == {"d1", "d2"} + assert {estimator.n_features_in_ for estimator in predictor._estimators.values()} == {3} + + predictions = stack.predict_from_features( + np.array(["cl1", "cl1"]), + np.array(["d1", "d2"]), + cell_line_input, + ) + assert np.allclose(predictions, np.array([1.0, 10.0])) + + +class TestState: + """The per-drug predictor swaps the estimator entry but shares the rest with its base.""" + + def test_state_carries_the_estimators_plus_the_shared_entries(self): + assert set(_fitted_predictor().get_state()) == {"estimators", "hyperparameters", "mode"} + + def test_is_not_fitted_without_estimators(self): + assert SingleDrugElasticNetPredictor().is_fitted() is False + + def test_round_trip_restores_every_per_drug_estimator(self): + predictor = _fitted_predictor() + + restored = SingleDrugElasticNetPredictor() + restored.set_state(predictor.get_state()) + + assert restored.is_fitted() is True + assert set(restored._estimators) == {"d1", "d2"} + assert restored._h["alpha"] == pytest.approx(0.1) + assert restored._mode is PredictionMode.REGRESSION + + def test_rejects_a_state_without_estimators(self): + state = _fitted_predictor().get_state() + state["estimators"] = {} + + with pytest.raises(PredictorStateError, match="missing fitted per-drug estimators"): + SingleDrugElasticNetPredictor().set_state(state) + + def test_the_missing_estimators_error_takes_precedence_over_the_shared_ones(self): + with pytest.raises(PredictorStateError, match="missing fitted per-drug estimators"): + SingleDrugElasticNetPredictor().set_state({"estimators": {}, "hyperparameters": {}}) + + def test_rejects_a_state_without_hyperparameters(self): + state = _fitted_predictor().get_state() + state["hyperparameters"] = {} + + with pytest.raises(PredictorStateError, match="missing hyperparameters"): + SingleDrugElasticNetPredictor().set_state(state) + + def test_rejects_an_unusable_prediction_mode(self): + state = _fitted_predictor().get_state() + state["mode"] = 3 + + with pytest.raises(PredictorStateError, match="invalid prediction mode"): + SingleDrugElasticNetPredictor().set_state(state) + + def test_a_rejected_state_leaves_the_estimators_untouched(self): + predictor = _fitted_predictor() + state = predictor.get_state() + state["mode"] = 3 + + with pytest.raises(PredictorStateError, match="invalid prediction mode"): + predictor.set_state(state) + + assert set(predictor._estimators) == {"d1", "d2"} diff --git a/tests/components/predictors/test_sklearn_models.py b/tests/components/predictors/test_sklearn_models.py new file mode 100644 index 000000000..e9ecb5dcc --- /dev/null +++ b/tests/components/predictors/test_sklearn_models.py @@ -0,0 +1,56 @@ +"""Tests for scikit-learn predictor component scope contracts.""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.predictors.abstract.base import Predictor +from drevalpy.components.predictors.single_drug_sklearn import SingleDrugSklearnPredictor +from drevalpy.components.predictors.sklearn_models import ( + AdaBoostPredictor, + ElasticNetPredictor, + RandomForestPredictor, + SingleDrugElasticNetPredictor, + SingleDrugRandomForestPredictor, +) +from drevalpy.models.config import ModelConfig, from_spec +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.types.enums.model_scope import ModelScope + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +@pytest.mark.parametrize("predictor_class", [ElasticNetPredictor, RandomForestPredictor]) +def test_multi_drug_sklearn_predictors_are_multi_drug(predictor_class: type[Predictor]) -> None: + assert predictor_class.scope is ModelScope.MULTI_DRUG + + +@pytest.mark.parametrize( + ("predictor_class", "shared_predictor_class"), + [ + (SingleDrugElasticNetPredictor, ElasticNetPredictor), + (SingleDrugRandomForestPredictor, RandomForestPredictor), + ], +) +def test_single_drug_sklearn_predictors_route_by_identity( + predictor_class: type[Predictor], + shared_predictor_class: type[Predictor], +) -> None: + assert issubclass(predictor_class, shared_predictor_class) + assert issubclass(predictor_class, SingleDrugSklearnPredictor) + assert predictor_class.scope is ModelScope.SINGLE_DRUG + + +def test_ridge_zoo_preset_exists() -> None: + config = from_spec("Ridge") + assert isinstance(config, ModelConfig) + assert config.predictor.name == "ridge" + + +def test_adaboost_default_depth_matches_space() -> None: + predictor = AdaBoostPredictor() + estimator = predictor._make_estimator() + assert estimator.estimator.max_depth == 4 diff --git a/tests/components/predictors/test_sklearn_tabular.py b/tests/components/predictors/test_sklearn_tabular.py new file mode 100644 index 000000000..be5f9f3a0 --- /dev/null +++ b/tests/components/predictors/test_sklearn_tabular.py @@ -0,0 +1,139 @@ +"""Tests for the shared scikit-learn tabular predictor base. + +Carved out of ``test_sklearn_models.py``, which covers the concrete estimator +zoo. This file covers the base class that ``sklearn_models``, ``xgboost_pred`` +and ``lightgbm_pred`` all inherit: hyperparameter merging, the degenerate +empty-matrix path, and the three ``set_state`` rejection branches. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.predictors.sklearn_models import ElasticNetPredictor, RidgePredictor +from drevalpy.components.predictors.state_errors import PredictorStateError +from drevalpy.types.enums.prediction_mode import PredictionMode +from tests.components.predictors._helpers import neural_batch + + +def test_sklearn_tabular_supports_regression_only() -> None: + assert RidgePredictor.supported_modes == frozenset({PredictionMode.REGRESSION}) + + +def test_sklearn_tabular_defaults_to_regression_mode() -> None: + predictor = RidgePredictor() + + assert predictor._mode is PredictionMode.REGRESSION + + +def test_sklearn_tabular_merges_non_tunable_hyperparameters_under_explicit_ones() -> None: + predictor = ElasticNetPredictor(hyperparameters={"max_iter": 7, "alpha": 0.5}) + + assert predictor._h["max_iter"] == 7 + assert predictor._h["tol"] == pytest.approx(1e-2) + assert predictor._h["alpha"] == pytest.approx(0.5) + + +def test_sklearn_tabular_is_not_fitted_before_fit() -> None: + assert RidgePredictor().is_fitted() is False + + +def test_sklearn_tabular_empty_design_matrix_leaves_estimator_unset() -> None: + predictor = RidgePredictor() + + predictor._fit_matrix(np.empty((0, 3)), np.empty(0)) + + assert predictor._estimator is None + assert predictor.is_fitted() is False + + +def test_sklearn_tabular_predicts_nan_without_a_fitted_estimator() -> None: + predictor = RidgePredictor() + + preds = predictor._predict_matrix(np.zeros((3, 2))) + + assert preds.shape == (3,) + assert np.isnan(preds).all() + assert preds.dtype == np.float64 + + +def test_sklearn_tabular_state_round_trip_reproduces_predictions() -> None: + predictor = RidgePredictor(hyperparameters={"alpha": 0.25}) + predictor.fit(neural_batch()) + expected = predictor.predict(neural_batch()) + + restored = RidgePredictor() + restored.set_state(predictor.get_state()) + + assert restored.is_fitted() + assert restored._h["alpha"] == pytest.approx(0.25) + np.testing.assert_allclose(restored.predict(neural_batch()), expected) + + +def test_sklearn_tabular_get_state_reports_the_prediction_mode_value() -> None: + predictor = RidgePredictor() + predictor.fit(neural_batch()) + + assert predictor.get_state()["mode"] == PredictionMode.REGRESSION.value + + +def test_sklearn_tabular_set_state_accepts_a_prediction_mode_instance() -> None: + predictor = RidgePredictor() + predictor.fit(neural_batch()) + state = predictor.get_state() + state["mode"] = PredictionMode.REGRESSION + + restored = RidgePredictor() + restored.set_state(state) + + assert restored._mode is PredictionMode.REGRESSION + + +def test_sklearn_tabular_set_state_rejects_missing_estimator() -> None: + predictor = RidgePredictor() + + with pytest.raises(PredictorStateError, match="missing a fitted estimator"): + predictor.set_state({"hyperparameters": {"alpha": 1.0}, "mode": "regression"}) + + +def test_sklearn_tabular_set_state_rejects_missing_hyperparameters() -> None: + predictor = RidgePredictor() + predictor.fit(neural_batch()) + state = predictor.get_state() + state["hyperparameters"] = {} + + with pytest.raises(PredictorStateError, match="missing hyperparameters"): + RidgePredictor().set_state(state) + + +def test_sklearn_tabular_set_state_rejects_an_invalid_prediction_mode() -> None: + predictor = RidgePredictor() + predictor.fit(neural_batch()) + state = predictor.get_state() + state["mode"] = 3 + + with pytest.raises(PredictorStateError, match="invalid prediction mode"): + RidgePredictor().set_state(state) + + +def test_sklearn_tabular_a_rejected_state_leaves_the_predictor_untouched() -> None: + """The shared entries are validated before anything is assigned, so a bad mode is not half-applied.""" + predictor = RidgePredictor(hyperparameters={"alpha": 0.25}) + predictor.fit(neural_batch()) + expected = predictor.predict(neural_batch()) + state = predictor.get_state() + state["mode"] = 3 + + with pytest.raises(PredictorStateError, match="invalid prediction mode"): + predictor.set_state(state) + + assert predictor._h["alpha"] == pytest.approx(0.25) + np.testing.assert_allclose(predictor.predict(neural_batch()), expected) + + +def test_sklearn_tabular_state_carries_only_the_estimator_and_the_shared_entries() -> None: + predictor = RidgePredictor() + predictor.fit(neural_batch()) + + assert set(predictor.get_state()) == {"estimator", "hyperparameters", "mode"} diff --git a/tests/components/predictors/test_state_errors.py b/tests/components/predictors/test_state_errors.py new file mode 100644 index 000000000..ac8e3e172 --- /dev/null +++ b/tests/components/predictors/test_state_errors.py @@ -0,0 +1,22 @@ +"""Tests for the predictor state error type.""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.predictors.state_errors import PredictorStateError + + +def test_predictor_state_error_is_a_runtime_error() -> None: + assert issubclass(PredictorStateError, RuntimeError) + + +def test_predictor_state_error_carries_its_message() -> None: + error = PredictorStateError("state is missing a fitted estimator") + + assert str(error) == "state is missing a fitted estimator" + + +def test_predictor_state_error_is_catchable_as_runtime_error() -> None: + with pytest.raises(RuntimeError): + raise PredictorStateError("boom") diff --git a/tests/components/predictors/test_state_helpers.py b/tests/components/predictors/test_state_helpers.py new file mode 100644 index 000000000..951edf1ab --- /dev/null +++ b/tests/components/predictors/test_state_helpers.py @@ -0,0 +1,66 @@ +"""Tests for the serialized-state coercion helpers. + +Mirrors the private module ``drevalpy.components.predictors._state_helpers`` +with the leading underscore stripped, per the all-private mirroring rule in +``AGENTS.md``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.predictors._state_helpers import state_float, state_mapping + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + pytest.param(2, 2.0, id="int"), + pytest.param(2.5, 2.5, id="float"), + pytest.param(np.float32(1.5), 1.5, id="numpy-scalar"), + pytest.param("3.25", 3.25, id="numeric-string"), + pytest.param(True, 1.0, id="bool-is-real"), + ], +) +def test_state_float_coerces_real_and_string_values(stored: object, expected: float) -> None: + result = state_float({"key": stored}, "key") + + assert result == pytest.approx(expected) + assert isinstance(result, float) + + +def test_state_float_returns_none_for_missing_key() -> None: + assert state_float({}, "dataset_mean") is None + + +def test_state_float_returns_none_for_non_numeric_value() -> None: + assert state_float({"dataset_mean": [1.0]}, "dataset_mean") is None + + +def test_state_float_raises_on_unparsable_string() -> None: + with pytest.raises(ValueError): + state_float({"dataset_mean": "not-a-number"}, "dataset_mean") + + +def test_state_mapping_returns_a_copy_of_the_stored_dict() -> None: + stored = {"alpha": 1.0} + + result = state_mapping({"hyperparameters": stored}, "hyperparameters") + + assert result == {"alpha": 1.0} + assert result is not stored + + +def test_state_mapping_stringifies_nothing_but_preserves_keys() -> None: + result = state_mapping({"hyperparameters": {"n_estimators": 5, "mode": "regression"}}, "hyperparameters") + + assert result == {"n_estimators": 5, "mode": "regression"} + + +def test_state_mapping_returns_empty_dict_for_missing_key() -> None: + assert state_mapping({}, "hyperparameters") == {} + + +def test_state_mapping_returns_empty_dict_for_non_mapping_value() -> None: + assert state_mapping({"hyperparameters": ["alpha"]}, "hyperparameters") == {} diff --git a/tests/components/predictors/test_xgboost_pred.py b/tests/components/predictors/test_xgboost_pred.py new file mode 100644 index 000000000..170b09052 --- /dev/null +++ b/tests/components/predictors/test_xgboost_pred.py @@ -0,0 +1,84 @@ +"""Tests for the XGBoost predictor state round trip and hyperparameter wiring.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from drevalpy.components.predictors._boosted_trees import BoostedTreesPredictor +from drevalpy.components.predictors.xgboost_pred import XGBoostPredictor +from drevalpy.registry._builtins import register_builtin_components +from tests.components.predictors._helpers import neural_batch + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_xgboost_shares_the_boosting_base_with_lightgbm() -> None: + assert issubclass(XGBoostPredictor, BoostedTreesPredictor) + + +def test_xgboost_hyperparameter_space_tunes_three_knobs() -> None: + assert set(XGBoostPredictor.get_hyperparameter_space()) == {"n_estimators", "max_depth", "learning_rate"} + + +def test_xgboost_caps_the_tree_depth_below_the_shared_ceiling() -> None: + assert XGBoostPredictor.get_hyperparameter_space()["max_depth"]["high"] == 8 + + +def test_xgboost_samples_the_learning_rate_uniformly() -> None: + assert "log" not in XGBoostPredictor.get_hyperparameter_space()["learning_rate"] + + +def test_xgboost_hyperparameter_space_declares_bounded_specs() -> None: + space = XGBoostPredictor.get_hyperparameter_space() + + assert all(spec["low"] <= spec["default"] <= spec["high"] for spec in space.values()) + + +def test_xgboost_estimator_params_keep_the_documented_defaults() -> None: + params = XGBoostPredictor()._estimator_params() + + assert params["n_estimators"] == 100 + assert params["max_depth"] == 6 + assert params["learning_rate"] == pytest.approx(0.1) + assert params["subsample"] == pytest.approx(1.0) + assert params["colsample_bytree"] == pytest.approx(1.0) + assert params["reg_alpha"] == pytest.approx(0.0) + assert params["random_state"] == 42 + + +def test_xgboost_does_not_pass_lightgbm_only_arguments() -> None: + params = XGBoostPredictor()._estimator_params() + + assert "num_leaves" not in params + assert "reg_lambda" not in params + + +def test_xgboost_make_estimator_forwards_hyperparameters() -> None: + pytest.importorskip("xgboost") + + estimator = XGBoostPredictor(hyperparameters={"n_estimators": 7, "max_depth": 3})._make_estimator() + + assert estimator.n_estimators == 7 + assert estimator.max_depth == 3 + + +def test_xgboost_load_applies_thread_defaults_before_restore() -> None: + pytest.importorskip("xgboost") + from drevalpy.components.predictors.xgboost_pred import _set_xgboost_thread_defaults + + predictor = XGBoostPredictor(hyperparameters={"n_estimators": 5}) + predictor.fit(neural_batch()) + state = predictor.get_state() + + with patch( + "drevalpy.components.predictors.xgboost_pred._set_xgboost_thread_defaults", + wraps=_set_xgboost_thread_defaults, + ) as thread_defaults: + restored = XGBoostPredictor() + restored.set_state(state) + thread_defaults.assert_called_once() diff --git a/tests/components/test_init.py b/tests/components/test_init.py new file mode 100644 index 000000000..bd452dc8c --- /dev/null +++ b/tests/components/test_init.py @@ -0,0 +1,50 @@ +"""Tests for stable drevalpy.components public exports.""" + +from __future__ import annotations + +import drevalpy.components as components +import drevalpy.models.config as model_config + + +def test_public_exports_are_importable() -> None: + expected = { + "register_builtin_components", + "register_cell_line_featurizer", + "register_drug_featurizer", + "register_predictor", + "load_extensions", + "list_predictor_metadata", + } + for name in expected: + assert hasattr(components, name), name + + +def test_model_config_lives_under_models() -> None: + expected = { + "ModelConfig", + "FeaturizerConfig", + "CellLineFeaturizerConfig", + "DrugFeaturizerConfig", + "PredictorConfig", + "PredictionMode", + } + for name in expected: + assert hasattr(model_config, name), name + for name in expected: + assert not hasattr(components, name), name + + +def test_components_do_not_reexport_orchestration() -> None: + orchestration_exports = { + "ComposedModel", + "zoo_config", + "from_spec", + "model_config_for_name", + "get_zoo_config", + "list_zoo_names", + "ComponentDRPBridge", + "format_model_id", + "parse_model_id", + } + for name in orchestration_exports: + assert not hasattr(components, name), name diff --git a/tests/conftest.py b/tests/conftest.py index 19a265525..d974ef847 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,309 +1,148 @@ -"""Pytest configuration file for the tests directory.""" +"""Pytest configuration for the drevalpy test suite. -import pathlib +The suite is deliberately self-contained: no fixture downloads anything and no +test resolves against a developer's local ``data/`` directory. Every dataset the +suite needs is built in memory by +:func:`tests.synthetic.build_synthetic_dataset`. +""" -import pytest +from __future__ import annotations -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.datasets.loader import load_toyv1, load_toyv2 +import shutil +import zipfile +from typing import Any -_TESTS_DIR = pathlib.Path(__file__).parent.resolve() -_DATA_DIR = (_TESTS_DIR.parent / "data").resolve() +import numpy as np +import pytest +from upath import UPath +import drevalpy.registry # noqa: F401 -- triggers register_builtin_components() + discover_plugins() +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import FeatureSource +from tests._trusted_subprocess import run_trusted_python +from tests.synthetic import build_synthetic_dataset -@pytest.fixture(scope="session") -def data_dir() -> pathlib.Path: - """ - Fixture to provide the path to the data directory for tests. +REPO_ROOT = UPath(__file__).resolve().parents[1] - :returns: path to the data directory - """ - return _DATA_DIR +#: Builds the wheel for :func:`built_wheel_contents` in a fresh interpreter. +#: ``sys.argv[1]`` is the output directory. +_BUILD_WHEEL_SCRIPT = ( + "import subprocess, sys; sys.exit(subprocess.run(['uv', 'build', '--wheel', '--out-dir', sys.argv[1]]).returncode)" +) -@pytest.hookimpl(tryfirst=True) -def pytest_configure(config) -> None: - """ - Configure pytest. +@pytest.fixture(autouse=True) +def _ensure_registries_populated(): + """Ensure built-in components are registered before each test. - :param config: pytest config object + Some tests call registry.clear() for isolation. This fixture guarantees + the next test always starts with populated registries. """ - # Reduce flaky plugin verbosity - config.option.flaky_report = "none" - config.option.tbstyle = "short" + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() @pytest.fixture(scope="session") -def sample_dataset(data_dir) -> DrugResponseDataset: - """ - Sample dataset for testing individual models. +def synthetic_dataset() -> Dataset: + """Session-wide synthetic raw-omics dataset standing in for a real ``.h5mu``. - :param data_dir: path to the data directory - :returns: drug_response, cell_line_input, drug_input + Built once because rdkit fingerprints, molecular graphs and the learned BPE + merges cost more than the tests that consume them. Nothing in the suite + writes to it, so sharing is safe. + + :returns: Dataset with complete modality coverage; see + :mod:`tests.synthetic.builders` for its exact shape. """ - drug_response = load_toyv1(str(data_dir)) - drug_response.remove_nan_responses() - return drug_response + return build_synthetic_dataset() @pytest.fixture(scope="session") -def cross_study_dataset(data_dir) -> DrugResponseDataset: - """ - Sample dataset for testing individual models. +def built_wheel_contents(tmp_path_factory: pytest.TempPathFactory) -> frozenset[str]: + """Namelist of a real ``uv build --wheel`` of this repository, built once. - :param data_dir: path to the data directory - :returns: drug_response, cell_line_input, drug_input + Two packaging tests assert against the wheel that consumers actually get: + ``tests/plugin/test_init.py`` for the PEP 561 marker and + ``tests/testing/test_init.py`` for the shipped ``drevalpy.testing`` + submodules. The build is identical for both, so it runs once per session and + the namelist is shared read-only rather than paying for a second hatchling + run. Skips when ``uv`` is unavailable, which is what makes the tests that + request it skip too. + + :param tmp_path_factory: Session-scoped temporary directory factory. + :returns: Every archive member name in the built wheel. """ - drug_response = load_toyv2(str(data_dir)) - drug_response.remove_nan_responses() - return drug_response + if shutil.which("uv") is None: + pytest.skip("needs uv to build a wheel") + out_dir = UPath(tmp_path_factory.mktemp("built_wheel")) + result = run_trusted_python(_BUILD_WHEEL_SCRIPT, cwd=str(REPO_ROOT), extra_args=[str(out_dir)]) + if result.returncode != 0: + pytest.fail(f"uv build --wheel failed:\n{result.stderr}") -@pytest.fixture(scope="session", autouse=True) -def ensure_bpe_features(data_dir) -> None: - """ - Ensure BPE SMILES features are created for TOYv1 and TOYv2 before tests run. + wheels = list(out_dir.glob("*.whl")) + if not wheels: + pytest.fail("uv build --wheel produced no wheel") + with zipfile.ZipFile(str(wheels[0])) as archive: + return frozenset(archive.namelist()) - This fixture runs automatically before any tests to ensure that PharmaFormer - and other models requiring BPE features have the necessary data available. - :param data_dir: path to the data directory - """ - path_data = str(data_dir) - - try: - from drevalpy.datasets.featurizer.create_pharmaformer_drug_embeddings import ( - create_pharmaformer_drug_embeddings, - ) - except ImportError: - # If subword-nmt is not installed, skip BPE feature creation - # Tests that require BPE features will fail with a clear error message - return - - # Ensure datasets are loaded first (this will download them if needed) - try: - load_toyv1(path_data) - load_toyv2(path_data) - except Exception as e: - # If dataset loading fails, skip BPE creation - print(f"Warning: Could not load datasets for BPE feature creation: {e}") - return - - # Create BPE features for both TOYv1 and TOYv2 - for dataset_name in ["TOYv1", "TOYv2"]: - dataset_dir = pathlib.Path(path_data) / dataset_name - bpe_smiles_file = dataset_dir / "drug_bpe_smiles.csv" - smiles_file = dataset_dir / "drug_smiles.csv" - - # Only create if it doesn't exist and if drug_smiles.csv exists - if not bpe_smiles_file.exists(): - if not smiles_file.exists(): - print(f"Warning: drug_smiles.csv not found for {dataset_name}, skipping BPE creation") - continue - - try: - print(f"Creating BPE SMILES features for {dataset_name}...") - create_pharmaformer_drug_embeddings( - data_path=path_data, - dataset_name=dataset_name, - num_symbols=10000, - max_length=128, - ) - print(f"BPE SMILES features created for {dataset_name}") - except Exception as e: - # Log but don't fail - let individual tests handle missing features - print(f"Warning: Could not create BPE features for {dataset_name}: {e}") - import traceback - - traceback.print_exc() - - -@pytest.fixture(scope="session", autouse=True) -def ensure_precily_pathway_features(data_dir) -> None: - """ - Ensure GSVA pathway features exist for TOYv1 and TOYv2 before tests run. +class MockFeatureSource(FeatureSource): + """Test helper implementing the FeatureSource ABC.""" - This fixture runs automatically before any tests to ensure that Precily - and other models requiring Precily features have the necessary data available. + def __init__(self, features: dict[str, dict[str, Any]], meta_info: dict[str, Any] | None = None): + """Initialize with features dict and optional metadata. - :param data_dir: path to the data directory - """ - path_data = str(data_dir) - - try: - from drevalpy.datasets.featurizer.create_precily_pathway_features import ( - create_precily_pathway_features, - ) - except ImportError: - # If gseapy is not installed, skip pathway feature creation - # Tests that require Precily features will fail with a clear error message - return - - # Ensure datasets are loaded first (this will download them if needed) - try: - load_toyv1(path_data) - load_toyv2(path_data) - except Exception as e: - # If dataset loading fails, skip Precily creation - print(f"Warning: could not load datasets for pathway feature creation: {e}") - return - - # Create Precily features for both TOYv1 and TOYv2 - for dataset_name in ["TOYv1", "TOYv2"]: - dataset_dir = pathlib.Path(path_data) / dataset_name - pathway_file = dataset_dir / "pathway_features.csv" - expr_file = dataset_dir / "gene_expression.csv" - - if pathway_file.exists(): - continue - if not expr_file.exists(): - print(f"Warning: gene_expression.csv not found for {dataset_name}, skipping") - continue - - # Collect gene symbols from the expression header (drop id/name columns) - with open(expr_file, encoding="utf-8") as f: - header = f.readline().strip().split(",") - non_gene_cols = {"cellosaurus_id", "cell_line_name"} - genes = [c for c in header if c not in non_gene_cols] - - # GSVA filters gene sets by min_size (default 5); build overlapping sets - # of >=5 genes each so at least a couple survive the size filter. - min_size = 5 - if len(genes) < min_size: - print(f"Warning: too few genes in {dataset_name} ({len(genes)}), skipping") - continue - - gene_sets = { - "SYNTH_PATHWAY_A": genes[: max(min_size, len(genes) // 2)], - "SYNTH_PATHWAY_B": genes[-max(min_size, len(genes) // 2) :], # noqa: E203 - } - - # Write a temporary .gmt next to the dataset - gmt_path = dataset_dir / "synthetic_pathways.gmt" - with open(gmt_path, "w", encoding="utf-8") as f: - for name, set_genes in gene_sets.items(): - f.write("\t".join([name, "synthetic", *set_genes]) + "\n") - - try: - print(f"Creating synthetic GSVA pathway features for {dataset_name}...") - create_precily_pathway_features( - data_path=path_data, - dataset_name=dataset_name, - gene_sets=str(gmt_path), - min_size=min_size, - ) - except Exception as e: - # Log but don't fail - let individual tests handle missing features - print(f"Warning: could not create pathway features for {dataset_name}: {e}") - import traceback - - traceback.print_exc() - - -@pytest.fixture(scope="session", autouse=True) -def ensure_precily_drug_features(data_dir) -> None: - """ - Ensure SMILESVec drug features exist for TOYv1 and TOYv2 before tests run. + :param features: Mapping of entity_id -> {view_name -> feature_array}. + :param meta_info: Optional mapping of view_name -> feature names or metadata. + """ + self._features = features + self._meta_info = meta_info or {} - This fixture runs automatically before any tests to ensure that Precily - and other models requiring Precily features have the necessary data available. + @property + def identifiers(self) -> np.ndarray: + """All available entity IDs.""" + return np.array(list(self._features.keys())) - :param data_dir: path to the data directory - """ - path_data = str(data_dir) - - # Ensure datasets are loaded first (this will download them if needed) - try: - load_toyv1(path_data) - load_toyv2(path_data) - except Exception as e: - print(f"Warning: could not load datasets for drug feature creation: {e}") - return - - embedding_dim = 100 # matches the SMILESVec featurizer default (dim=100) - - for dataset_name in ["TOYv1", "TOYv2"]: - dataset_dir = pathlib.Path(path_data) / dataset_name - smilesvec_file = dataset_dir / "drug_smilesvec.csv" - smiles_file = dataset_dir / "drug_smiles.csv" - - if smilesvec_file.exists(): - continue - if not smiles_file.exists(): - print(f"Warning: drug_smiles.csv not found for {dataset_name}, skipping") - continue - - try: - import numpy as np - import pandas as pd - - smiles_df = pd.read_csv(smiles_file, dtype=str) - pubchem_ids = smiles_df["pubchem_id"].astype(str).tolist() - - # Deterministic synthetic embeddings for reproducible test runs - rng = np.random.default_rng(seed=42) - embeddings = rng.standard_normal((len(pubchem_ids), embedding_dim)).astype(np.float32) - - out_df = pd.DataFrame(embeddings, index=pubchem_ids) - out_df.index.name = "pubchem_id" - print(f"Creating synthetic SMILESVec drug features for {dataset_name}...") - out_df.to_csv(smilesvec_file) - except Exception as e: - print(f"Warning: could not create drug features for {dataset_name}: {e}") - - -@pytest.fixture(scope="session", autouse=True) -def ensure_sparsego_ontology_features(data_dir) -> None: - """ - Ensure SparseGO ontology features exist for TOYv1 and TOYv2 before tests run. + @property + def mdata(self) -> Any: + """No MuData backing for mocks.""" + return None + + @property + def features(self) -> dict[str, dict[str, Any]]: + """Direct access to the backing features dict.""" + return self._features + + def get_view_matrix(self, view: str, entity_ids: np.ndarray) -> np.ndarray: + """Return (len(ids), n_features) float array for a dense numeric view.""" + rows = [np.asarray(self._features[str(eid)][view], dtype=np.float64).ravel() for eid in entity_ids] + return np.vstack(rows) - This fixture runs automatically before any tests to ensure that SparseGO - has the necessary gene2ind.txt and sparseGO_ont.txt files available. These - are generated from go-basic.obo and MyGene.info GO annotations (real - network calls), using the same default n/m/p pruning thresholds that were - used to originally generate the committed TOYv1/TOYv2 files by hand. + def get_feature_names(self, view: str) -> tuple[str, ...] | None: + """Return ordered feature/column names for a view, or None.""" + meta = self._meta_info.get(view) + return tuple(str(n) for n in meta) if meta is not None else None - :param data_dir: path to the data directory + def get_entity_view(self, entity_id: str, view: str) -> Any: + """Return the raw per-entity object for non-numeric views (graphs, etc.).""" + entity = self._features.get(str(entity_id)) + if entity is None: + return None + return entity.get(view) + + def get_metadata(self, key: str) -> Any: + """Return arbitrary metadata (e.g. ontology structures).""" + return self._meta_info.get(key) + + +def pytest_configure(config: pytest.Config) -> None: + """Configure pytest session defaults and a headless Matplotlib backend. + + :param config: Pytest configuration object. """ - path_data = str(data_dir) - - try: - from drevalpy.datasets.featurizer.create_sparsego_features import create_sparsego_files - except ImportError: - # If obonet/mygene are not installed, skip ontology feature creation - # Tests that require SparseGO features will fail with a clear error message - return - - # Ensure datasets are loaded first (this will download them if needed) - try: - load_toyv1(path_data) - load_toyv2(path_data) - except Exception as e: - print(f"Warning: could not load datasets for SparseGO ontology creation: {e}") - return - - for dataset_name in ["TOYv1", "TOYv2"]: - dataset_dir = pathlib.Path(path_data) / dataset_name - ont_file = dataset_dir / "sparseGO_ont.txt" - gene2ind_file = dataset_dir / "gene2ind.txt" - expr_file = dataset_dir / "gene_expression.csv" - - if ont_file.exists() and gene2ind_file.exists(): - continue - if not expr_file.exists(): - print(f"Warning: gene_expression.csv not found for {dataset_name}, skipping") - continue - - try: - print(f"Generating SparseGO ontology features for {dataset_name} (network calls to GO/MyGene.info)...") - create_sparsego_files( - data_path=path_data, - dataset_name=dataset_name, - ) - print(f"SparseGO ontology features created for {dataset_name}") - except Exception as e: - # Log but don't fail - let individual tests handle missing features - print(f"Warning: could not create SparseGO ontology features for {dataset_name}: {e}") - import traceback - - traceback.print_exc() + import matplotlib + + matplotlib.use("Agg") + config.option.flaky_report = "none" + config.option.tbstyle = "short" diff --git a/tests/curation/conftest.py b/tests/curation/conftest.py new file mode 100644 index 000000000..07c9fe133 --- /dev/null +++ b/tests/curation/conftest.py @@ -0,0 +1,85 @@ +"""Shared dose-response fixtures for the curation tests.""" + +from __future__ import annotations + +import anndata +import numpy as np +import pandas as pd +import pytest + +from drevalpy.curation import curate + + +def sigmoid(x: np.ndarray, top: float, bottom: float, ec50: float, slope: float) -> np.ndarray: + """Evaluate a 4-parameter log-logistic sigmoid. + + :param x: Concentrations. + :param top: Response plateau at zero concentration. + :param bottom: Response plateau at infinite concentration. + :param ec50: Concentration at half-maximal effect. + :param slope: Hill slope. + :returns: Modelled response at each concentration. + """ + return bottom + (top - bottom) / (1 + (x / ec50) ** slope) + + +def build_dose_response_df() -> pd.DataFrame: + """Artificial dose-response data: 3 cell lines x 2 drugs. + + Exposed as a plain function so the session-scoped fitted fixtures below can + build their own private copy without sharing one frame across scopes. + + :returns: Long-form dose-response measurements. + """ + concentrations = [0.001, 0.01, 0.1, 1.0, 10.0] + cell_lines = ["CL_A", "CL_B", "CL_C"] + drugs = ["DrugX", "DrugY"] + + rng = np.random.default_rng(42) + rows: list[dict] = [] + + for cl in cell_lines: + for drug in drugs: + conc_arr = np.array(concentrations) + if drug == "DrugX": + intensity = sigmoid(conc_arr, top=1.0, bottom=0.1, ec50=0.5, slope=1.5) + else: + intensity = np.ones_like(conc_arr) * 0.95 + + noise = rng.normal(0, 0.02, size=len(concentrations)) + intensity = np.clip(intensity + noise, 0.01, 1.5) + + for conc, intens in zip(concentrations, intensity, strict=True): + rows.append({"drug": drug, "cell_line": cl, "concentration": conc, "intensity": intens}) + + return pd.DataFrame(rows) + + +@pytest.fixture() +def dose_response_df() -> pd.DataFrame: + """Artificial dose-response data: 3 cell lines x 2 drugs.""" + return build_dose_response_df() + + +@pytest.fixture(scope="session") +def curated_adata() -> anndata.AnnData: + """``curate`` run once on :func:`build_dose_response_df`, shared read-only. + + The six real CurveCurator fits behind this cost ~0.5s, so the end-to-end + assertions in ``test_init.py`` share one run instead of repeating it. Treat + the returned object as immutable; a test that needs to mutate it, or that + asserts on ``curate``'s own arguments, must call ``curate`` itself. + """ + return curate(build_dose_response_df(), max_workers=1, fit_speed="fast") + + +@pytest.fixture() +def dose_response_df_with_replicates(dose_response_df: pd.DataFrame) -> pd.DataFrame: + """Same data duplicated with replicate column.""" + rng = np.random.default_rng(99) + rep1 = dose_response_df.copy() + rep1["replicate"] = 1 + rep2 = dose_response_df.copy() + rep2["replicate"] = 2 + rep2["intensity"] = rep2["intensity"] + rng.normal(0, 0.01, size=len(rep2)) + return pd.concat([rep1, rep2], ignore_index=True) diff --git a/tests/curation/test_anndata.py b/tests/curation/test_anndata.py new file mode 100644 index 000000000..7a9c4b637 --- /dev/null +++ b/tests/curation/test_anndata.py @@ -0,0 +1,127 @@ +"""Tests for drevalpy.curation._anndata.build_anndata.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from drevalpy.curation._anndata import _LAYER_METRICS, X_MEASURE, build_anndata + + +def _make_metrics_df() -> pd.DataFrame: + """Build a flat metrics frame covering every layer metric. + + Columns are checked against :data:`_LAYER_METRICS` so a metric added there + without a value here fails loudly rather than silently skipping its layer. + + :returns: A 2 cell lines x 2 drugs metrics frame. + """ + values: dict[str, list] = { + "cell_line": ["CL_A", "CL_A", "CL_B", "CL_B"], + "drug": ["DrugX", "DrugY", "DrugX", "DrugY"], + X_MEASURE: [6.0, 4.0, 5.5, 3.5], + "EC50": [1.0, 100.0, 3.16, 316.0], + "IC50": [0.5, 200.0, 1.5, 500.0], + "LN_IC50": [-0.69, 5.3, 0.4, 6.2], + "AUC": [0.3, 0.9, 0.4, 0.95], + "fold_change": [0.9, 0.05, 0.8, 0.03], + "slope": [1.5, 0.8, 1.2, 0.5], + "front": [1.0, 1.0, 0.95, 1.0], + "back": [0.1, 0.9, 0.15, 0.92], + "R2": [0.99, 0.5, 0.98, 0.4], + "RMSE": [0.02, 0.05, 0.03, 0.06], + "p_value": [0.001, 0.5, 0.01, 0.6], + "log_p_value": [-3.0, -0.3, -2.0, -0.2], + "f_value": [50.0, 2.0, 30.0, 1.5], + "f_value_sam": [45.0, 1.5, 28.0, 1.2], + "relevance_score": [0.95, 0.1, 0.85, 0.05], + "signal_quality": [0.9, 0.3, 0.85, 0.25], + "regulation": ["down", "not", "down", "not"], + "pec50_error": [0.05, 12.0, 0.08, 15.0], + "slope_error": [0.1, 30.0, 0.2, 40.0], + "front_error": [0.01, 3.0, 0.02, 4.0], + "back_error": [0.01, 4.0, 0.03, 5.0], + } + unset = set(_LAYER_METRICS) - set(values) + assert not unset, f"metrics frame is missing layer metric(s): {sorted(unset)}" + return pd.DataFrame(values) + + +@pytest.fixture() +def metrics_df() -> pd.DataFrame: + """A flat metrics frame covering every layer metric.""" + return _make_metrics_df() + + +@pytest.fixture() +def adata(metrics_df: pd.DataFrame): + """``build_anndata`` applied to that frame.""" + return build_anndata(metrics_df) + + +class TestShapeAndAxes: + """Long-form rows become a (cell_lines x drugs) matrix.""" + + def test_shape(self, adata) -> None: + assert adata.shape == (2, 2) + + def test_obs_var_indices_are_sorted_uniques(self, adata) -> None: + assert list(adata.obs_names) == ["CL_A", "CL_B"] + assert list(adata.var_names) == ["DrugX", "DrugY"] + + def test_an_unmeasured_pair_becomes_nan(self, metrics_df: pd.DataFrame) -> None: + result = build_anndata(metrics_df.drop(index=1)) + + assert np.isnan(result.X[0, 1]) + + +class TestXMatrix: + """``X`` holds pEC50; nothing else does.""" + + def test_x_is_the_pec50_column(self, metrics_df: pd.DataFrame, adata) -> None: + expected = metrics_df.pivot(index="cell_line", columns="drug", values=X_MEASURE).to_numpy() + + np.testing.assert_allclose(adata.X, expected.astype(np.float32)) + + def test_x_is_float32(self, adata) -> None: + assert adata.X.dtype == np.float32 + + def test_pec50_is_not_duplicated_as_a_layer(self, adata) -> None: + assert X_MEASURE not in adata.layers + + +class TestLayers: + """Every metric present in the frame becomes a float32 layer.""" + + @pytest.mark.parametrize("metric", list(_LAYER_METRICS)) + def test_each_layer_metric_is_stored(self, adata, metric: str) -> None: + assert metric in adata.layers + assert adata.layers[metric].dtype == np.float32 + + def test_a_metric_absent_from_the_frame_is_skipped(self, metrics_df: pd.DataFrame) -> None: + result = build_anndata(metrics_df.drop(columns=["AUC"])) + + assert "AUC" not in result.layers + + def test_regulation_is_encoded_numerically(self, adata) -> None: + assert set(np.unique(adata.layers["regulation"])) == {-1.0, 0.0} + + def test_the_input_frame_is_not_mutated(self, metrics_df: pd.DataFrame) -> None: + build_anndata(metrics_df) + + assert metrics_df["regulation"].tolist() == ["down", "not", "down", "not"] + + +class TestPerCurveErrorLayers: + """The recovered per-parameter standard errors reach ``layers`` intact.""" + + @pytest.mark.parametrize("metric", ["pec50_error", "slope_error", "front_error", "back_error"]) + def test_values_round_trip_through_the_pivot(self, metrics_df: pd.DataFrame, adata, metric: str) -> None: + expected = metrics_df.pivot(index="cell_line", columns="drug", values=metric).to_numpy() + + np.testing.assert_allclose(adata.layers[metric], expected.astype(np.float32)) + + def test_pec50_error_aligns_with_x(self, adata) -> None: + """The uncertainty layer must be measured wherever ``X`` is.""" + np.testing.assert_array_equal(np.isnan(adata.layers["pec50_error"]), np.isnan(adata.X)) diff --git a/tests/curation/test_fit.py b/tests/curation/test_fit.py new file mode 100644 index 000000000..cfd956c5f --- /dev/null +++ b/tests/curation/test_fit.py @@ -0,0 +1,396 @@ +"""Tests for drevalpy.curation._fit. + +Every test pins ``max_workers=1`` (or a single work item) so the fitting stays in the +calling process: the parallel path spawns a ``ProcessPoolExecutor``, which would +re-import curve_curator per worker for no additional coverage. + +A real CurveCurator fit costs ~0.5s, so the tests that only *read* a fitted frame +share one session-scoped fit (:class:`_Fitted` below) instead of repeating it. +Two kinds of test deliberately keep their own fit and are marked as such in place: +those asserting a mutation or lifecycle side effect of the call, and those where +the call itself - not its result - is the subject. +""" + +from __future__ import annotations + +from typing import NamedTuple + +import anndata +import numpy as np +import pandas as pd +import pytest + +from drevalpy.curation import curate +from drevalpy.curation._fit import ( + _build_config, + _build_work_items, + _fit_chunk, + _run_work_items, + fit_groups, +) +from drevalpy.curation._preprocess import preprocess +from tests.curation.test_normalize import build_normalizable_df + +_DOSES = [0.0, 0.001, 0.01, 0.1, 1.0, 10.0] + + +def _curve_rows(concentrations: list[float], cell_lines: list[str], drug: str) -> list[dict]: + """Build long-form rows following a well-behaved sigmoid, one row per dose.""" + return [ + { + "drug": drug, + "cell_line": cell_line, + "concentration": concentration, + "intensity": 0.1 + 0.9 / (1 + (concentration / 0.5) ** 1.5), + } + for cell_line in cell_lines + for concentration in concentrations + ] + + +def _build_single_group() -> list[tuple[pd.DataFrame, dict]]: + """One dose-range group holding three curves.""" + rows = _curve_rows([0.001, 0.01, 0.1, 1.0, 10.0], ["CL_A", "CL_B", "CL_C"], "DrugX") + return preprocess(pd.DataFrame(rows)) + + +def _build_two_groups() -> list[tuple[pd.DataFrame, dict]]: + """Two dose-range groups: DrugY tops out two decades above DrugX.""" + rows = _curve_rows([0.001, 0.01, 0.1, 1.0, 10.0], ["CL_A", "CL_B"], "DrugX") + rows += _curve_rows([0.001, 0.01, 0.1, 1.0, 100.0], ["CL_A", "CL_B"], "DrugY") + return preprocess(pd.DataFrame(rows)) + + +class _Fitted(NamedTuple): + """A completed ``fit_groups`` run together with the groups it was given. + + Both halves come from the same session-scoped build, so a test may compare the + results against ``groups`` without rebuilding either. + """ + + groups: list[tuple[pd.DataFrame, dict]] + results: list[tuple[pd.DataFrame, dict]] + + +@pytest.fixture() +def single_group() -> list[tuple[pd.DataFrame, dict]]: + """One dose-range group holding three curves, private to the requesting test.""" + return _build_single_group() + + +@pytest.fixture() +def two_groups() -> list[tuple[pd.DataFrame, dict]]: + """Two dose-range groups, private to the requesting test.""" + return _build_two_groups() + + +@pytest.fixture(scope="session") +def fitted_single_group() -> _Fitted: + """``fit_groups`` over one group, fitted once and shared read-only.""" + groups = _build_single_group() + return _Fitted(groups, fit_groups(groups, max_workers=1, fit_speed="fast")) + + +@pytest.fixture(scope="session") +def fitted_two_groups() -> _Fitted: + """``fit_groups`` over two dose-range groups, fitted once and shared read-only.""" + groups = _build_two_groups() + return _Fitted(groups, fit_groups(groups, max_workers=1, fit_speed="fast")) + + +@pytest.fixture(scope="session") +def fitted_chunk() -> tuple[pd.DataFrame, pd.DataFrame]: + """One ``_fit_chunk`` call, as ``(input wide_df, fitted frame)``.""" + wide_df, group_info = _build_single_group()[0] + config = _build_config(group_info["n_experiments"], group_info["doses"], 1, fit_speed="fast") + return wide_df, _fit_chunk(wide_df, config) + + +@pytest.fixture() +def recorded_chunk_fits(monkeypatch: pytest.MonkeyPatch) -> list[pd.DataFrame]: + """Replace ``_fit_chunk`` with a pass-through that records the chunks it saw. + + ``_run_work_items`` only dispatches and re-labels; substituting the fit keeps + the real dispatch under test - the returned results still come from this stub, + so a missed work item still shows up as a length mismatch - while dropping a + CurveCurator fit per chunk that no assertion here inspects. + """ + seen: list[pd.DataFrame] = [] + + def _record(chunk_df: pd.DataFrame, config: dict) -> pd.DataFrame: + seen.append(chunk_df) + return chunk_df + + monkeypatch.setattr("drevalpy.curation._fit._fit_chunk", _record) + return seen + + +class TestBuildConfig: + """The curve_curator config assembled from a preprocess group_info.""" + + def test_forwards_fit_type_and_speed(self) -> None: + # "MLE" is rejected by the public curate() entry point, but the private + # plumbing still forwards it so re-enabling is a one-line change once the + # curve_curator fork accepts the 'weights' argument again. + config = _build_config(6, _DOSES, 1, fit_type="MLE", fit_speed="fast") + + assert config["Curve Fit"]["type"] == "MLE" + assert config["Curve Fit"]["speed"] == "fast" + + def test_forwards_normalize_flag(self) -> None: + config = _build_config(6, _DOSES, 1, normalize=True) + + assert config["Processing"]["normalization"] is True + + @pytest.mark.parametrize( + ("doses", "expected_max_missing"), + [ + pytest.param(_DOSES, 1, id="six-doses-tolerates-one"), + pytest.param([0.0, 0.1, 1.0], 0, id="fewer-than-five-doses-tolerates-none"), + ], + ) + def test_max_missing_scales_with_dose_count(self, doses: list[float], expected_max_missing: int) -> None: + config = _build_config(len(doses), doses, 1) + + assert config["Processing"]["max_missing"] == expected_max_missing + + def test_experiment_indices_cover_every_column(self) -> None: + config = _build_config(6, _DOSES, 1) + + assert len(config["Experiment"]["experiments"]) == 6 + + def test_control_experiments_follow_replicate_count(self) -> None: + config = _build_config(12, _DOSES, 2) + + assert len(config["Experiment"]["control_experiment"]) == 2 + + def test_defaults_are_filled_in_by_curve_curator(self) -> None: + """``set_default_values`` adds sections the caller never writes.""" + config = _build_config(6, _DOSES, 1) + + assert "Dashboard" in config + + +class TestBuildWorkItems: + """Chunking of groups into independently fittable units.""" + + def test_splits_a_group_into_ceil_chunks(self, single_group: list[tuple[pd.DataFrame, dict]]) -> None: + work_items, _ = _build_work_items( + single_group, max_chunk_size=2, normalize=False, fit_type="OLS", fit_speed="fast" + ) + + assert len(work_items) == 2 + + def test_chunks_partition_the_group(self, single_group: list[tuple[pd.DataFrame, dict]]) -> None: + work_items, _ = _build_work_items( + single_group, max_chunk_size=2, normalize=False, fit_type="OLS", fit_speed="fast" + ) + + assert sum(len(chunk) for chunk, _, _ in work_items) == len(single_group[0][0]) + + def test_chunk_index_is_reset(self, single_group: list[tuple[pd.DataFrame, dict]]) -> None: + work_items, _ = _build_work_items( + single_group, max_chunk_size=2, normalize=False, fit_type="OLS", fit_speed="fast" + ) + + _, trailing_chunk = work_items[0][0], work_items[1][0] + assert trailing_chunk.index.tolist() == [0] + + def test_one_config_per_group(self, two_groups: list[tuple[pd.DataFrame, dict]]) -> None: + _, configs = _build_work_items(two_groups, max_chunk_size=10, normalize=False, fit_type="OLS", fit_speed="fast") + + assert len(configs) == 2 + + def test_each_chunk_carries_its_group_index(self, two_groups: list[tuple[pd.DataFrame, dict]]) -> None: + work_items, _ = _build_work_items( + two_groups, max_chunk_size=10, normalize=False, fit_type="OLS", fit_speed="fast" + ) + + assert [group_idx for _, _, group_idx in work_items] == [0, 1] + + +class TestFitChunk: + """Single-chunk fitting. + + Extended tier: both tests run a real CurveCurator fit, ~1s together. + """ + + pytestmark = pytest.mark.slow + + def test_does_not_mutate_the_shared_config(self, single_group: list[tuple[pd.DataFrame, dict]]) -> None: + # Keeps its own fit on purpose: the assertion is about what the call did to + # the config it was handed, which a shared fitted result cannot show. + wide_df, group_info = single_group[0] + config = _build_config(group_info["n_experiments"], group_info["doses"], 1, fit_speed="fast") + config["Processing"]["available_max_workers"] = 8 + + _fit_chunk(wide_df, config) + + assert config["Processing"]["available_max_workers"] == 8 + + def test_returns_one_row_per_curve(self, fitted_chunk: tuple[pd.DataFrame, pd.DataFrame]) -> None: + wide_df, fitted = fitted_chunk + + assert len(fitted) == len(wide_df) + + +class TestRunWorkItems: + """Dispatch between the serial and the pooled fitting paths.""" + + @pytest.mark.parametrize( + ("max_chunk_size", "max_workers"), + [ + pytest.param(2, 1, id="single-core-many-chunks"), + pytest.param(10, 4, id="many-cores-single-chunk"), + ], + ) + def test_stays_in_process( + self, + single_group: list[tuple[pd.DataFrame, dict]], + monkeypatch: pytest.MonkeyPatch, + recorded_chunk_fits: list[pd.DataFrame], + max_chunk_size: int, + max_workers: int, + ) -> None: + def _no_pool(*args: object, **kwargs: object) -> None: + raise AssertionError("ProcessPoolExecutor must not be used on the serial path") + + monkeypatch.setattr("drevalpy.curation._fit.ProcessPoolExecutor", _no_pool) + work_items, _ = _build_work_items( + single_group, max_chunk_size=max_chunk_size, normalize=False, fit_type="OLS", fit_speed="fast" + ) + + results = _run_work_items(work_items, max_workers=max_workers) + + assert len(results) == len(work_items) + + def test_preserves_group_index_per_chunk( + self, two_groups: list[tuple[pd.DataFrame, dict]], recorded_chunk_fits: list[pd.DataFrame] + ) -> None: + work_items, _ = _build_work_items( + two_groups, max_chunk_size=1, normalize=False, fit_type="OLS", fit_speed="fast" + ) + + results = _run_work_items(work_items, max_workers=1) + + assert [group_idx for _, group_idx in results] == [group_idx for _, _, group_idx in work_items] + + def test_fits_every_chunk_exactly_once( + self, two_groups: list[tuple[pd.DataFrame, dict]], recorded_chunk_fits: list[pd.DataFrame] + ) -> None: + """Guards the stub above: a dropped work item must not look like a pass.""" + work_items, _ = _build_work_items( + two_groups, max_chunk_size=1, normalize=False, fit_type="OLS", fit_speed="fast" + ) + + _run_work_items(work_items, max_workers=1) + + assert [chunk["Name"].tolist() for chunk in recorded_chunk_fits] == [ + chunk["Name"].tolist() for chunk, _, _ in work_items + ] + + +class TestFitGroups: + """The public entry point. + + Extended tier: the session-scoped ``fitted_*`` fixtures behind these are real + CurveCurator fits (~1.5s). Because the fits are shared, the cost only goes away + when the whole class is deselected - hence a class-level marker. + """ + + pytestmark = pytest.mark.slow + + def test_returns_one_result_per_group(self, fitted_two_groups: _Fitted) -> None: + assert len(fitted_two_groups.results) == 2 + + def test_keeps_every_curve(self, fitted_single_group: _Fitted) -> None: + fitted_df, _ = fitted_single_group.results[0] + + assert fitted_df["Name"].tolist() == fitted_single_group.groups[0][0]["Name"].tolist() + + def test_routes_curves_back_to_their_own_group(self, fitted_two_groups: _Fitted) -> None: + assert [sorted(df["Name"]) for df, _ in fitted_two_groups.results] == [ + ["CL_A|DrugX", "CL_B|DrugX"], + ["CL_A|DrugY", "CL_B|DrugY"], + ] + + def test_adds_the_curve_parameters_postprocess_consumes(self, fitted_single_group: _Fitted) -> None: + fitted_df, _ = fitted_single_group.results[0] + + assert {"pEC50", "Curve Slope", "Curve Front", "Curve Back", "Curve AUC"} <= set(fitted_df.columns) + + def test_emits_the_per_curve_parameter_errors(self, fitted_single_group: _Fitted) -> None: + """CurveCurator computes these on every fit; ``_postprocess`` keeps them.""" + fitted_df, _ = fitted_single_group.results[0] + + assert {"pEC50 Error", "Curve Slope Error", "Curve Front Error", "Curve Back Error"} <= set(fitted_df.columns) + + def test_applies_significance_thresholds(self, fitted_single_group: _Fitted) -> None: + """Only ``thresholding.apply_significance_thresholds`` adds these columns.""" + fitted_df, _ = fitted_single_group.results[0] + + assert {"Curve Relevance Score", "Curve Regulation"} <= set(fitted_df.columns) + + def test_returns_the_config_each_group_was_fitted_with(self, fitted_single_group: _Fitted) -> None: + _, config = fitted_single_group.results[0] + + assert config["Curve Fit"]["speed"] == "fast" + assert config["Experiment"]["doses"].tolist() == fitted_single_group.groups[0][1]["doses"] + + +class TestNormalizedFitIsCoreCountIndependent: + """The regression guard for the per-chunk normalization bug. + + ``normalize=True`` used to run inside every parallel chunk, so a dataset got + one set of median-derived normalization factors per chunk and its output + depended on the worker count. ``max_workers=1`` and ``max_workers=4`` chunk + the same group into one and four pieces respectively, so agreeing here is + exactly the property that used to fail. + + Driven through :func:`drevalpy.curation.curate` because that is the only + public entry point; the AnnData it returns is indexed by cell line and drug, + so ``X`` and the layers line up pair-for-pair without any sorting. + + Extended tier: two real multi-curve CurveCurator fits, one of them across a + process pool. + """ + + pytestmark = pytest.mark.slow + + @staticmethod + def _curate(*, max_workers: int, normalize: bool) -> anndata.AnnData: + """Curate the normalizable dataset at a given core count.""" + return curate(build_normalizable_df(), max_workers=max_workers, normalize=normalize, fit_speed="fast") + + @pytest.fixture(scope="class") + def normalized_fits(self) -> tuple[anndata.AnnData, anndata.AnnData]: + """The same normalized dataset curated at ``max_workers=1`` and ``max_workers=4``.""" + return self._curate(max_workers=1, normalize=True), self._curate(max_workers=4, normalize=True) + + def test_the_same_curves_come_back(self, normalized_fits: tuple[anndata.AnnData, anndata.AnnData]) -> None: + serial, pooled = normalized_fits + + assert serial.obs_names.tolist() == pooled.obs_names.tolist() + assert serial.var_names.tolist() == pooled.var_names.tolist() + + def test_every_metric_is_identical(self, normalized_fits: tuple[anndata.AnnData, anndata.AnnData]) -> None: + serial, pooled = normalized_fits + + np.testing.assert_array_equal(serial.X, pooled.X) + assert set(serial.layers) == set(pooled.layers) + for name in serial.layers: + np.testing.assert_array_equal(serial.layers[name], pooled.layers[name], err_msg=name) + + def test_normalization_actually_changed_the_result(self) -> None: + """Otherwise the equality above would hold for a no-op implementation.""" + normalized = self._curate(max_workers=1, normalize=True) + plain = self._curate(max_workers=1, normalize=False) + + assert not np.allclose(normalized.X, plain.X, equal_nan=True) + + def test_signal_quality_reflects_the_raw_controls(self) -> None: + """Normalization overwrites the raw columns, so this is restored explicitly.""" + normalized = self._curate(max_workers=1, normalize=True) + plain = self._curate(max_workers=1, normalize=False) + + np.testing.assert_allclose(normalized.layers["signal_quality"], plain.layers["signal_quality"]) diff --git a/tests/curation/test_init.py b/tests/curation/test_init.py new file mode 100644 index 000000000..234328d25 --- /dev/null +++ b/tests/curation/test_init.py @@ -0,0 +1,177 @@ +"""End-to-end tests for the drevalpy.curation package surface. + +The per-stage tests live in ``test_preprocess.py`` / ``test_fit.py`` / +``test_postprocess.py`` / ``test_anndata.py`` / ``test_normalize.py``; only +:func:`~drevalpy.curation.curate`, which wires those stages together, is +exercised here. +""" + +from __future__ import annotations + +import anndata +import numpy as np +import pandas as pd +import pytest + +import drevalpy.curation as curation +from drevalpy.curation import ( + DEFAULT_FIT_SPEED, + FIT_SPEEDS, + SUPPORTED_FIT_TYPES, + build_anndata, + curate, +) +from tests.curation.conftest import build_dose_response_df + + +class TestPackageSurface: + """``curate`` is the only entry point, and ``__all__`` says so.""" + + def test_curate_and_build_anndata_are_exported(self) -> None: + assert {"build_anndata", "curate"} <= set(curation.__all__) + + def test_no_second_entry_point_is_advertised(self) -> None: + """``fit_curves`` was removed: the .h5ad is the pipeline's intermediate.""" + assert "fit_curves" not in curation.__all__ + assert not hasattr(curation, "fit_curves") + + +class TestFitOptionValidation: + """``curate`` rejects options it cannot actually run, before fitting.""" + + def test_only_ols_is_advertised(self) -> None: + assert SUPPORTED_FIT_TYPES == ("OLS",) + + def test_the_documented_default_speed_is_exhaustive(self) -> None: + """``fast`` takes one shot from a single guess, so it is not the default.""" + assert DEFAULT_FIT_SPEED == "exhaustive" + assert DEFAULT_FIT_SPEED in FIT_SPEEDS + + def test_mle_is_rejected_before_any_fitting(self, dose_response_df: pd.DataFrame) -> None: + with pytest.raises(ValueError, match="fit_mle"): + curate(dose_response_df, max_workers=1, fit_type="MLE", fit_speed="fast") + + def test_unknown_fit_type_names_the_supported_ones(self, dose_response_df: pd.DataFrame) -> None: + with pytest.raises(ValueError, match=r"expected one of \['OLS'\]"): + curate(dose_response_df, max_workers=1, fit_type="nonsense", fit_speed="fast") + + def test_unknown_fit_speed_is_rejected(self, dose_response_df: pd.DataFrame) -> None: + with pytest.raises(ValueError, match="fit_speed='turbo'"): + curate(dose_response_df, max_workers=1, fit_speed="turbo") + + +class TestCurate: + """The one public entry point, end to end. + + Extended tier: shares the session-scoped ``curated_adata`` fixture, which is + six real CurveCurator fits. + """ + + pytestmark = pytest.mark.slow + + def test_curate_returns_anndata(self, curated_adata: anndata.AnnData) -> None: + assert isinstance(curated_adata, anndata.AnnData) + assert curated_adata.shape == (3, 2) + + def test_the_labels_become_the_index(self, curated_adata: anndata.AnnData) -> None: + assert curated_adata.obs_names.tolist() == ["CL_A", "CL_B", "CL_C"] + assert curated_adata.var_names.tolist() == ["DrugX", "DrugY"] + + def test_curate_x_not_all_nan(self, curated_adata: anndata.AnnData) -> None: + assert not np.all(np.isnan(curated_adata.X)) + + def test_x_is_finite_for_the_sigmoid_drug(self, curated_adata: anndata.AnnData) -> None: + sigmoid = curated_adata[:, "DrugX"].X + + assert np.isfinite(sigmoid).all() + + def test_curate_layers_exist(self, curated_adata: anndata.AnnData) -> None: + assert len(curated_adata.layers) > 0 + assert "EC50" in curated_adata.layers + assert "AUC" in curated_adata.layers + + @pytest.mark.parametrize("metric", ["EC50", "IC50", "LN_IC50", "AUC", "R2", "regulation"]) + def test_the_derived_and_quality_metrics_reach_the_caller( + self, curated_adata: anndata.AnnData, metric: str + ) -> None: + assert metric in curated_adata.layers + + def test_the_recovered_error_layers_are_present(self, curated_adata: anndata.AnnData) -> None: + assert {"pec50_error", "slope_error", "front_error", "back_error"} <= set(curated_adata.layers) + + def test_every_layer_is_shaped_like_x(self, curated_adata: anndata.AnnData) -> None: + for name, layer in curated_adata.layers.items(): + assert layer.shape == curated_adata.shape, name + + +class TestNativeIdentifiersSurvive: + """The reason no flat metrics frame is needed. + + The curation pipeline curates on native identifiers, persists the ``.h5ad``, + and remaps ``obs_names``/``var_names`` in a later, cheap stage - so the labels + it was given have to come back verbatim as the index. + + Extended tier: one real six-curve CurveCurator fit. + """ + + pytestmark = pytest.mark.slow + + @pytest.fixture(scope="class") + def natively_curated(self) -> anndata.AnnData: + """``curate`` over the same data relabelled with native identifiers.""" + native = build_dose_response_df() + native["cell_line"] = native["cell_line"].map({"CL_A": "ACH-1", "CL_B": "SIDM00400", "CL_C": "2004"}) + native["drug"] = native["drug"].map({"DrugX": "DRUG_1047", "DrugY": "BRD-K02251932"}) + return curate(native, max_workers=1, fit_speed="fast") + + def test_the_cell_line_labels_are_the_obs_index(self, natively_curated: anndata.AnnData) -> None: + assert set(natively_curated.obs_names) == {"ACH-1", "SIDM00400", "2004"} + + def test_the_drug_labels_are_the_var_index(self, natively_curated: anndata.AnnData) -> None: + assert set(natively_curated.var_names) == {"DRUG_1047", "BRD-K02251932"} + + def test_a_purely_numeric_label_stays_a_string(self, natively_curated: anndata.AnnData) -> None: + """``2004`` is a valid Cellosaurus-adjacent ID; it must not become an int.""" + assert "2004" in natively_curated.obs_names + + def test_the_values_are_the_same_as_under_the_original_labels( + self, natively_curated: anndata.AnnData, curated_adata: anndata.AnnData + ) -> None: + """Relabelling must not change a single fit - the labels are opaque.""" + renamed = natively_curated[["ACH-1", "SIDM00400", "2004"], ["DRUG_1047", "BRD-K02251932"]] + + np.testing.assert_array_equal(renamed.X, curated_adata.X) + + +class TestCurateIsPreprocessFitPostprocessBuild: + """``curate`` must stay exactly the composition of the four private stages. + + Extended tier: shares the session-scoped fitted fixture. + """ + + pytestmark = pytest.mark.slow + + @staticmethod + def _stages(df: pd.DataFrame) -> anndata.AnnData: + """Run the private stages by hand, as ``curate`` is documented to.""" + from drevalpy.curation._fit import fit_groups + from drevalpy.curation._postprocess import postprocess + from drevalpy.curation._preprocess import preprocess + + return build_anndata(postprocess(fit_groups(preprocess(df), max_workers=1, fit_speed="fast"))) + + def test_running_the_stages_by_hand_reproduces_curate( + self, curated_adata: anndata.AnnData, dose_response_df: pd.DataFrame + ) -> None: + composed = self._stages(dose_response_df) + + np.testing.assert_array_equal(composed.X, curated_adata.X) + assert set(composed.layers) == set(curated_adata.layers) + + def test_the_composed_layers_agree_value_for_value( + self, curated_adata: anndata.AnnData, dose_response_df: pd.DataFrame + ) -> None: + composed = self._stages(dose_response_df) + + for name in curated_adata.layers: + np.testing.assert_array_equal(composed.layers[name], curated_adata.layers[name], err_msg=name) diff --git a/tests/curation/test_normalize.py b/tests/curation/test_normalize.py new file mode 100644 index 000000000..1ef28cdbf --- /dev/null +++ b/tests/curation/test_normalize.py @@ -0,0 +1,234 @@ +"""Tests for drevalpy.curation._normalize. + +The module exists to make a normalized fit independent of the core count, so the +headline test here compares two ``cores`` values byte for byte. That comparison +is what :mod:`drevalpy.curation._fit` used to fail: it called +``quantification.run_pipeline`` once per parallel chunk, and curve_curator +derives its normalization factors from the rows of the frame it was handed. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from drevalpy.curation._fit import _build_config, _build_work_items +from drevalpy.curation._normalize import ( + PRE_NORM_SIGNAL_QUALITY, + _column_names, + normalize_group, + restore_signal_quality, +) +from drevalpy.curation._preprocess import preprocess + +_CONCENTRATIONS = (0.001, 0.01, 0.1, 1.0, 10.0) + + +def _viability_rows(cell_lines: list[str], drug: str, scale: float) -> list[dict]: + """Long-form rows for a sigmoid scaled by a per-cell-line offset. + + A constant scale per cell line is exactly what median-centric normalization + is meant to remove, so the scaling makes the factors non-trivial. + + :param cell_lines: Cell-line labels to emit curves for. + :param drug: Drug label. + :param scale: Multiplicative offset applied to every intensity. + :returns: Long-form rows. + """ + return [ + { + "drug": drug, + "cell_line": cell_line, + "concentration": concentration, + "intensity": scale * (index + 1) * (0.1 + 0.9 / (1 + (concentration / 0.5) ** 1.5)), + } + for index, cell_line in enumerate(cell_lines) + for concentration in _CONCENTRATIONS + ] + + +def build_normalizable_df() -> pd.DataFrame: + """Twelve curves across two dose-range groups, each on its own intensity scale. + + :returns: Long-form dose-response measurements. + """ + rows = _viability_rows([f"CL_{index}" for index in range(6)], "DrugX", scale=1.0) + rows += _viability_rows([f"CL_{index}" for index in range(6)], "DrugY", scale=3.0) + frame = pd.DataFrame(rows) + # Push DrugY into a second dose-range group so the fix is exercised per group. + frame.loc[frame["drug"] == "DrugY", "concentration"] *= 10.0 + return frame + + +@pytest.fixture() +def groups() -> list[tuple[pd.DataFrame, dict]]: + """Two dose-range groups from :func:`build_normalizable_df`.""" + return preprocess(build_normalizable_df()) + + +@pytest.fixture() +def group_and_config(groups: list[tuple[pd.DataFrame, dict]]) -> tuple[pd.DataFrame, dict]: + """The first group with a matching normalizing config.""" + df, info = groups[0] + config = _build_config(info["n_experiments"], info["doses"], info["n_replicates"], normalize=True, fit_speed="fast") + return df, config + + +class TestColumnNames: + """The column names must match the ones ``run_pipeline`` would build.""" + + def test_raw_and_normalized_names_line_up(self, group_and_config: tuple[pd.DataFrame, dict]) -> None: + _, config = group_and_config + + raw, normalized, _, _ = _column_names(config) + + assert [name.replace("Raw", "Normalized") for name in raw] == list(normalized) + + def test_raw_names_exist_on_the_group_frame(self, group_and_config: tuple[pd.DataFrame, dict]) -> None: + df, config = group_and_config + + raw, _, _, _ = _column_names(config) + + assert set(raw) <= set(df.columns) + + def test_the_zero_dose_column_is_excluded_from_the_dosed_set( + self, group_and_config: tuple[pd.DataFrame, dict] + ) -> None: + _, config = group_and_config + + raw, _, dosed, _ = _column_names(config) + + assert len(dosed) == len(raw) - 1 + + def test_controls_are_the_zero_dose_columns(self, group_and_config: tuple[pd.DataFrame, dict]) -> None: + _, config = group_and_config + + _, _, _, controls = _column_names(config) + + assert list(controls) == ["Raw 0"] + + +class TestNormalizeGroup: + """Normalization is applied to the whole group in a single pass.""" + + def test_raw_columns_are_overwritten_with_normalized_values( + self, group_and_config: tuple[pd.DataFrame, dict] + ) -> None: + df, config = group_and_config + + result = normalize_group(df, config) + + raw, _, _, _ = _column_names(config) + assert not np.allclose(result[raw].to_numpy(), df[raw].to_numpy()) + + def test_the_normalized_helper_columns_are_not_left_behind( + self, group_and_config: tuple[pd.DataFrame, dict] + ) -> None: + df, config = group_and_config + + result = normalize_group(df, config) + + assert not [column for column in result.columns if str(column).startswith("Normalized")] + + def test_the_input_frame_is_not_mutated(self, group_and_config: tuple[pd.DataFrame, dict]) -> None: + df, config = group_and_config + raw, _, _, _ = _column_names(config) + before = df[raw].to_numpy(copy=True) + + normalize_group(df, config) + + np.testing.assert_array_equal(df[raw].to_numpy(), before) + + def test_every_curve_survives(self, group_and_config: tuple[pd.DataFrame, dict]) -> None: + df, config = group_and_config + + result = normalize_group(df, config) + + assert result["Name"].tolist() == df["Name"].tolist() + + def test_pre_normalization_signal_quality_is_carried(self, group_and_config: tuple[pd.DataFrame, dict]) -> None: + df, config = group_and_config + + result = normalize_group(df, config) + + expected = np.log2(df["Raw 0"].to_numpy()) + np.testing.assert_allclose(result[PRE_NORM_SIGNAL_QUALITY].to_numpy(), expected) + + def test_factors_are_independent_of_how_the_frame_is_ordered( + self, group_and_config: tuple[pd.DataFrame, dict] + ) -> None: + """Medians over the same rows, so a permutation cannot change them.""" + df, config = group_and_config + raw, _, _, _ = _column_names(config) + + straight = normalize_group(df, config).set_index("Name")[raw] + shuffled = normalize_group(df.iloc[::-1].reset_index(drop=True), config).set_index("Name")[raw] + + np.testing.assert_allclose(straight.to_numpy(), shuffled.loc[straight.index].to_numpy()) + + def test_a_subset_of_rows_gets_different_factors(self, group_and_config: tuple[pd.DataFrame, dict]) -> None: + """The bug this module fixes, demonstrated directly on the primitive.""" + df, config = group_and_config + raw, _, _, _ = _column_names(config) + + whole = normalize_group(df, config).set_index("Name")[raw] + half = normalize_group(df.iloc[:3].reset_index(drop=True), config).set_index("Name")[raw] + + assert not np.allclose(whole.loc[half.index].to_numpy(), half.to_numpy()) + + +class TestRestoreSignalQuality: + """The carrier column is consumed after the fit, not shipped.""" + + def test_the_carried_value_replaces_the_post_normalization_one(self) -> None: + fitted = pd.DataFrame({"Signal Quality": [0.0, 0.0], PRE_NORM_SIGNAL_QUALITY: [3.0, 4.0]}) + + result = restore_signal_quality(fitted) + + assert result["Signal Quality"].tolist() == [3.0, 4.0] + + def test_the_carrier_column_is_dropped(self) -> None: + fitted = pd.DataFrame({"Signal Quality": [0.0], PRE_NORM_SIGNAL_QUALITY: [3.0]}) + + result = restore_signal_quality(fitted) + + assert PRE_NORM_SIGNAL_QUALITY not in result.columns + + def test_a_frame_that_never_was_normalized_passes_through(self) -> None: + fitted = pd.DataFrame({"Signal Quality": [1.0]}) + + result = restore_signal_quality(fitted) + + assert result is fitted + + +class TestWorkItemWiring: + """``_build_work_items`` must hand the chunks an already-normalized frame.""" + + def test_chunk_configs_have_normalization_switched_off(self, groups: list[tuple[pd.DataFrame, dict]]) -> None: + work_items, _ = _build_work_items(groups, max_chunk_size=2, normalize=True, fit_type="OLS", fit_speed="fast") + + assert all(config["Processing"]["normalization"] is False for _, config, _ in work_items) + + def test_the_returned_group_configs_still_record_the_request(self, groups: list[tuple[pd.DataFrame, dict]]) -> None: + """``fit_groups`` thresholds with these, and they document what was asked.""" + _, configs = _build_work_items(groups, max_chunk_size=2, normalize=True, fit_type="OLS", fit_speed="fast") + + assert all(config["Processing"]["normalization"] is True for config in configs) + + def test_chunks_carry_the_pre_normalization_signal_quality(self, groups: list[tuple[pd.DataFrame, dict]]) -> None: + work_items, _ = _build_work_items(groups, max_chunk_size=2, normalize=True, fit_type="OLS", fit_speed="fast") + + assert all(PRE_NORM_SIGNAL_QUALITY in chunk.columns for chunk, _, _ in work_items) + + def test_no_normalization_leaves_the_frames_untouched(self, groups: list[tuple[pd.DataFrame, dict]]) -> None: + work_items, _ = _build_work_items(groups, max_chunk_size=2, normalize=False, fit_type="OLS", fit_speed="fast") + + assert all(PRE_NORM_SIGNAL_QUALITY not in chunk.columns for chunk, _, _ in work_items) + + def test_chunking_is_unaffected_by_the_extra_column(self, groups: list[tuple[pd.DataFrame, dict]]) -> None: + normalized, _ = _build_work_items(groups, max_chunk_size=2, normalize=True, fit_type="OLS", fit_speed="fast") + plain, _ = _build_work_items(groups, max_chunk_size=2, normalize=False, fit_type="OLS", fit_speed="fast") + + assert [len(chunk) for chunk, _, _ in normalized] == [len(chunk) for chunk, _, _ in plain] diff --git a/tests/curation/test_postprocess.py b/tests/curation/test_postprocess.py new file mode 100644 index 000000000..64f7d889d --- /dev/null +++ b/tests/curation/test_postprocess.py @@ -0,0 +1,166 @@ +"""Tests for drevalpy.curation._postprocess.postprocess.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from drevalpy.curation._postprocess import _COLUMN_RENAME, DERIVED_METRICS, postprocess + + +def _make_mock_fitted_df() -> pd.DataFrame: + """Build a frame mimicking curve_curator output for three curves. + + The columns come from :data:`_COLUMN_RENAME` rather than a hand-written list, + so a metric added there without a value here fails loudly instead of quietly + exercising a shorter frame. + + :returns: A frame with every column ``postprocess`` renames. + """ + values: dict[str, list] = { + "Name": ["CL_A|DrugX", "CL_B|DrugX", "CL_A|DrugY"], + "pEC50": [6.0, 5.5, 4.0], + "Curve Slope": [1.5, 1.2, 0.8], + "Curve Front": [1.0, 0.95, 1.0], + "Curve Back": [0.1, 0.15, 0.9], + "Curve Fold Change": [0.9, 0.8, 0.05], + "Curve AUC": [0.3, 0.4, 0.9], + "Curve RMSE": [0.02, 0.03, 0.01], + "Curve R2": [0.99, 0.98, 0.5], + "Curve P_Value": [0.001, 0.01, 0.5], + "Curve Log P_Value": [-3.0, -2.0, -0.3], + "Curve F_Value": [50.0, 30.0, 2.0], + "Curve F_Value SAM Corrected": [45.0, 28.0, 1.5], + "Curve Relevance Score": [0.95, 0.85, 0.1], + "Curve Regulation": ["down", "down", "not"], + "Signal Quality": [0.9, 0.85, 0.3], + "pEC50 Error": [0.05, 0.08, 12.0], + "Curve Slope Error": [0.1, 0.2, 30.0], + "Curve Front Error": [0.01, 0.02, 3.0], + "Curve Back Error": [0.01, 0.03, 4.0], + } + unset = set(_COLUMN_RENAME) - set(values) + assert not unset, f"mock frame is missing renamed column(s): {sorted(unset)}" + return pd.DataFrame(values) + + +@pytest.fixture() +def fitted_df() -> pd.DataFrame: + """A frame mimicking curve_curator output for three curves.""" + return _make_mock_fitted_df() + + +@pytest.fixture() +def metrics(fitted_df: pd.DataFrame) -> pd.DataFrame: + """``postprocess`` applied to a single group.""" + return postprocess([(fitted_df, {})]) + + +class TestColumns: + """The shape of the returned flat metrics frame.""" + + def test_it_returns_a_dataframe(self, metrics: pd.DataFrame) -> None: + assert isinstance(metrics, pd.DataFrame) + + @pytest.mark.parametrize("column", ["cell_line", "drug"]) + def test_label_columns_are_present(self, metrics: pd.DataFrame, column: str) -> None: + assert column in metrics.columns + + @pytest.mark.parametrize("column", list(DERIVED_METRICS)) + def test_derived_metrics_are_present(self, metrics: pd.DataFrame, column: str) -> None: + assert column in metrics.columns + + @pytest.mark.parametrize("column", sorted(set(_COLUMN_RENAME.values()))) + def test_every_renamed_metric_is_present(self, metrics: pd.DataFrame, column: str) -> None: + assert column in metrics.columns + + def test_no_raw_curve_curator_names_survive(self, metrics: pd.DataFrame) -> None: + assert not [column for column in metrics.columns if column.startswith(("Curve ", "Signal "))] + + def test_the_grouping_name_column_is_dropped(self, metrics: pd.DataFrame) -> None: + assert "Name" not in metrics.columns + + +class TestPerCurveErrors: + """The four per-parameter standard errors, previously discarded. + + They are CurveCurator's only per-curve uncertainty estimate, and they were + silently dropped for as long as ``_COLUMN_RENAME`` did not list them. + """ + + @pytest.mark.parametrize( + ("source", "renamed"), + [ + ("pEC50 Error", "pec50_error"), + ("Curve Slope Error", "slope_error"), + ("Curve Front Error", "front_error"), + ("Curve Back Error", "back_error"), + ], + ) + def test_each_error_survives_under_its_snake_case_name( + self, fitted_df: pd.DataFrame, metrics: pd.DataFrame, source: str, renamed: str + ) -> None: + np.testing.assert_allclose(metrics[renamed].to_numpy(), fitted_df[source].to_numpy()) + + def test_a_missing_error_column_is_an_error_not_a_silent_drop(self, fitted_df: pd.DataFrame) -> None: + with pytest.raises(KeyError, match="pEC50 Error"): + postprocess([(fitted_df.drop(columns=["pEC50 Error"]), {})]) + + +class TestDerivedMetrics: + """``EC50``/``IC50``/``LN_IC50`` are computed here, not read from the fit.""" + + def test_ec50_inverts_pec50(self, metrics: pd.DataFrame) -> None: + np.testing.assert_allclose(metrics.loc[0, "EC50"], 10 ** (-6.0) * 1e6, rtol=1e-5) + + def test_ln_ic50_is_the_log_of_ic50(self, metrics: pd.DataFrame) -> None: + np.testing.assert_allclose(metrics["LN_IC50"].to_numpy(), np.log(metrics["IC50"].to_numpy())) + + def test_a_curve_with_no_half_maximal_crossing_yields_nan(self, fitted_df: pd.DataFrame) -> None: + """``CL_A|DrugY`` has ``back = 0.9``, so the curve never reaches 0.5.""" + result = postprocess([(fitted_df, {})]) + + assert np.isnan(result.loc[2, "IC50"]) + + +class TestLabels: + """``Name`` is split back into the labels the caller supplied.""" + + def test_the_name_column_is_split_on_the_separator(self, metrics: pd.DataFrame) -> None: + assert metrics.loc[0, "cell_line"] == "CL_A" + assert metrics.loc[0, "drug"] == "DrugX" + + def test_native_identifiers_survive_unchanged(self) -> None: + """The pipeline fits on native IDs and remaps later, so these must pass through.""" + native = _make_mock_fitted_df() + native["Name"] = ["ACH-000001|DRUG_1047", "SIDM00400|DRUG_1047", "ACH-000001|BRD-K02251932"] + + result = postprocess([(native, {})]) + + assert result["cell_line"].tolist() == ["ACH-000001", "SIDM00400", "ACH-000001"] + assert result["drug"].tolist() == ["DRUG_1047", "DRUG_1047", "BRD-K02251932"] + + +class TestMultipleGroups: + """Dose-range groups are concatenated, not merged.""" + + def test_rows_from_every_group_are_kept(self, fitted_df: pd.DataFrame) -> None: + other = fitted_df.copy() + other["Name"] = ["CL_A|DrugZ", "CL_B|DrugZ", "CL_C|DrugZ"] + + result = postprocess([(fitted_df, {}), (other, {})]) + + assert len(result) == 2 * len(fitted_df) + + def test_the_index_is_reset_across_groups(self, fitted_df: pd.DataFrame) -> None: + result = postprocess([(fitted_df, {}), (fitted_df.copy(), {})]) + + assert result.index.tolist() == list(range(len(result))) + + def test_the_input_frame_is_not_mutated(self, fitted_df: pd.DataFrame) -> None: + before = fitted_df.columns.tolist() + + postprocess([(fitted_df, {})]) + + assert fitted_df.columns.tolist() == before diff --git a/tests/curation/test_preprocess.py b/tests/curation/test_preprocess.py new file mode 100644 index 000000000..11cf922b3 --- /dev/null +++ b/tests/curation/test_preprocess.py @@ -0,0 +1,61 @@ +"""Tests for drevalpy.curation._preprocess.preprocess.""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from drevalpy.curation._preprocess import preprocess + + +class TestPreprocess: + """Tests for drevalpy.curation._preprocess.preprocess.""" + + def test_returns_list_of_tuples(self, dose_response_df: pd.DataFrame) -> None: + result = preprocess(dose_response_df) + assert isinstance(result, list) + assert len(result) >= 1 + for item in result: + assert isinstance(item, tuple) and len(item) == 2 + + def test_wide_df_has_expected_columns(self, dose_response_df: pd.DataFrame) -> None: + groups = preprocess(dose_response_df) + wide_df, _ = groups[0] + assert "Name" in wide_df.columns + raw_cols = [c for c in wide_df.columns if str(c).startswith("Raw")] + assert len(raw_cols) > 0 + + def test_group_info_structure(self, dose_response_df: pd.DataFrame) -> None: + groups = preprocess(dose_response_df) + _, group_info = groups[0] + assert "n_experiments" in group_info + assert "doses" in group_info + assert "n_replicates" in group_info + assert isinstance(group_info["doses"], list) + assert group_info["n_experiments"] == len([c for c in groups[0][0].columns if str(c).startswith("Raw")]) + + def test_missing_columns_raises(self) -> None: + bad_df = pd.DataFrame({"drug": ["A"], "cell_line": ["B"]}) + with pytest.raises(ValueError, match="Missing required columns"): + preprocess(bad_df) + + def test_with_replicates(self, dose_response_df_with_replicates: pd.DataFrame) -> None: + groups = preprocess(dose_response_df_with_replicates) + _, group_info = groups[0] + assert group_info["n_replicates"] == 2 + + def test_name_column_format(self, dose_response_df: pd.DataFrame) -> None: + groups = preprocess(dose_response_df) + wide_df, _ = groups[0] + for name in wide_df["Name"]: + parts = name.split("|") + assert len(parts) == 2 + + def test_duplicate_measurements_are_averaged_with_a_warning(self, dose_response_df: pd.DataFrame) -> None: + duplicated = pd.concat([dose_response_df, dose_response_df.head(1)], ignore_index=True) + + with pytest.warns(UserWarning, match="Duplicate entries found"): + groups = preprocess(duplicated) + + wide_df, _ = groups[0] + assert len(wide_df) == 6 diff --git a/tests/datasets/__init__.py b/tests/data/__init__.py similarity index 100% rename from tests/datasets/__init__.py rename to tests/data/__init__.py diff --git a/tests/data/datasets/test_load.py b/tests/data/datasets/test_load.py new file mode 100644 index 000000000..b54878ef3 --- /dev/null +++ b/tests/data/datasets/test_load.py @@ -0,0 +1,285 @@ +"""Tests for the dataset registry and loader.""" + +from __future__ import annotations + +import json +from importlib import resources +from pathlib import Path + +import pytest +from upath import UPath + +from drevalpy.data.datasets._load import _carries_curve_quality, _load_from_cache +from drevalpy.registry.dataset import DatasetRegistry as Registry +from drevalpy.registry.dataset import DrevalConfig, SourceEntry, get_config_path +from drevalpy.registry.dataset import dataset_registry as registry +from drevalpy.testing.synthetic import build_synthetic_dataset + +_CORE_DATASETS = [ + "BeatAML2", + "CTRPv1", + "CTRPv2", + "GDSC1", + "GDSC2", + "PDX_Bruna", +] + + +def _packaged_registry() -> dict: + """Read the packaged ``available_datasets.json``.""" + registry_path = resources.files("drevalpy.data.datasets").joinpath("available_datasets.json") + with registry_path.open(encoding="utf-8") as handle: + return json.load(handle) + + +class TestBuiltinRegistry: + """Tests for the built-in dataset registry (packaged JSON).""" + + def test_available_datasets_json_structure(self) -> None: + """Verify the packaged JSON has the expected schema.""" + raw = _packaged_registry() + + assert "sources" in raw + assert "datasets" in raw + + for _source_name, val in raw["sources"].items(): + assert isinstance(val, (str, dict)) + + for _ds_name, entry in raw["datasets"].items(): + assert "source" in entry + assert "file" in entry + assert entry["source"] in raw["sources"] + assert entry["file"].endswith(".h5mu") + + def test_dataset_names(self) -> None: + """Every packaged dataset is exposed, in sorted order.""" + assert registry.dataset_names == sorted(registry.dataset_names) + assert set(_packaged_registry()["datasets"]) <= set(registry.dataset_names) + + def test_core_datasets_are_registered(self) -> None: + assert set(_CORE_DATASETS) <= set(registry.dataset_names) + + def test_source_names(self) -> None: + assert "orakl" in registry.source_names + + def test_is_registered(self) -> None: + assert registry.is_registered("GDSC1") + assert registry.is_registered("BeatAML2") + assert not registry.is_registered("NonExistent") + + def test_builtin_vs_custom_separation(self) -> None: + assert set(registry.builtin_datasets) == set(_packaged_registry()["datasets"]) + assert set(registry.builtin_sources) == set(_packaged_registry()["sources"]) + assert registry.custom_datasets.keys() <= registry.datasets.keys() + + def test_datasets_property_returns_merged(self) -> None: + datasets = registry.datasets + assert all(name in datasets for name in _CORE_DATASETS) + + def test_sources_have_urls(self) -> None: + for source in registry.sources.values(): + assert source.url + assert isinstance(source.url, str) + + +class TestRegistration: + """Tests for register/unregister with a temporary config directory.""" + + @pytest.fixture(autouse=True) + def _use_tmp_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DREVALPY_CONFIG_DIR", str(tmp_path)) + self.reg = Registry() + + def test_register_source(self) -> None: + self.reg.register_source("test_src", "https://example.com/data") + assert "test_src" in self.reg.sources + assert self.reg.sources["test_src"].url == "https://example.com/data" + + def test_register_source_with_storage_options(self) -> None: + self.reg.register_source("s3_src", "s3://bucket/path", storage_options={"profile": "dev"}) + assert self.reg.sources["s3_src"].storage_options == {"profile": "dev"} + + def test_register_dataset(self) -> None: + self.reg.register_source("src", "https://example.com") + self.reg.register_dataset("MyData", source="src", file="MyData.h5mu") + assert self.reg.is_registered("MyData") + assert self.reg.datasets["MyData"].file == "MyData.h5mu" + + def test_register_dataset_unknown_source_raises(self) -> None: + with pytest.raises(KeyError, match="not registered"): + self.reg.register_dataset("MyData", source="nonexistent", file="x.h5mu") + + def test_unregister_dataset(self) -> None: + self.reg.register_source("src", "https://example.com") + self.reg.register_dataset("MyData", source="src", file="MyData.h5mu") + self.reg.unregister_dataset("MyData") + assert not self.reg.is_registered("MyData") + + def test_unregister_dataset_not_custom_raises(self) -> None: + with pytest.raises(KeyError, match="not in custom"): + self.reg.unregister_dataset("NonExistent") + + def test_unregister_source(self) -> None: + self.reg.register_source("src", "https://example.com") + self.reg.unregister_source("src") + assert "src" not in self.reg.custom_sources + + def test_unregister_source_with_datasets_raises(self) -> None: + self.reg.register_source("src", "https://example.com") + self.reg.register_dataset("MyData", source="src", file="x.h5mu") + with pytest.raises(ValueError, match="still referenced"): + self.reg.unregister_source("src") + + def test_unregister_source_not_custom_raises(self) -> None: + with pytest.raises(KeyError, match="not in custom"): + self.reg.unregister_source("NonExistent") + + def test_custom_overrides_builtin(self) -> None: + self.reg.register_source("orakl", "s3://my-mirror/data") + assert self.reg.sources["orakl"].url == "s3://my-mirror/data" + + +class TestPersistence: + """Tests for config persistence and reload.""" + + @pytest.fixture(autouse=True) + def _use_tmp_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DREVALPY_CONFIG_DIR", str(tmp_path)) + self.tmp_path = tmp_path + self.reg = Registry() + + def test_registration_persists_to_disk(self) -> None: + self.reg.register_source("persisted", "https://example.com") + config_path = get_config_path() + assert config_path.is_file() + + with open(config_path) as f: + raw = json.load(f) + assert "persisted" in raw["sources"] + + def test_reload_picks_up_external_changes(self) -> None: + config_path = get_config_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps( + { + "sources": {"external": "https://external.org"}, + "datasets": {"ExtData": {"source": "external", "file": "ext.h5mu"}}, + } + ) + ) + + self.reg.reload() + assert self.reg.is_registered("ExtData") + assert "external" in self.reg.custom_sources + + def test_atomic_write_preserves_existing_entries(self) -> None: + self.reg.register_source("first", "https://first.com") + self.reg.register_source("second", "https://second.com") + assert "first" in self.reg.custom_sources + assert "second" in self.reg.custom_sources + + +class TestModels: + """Tests for Pydantic config models.""" + + def test_source_entry_from_string(self) -> None: + entry = SourceEntry.from_raw("https://example.com") + assert entry.url == "https://example.com" + assert entry.storage_options == {} + + def test_source_entry_from_dict(self) -> None: + entry = SourceEntry.from_raw({"url": "s3://bucket", "storage_options": {"anon": True}}) + assert entry.url == "s3://bucket" + assert entry.storage_options == {"anon": True} + + def test_source_entry_to_raw_simple(self) -> None: + entry = SourceEntry(url="https://example.com") + assert entry.to_raw() == "https://example.com" + + def test_source_entry_to_raw_with_options(self) -> None: + entry = SourceEntry(url="s3://bucket", storage_options={"profile": "dev"}) + assert entry.to_raw() == {"url": "s3://bucket", "storage_options": {"profile": "dev"}} + + def test_dreval_config_roundtrip(self) -> None: + raw = { + "sources": {"lab": "https://lab.org/data"}, + "datasets": {"Study1": {"source": "lab", "file": "Study1.h5mu"}}, + } + config = DrevalConfig.from_raw(raw) + assert "lab" in config.sources + assert "Study1" in config.datasets + assert config.to_raw() == raw + + def test_dreval_config_forbids_extra_keys(self) -> None: + from pydantic import ValidationError + + with pytest.raises(ValidationError): + DrevalConfig( + sources={}, + datasets={}, + unknown="bad", + ) + + +class TestStrRepr: + """Tests for string representation.""" + + def test_str_contains_dataset_names(self) -> None: + output = str(registry) + assert "GDSC1" in output + assert "BeatAML2" in output + + def test_str_contains_source_names(self) -> None: + output = str(registry) + assert "orakl" in output + + def test_repr_equals_str(self) -> None: + assert str(registry) == repr(registry) + + +class TestCachedFileIsRejectedWhenTooOld: + """The CurveCurator refit reused the file names of the generation before it. + + A cache filled by an older drevalpy therefore holds a file that parses + perfectly but has none of the quality layers the splitters read. Serving it + would surface as a ``KeyError`` from deep inside a split, so ``load`` has to + treat it as stale. + """ + + def test_a_dataset_without_the_quality_layers_is_rejected(self) -> None: + dataset = build_synthetic_dataset(n_cell_lines=4, n_drugs=3) + del dataset.response.layers["relevance_score"] + + assert not _carries_curve_quality(dataset) + + def test_a_dataset_with_the_quality_layers_is_accepted(self) -> None: + assert _carries_curve_quality(build_synthetic_dataset(n_cell_lines=4, n_drugs=3)) + + def test_a_stale_cached_file_is_deleted_and_reported( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + dataset = build_synthetic_dataset(n_cell_lines=4, n_drugs=3) + del dataset.response.layers["relevance_score"] + path = UPath(tmp_path) / "stale.h5mu" + dataset.save(path) + + with caplog.at_level("WARNING"): + assert _load_from_cache(path) is None + + assert not path.is_file() + assert "curve-quality" in caplog.text + + def test_a_usable_cached_file_is_returned_and_kept(self, tmp_path: Path) -> None: + path = UPath(tmp_path) / "fresh.h5mu" + build_synthetic_dataset(n_cell_lines=4, n_drugs=3).save(path) + + assert _load_from_cache(path) is not None + assert path.is_file() + + def test_an_unparseable_cached_file_is_deleted(self, tmp_path: Path) -> None: + path = UPath(tmp_path) / "corrupt.h5mu" + path.write_bytes(b"not an h5mu file") + + assert _load_from_cache(path) is None + assert not path.is_file() diff --git a/tests/data/splitters/_helpers.py b/tests/data/splitters/_helpers.py new file mode 100644 index 000000000..3e7e242c3 --- /dev/null +++ b/tests/data/splitters/_helpers.py @@ -0,0 +1,133 @@ +"""Shared stubs for the splitter tests. + +``MockMuDataset`` is the reference in-memory ``MuDataLike`` stand-in: the +splitters read ``cell_line_ids``, ``drug_ids``, ``response_matrix``, +``get_tissue`` and the response layers behind +:func:`drevalpy.data.quality.curve_quality_mask`, so a real ``.h5mu`` +round-trip buys nothing here. + +By default every curve passes the quality filter, which keeps the folds a +splitter produces determined purely by ``response_matrix``. Pass +*failing_pairs* to mark individual pairs as low quality. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +import numpy as np + +from drevalpy.types import SplitMask + + +class MockMuDataset: + """Minimal MuDataLike for testing splitters.""" + + def __init__( + self, + n_cl: int = 10, + n_dr: int = 8, + density: float = 0.7, + n_tissues: int = 3, + failing_pairs: Iterable[tuple[int, int]] = (), + ): + """Build a response matrix with a deterministic pattern of missing values. + + :param n_cl: Number of cell lines (rows). + :param n_dr: Number of drugs (columns). + :param density: Fraction of observed entries; the rest become NaN. + :param n_tissues: Number of distinct tissues cycled over the cell lines. + :param failing_pairs: ``(row, column)`` pairs to mark as failing the + default curve-quality thresholds. + """ + rng = np.random.default_rng(42) + self._response = rng.standard_normal((n_cl, n_dr)).astype(np.float32) + mask = rng.random((n_cl, n_dr)) > density + self._response[mask] = np.nan + self._cl_ids = np.array([f"CL_{i}" for i in range(n_cl)]) + self._dr_ids = np.array([f"DR_{i}" for i in range(n_dr)]) + self._tissues = np.array([f"Tissue_{i % n_tissues}" for i in range(n_cl)]) + + # Comfortably above/below the default thresholds, so a boundary change + # in the filter cannot silently flip these fixtures. + relevance = np.full((n_cl, n_dr), 9.0, dtype=np.float32) + fold_change = np.full((n_cl, n_dr), -2.0, dtype=np.float32) + for row, column in failing_pairs: + relevance[row, column] = 0.0 + fold_change[row, column] = 0.0 + self._layers = {"relevance_score": relevance, "fold_change": fold_change} + + @property + def cell_line_ids(self) -> np.ndarray: + """Row identifiers of the response matrix.""" + return self._cl_ids + + @property + def drug_ids(self) -> np.ndarray: + """Column identifiers of the response matrix.""" + return self._dr_ids + + @property + def response_matrix(self) -> np.ndarray: + """Cell-line-by-drug response matrix with NaN for unobserved pairs.""" + return self._response + + def get_tissue(self, ids: np.ndarray) -> np.ndarray: + """Return the tissue label for each requested cell line id. + + :param ids: Cell line identifiers to look up. + :returns: Tissue labels in the order of *ids*. + """ + idx_map = {name: i for i, name in enumerate(self._cl_ids)} + indices = [idx_map[str(x)] for x in ids] + return self._tissues[indices] + + def response_layer_names(self) -> list[str]: + """Names of the available response layers.""" + return list(self._layers) + + def get_response_layer(self, name: str) -> np.ndarray: + """Return a named response layer. + + :param name: Layer name. + :returns: Cell-line-by-drug matrix for that layer. + :raises KeyError: If the layer was not built. + """ + if name not in self._layers: + raise KeyError(f"Response layer '{name}' not found. Available: {list(self._layers)}") + return self._layers[name] + + +def build_mask(shape: tuple[int, int], *positions: tuple[int, int]) -> SplitMask: + """Build a ``SplitMask`` of *shape* that is True exactly at *positions*. + + :param shape: ``(n_cell_lines, n_drugs)`` shape of the mask. + :param positions: ``(row, column)`` coordinates to set to True. + :returns: The assembled ``SplitMask``. + """ + mask = np.zeros(shape, dtype=bool) + for row, column in positions: + mask[row, column] = True + return SplitMask(mask) + + +def first_measured_pairs(dataset: MockMuDataset, count: int) -> list[tuple[int, int]]: + """Return the first *count* measured ``(row, column)`` pairs of *dataset*. + + Used to pick pairs that the quality filter can visibly remove: blanking an + already-unmeasured pair would prove nothing. + """ + measured = np.argwhere(~np.isnan(dataset.response_matrix)) + return [(int(row), int(column)) for row, column in measured[:count]] + + +def covered_pairs(folds: list) -> np.ndarray: + """Union every train, test and validation mask across *folds*. + + A pair the splitter considers usable lands in at least one of them, so this + is what a quality-filtered pair must be absent from. + """ + covered = np.zeros(folds[0].train.shape, dtype=bool) + for fold in folds: + covered |= fold.train.mask | fold.test.mask | fold.val.mask + return covered diff --git a/tests/data/splitters/conftest.py b/tests/data/splitters/conftest.py new file mode 100644 index 000000000..70a231552 --- /dev/null +++ b/tests/data/splitters/conftest.py @@ -0,0 +1,13 @@ +"""Fixtures shared by the splitter test modules.""" + +from __future__ import annotations + +import pytest + +from tests.data.splitters._helpers import MockMuDataset + + +@pytest.fixture +def mock_dataset() -> MockMuDataset: + """A 10x8 response matrix over three tissues with ~30% missing entries.""" + return MockMuDataset() diff --git a/tests/data/splitters/test_folds.py b/tests/data/splitters/test_folds.py new file mode 100644 index 000000000..6b014cb0b --- /dev/null +++ b/tests/data/splitters/test_folds.py @@ -0,0 +1,230 @@ +"""Tests for :mod:`drevalpy.data.splitters._folds`. + +Mirrors the private module with the underscore stripped. All four built-in +splitters are assembled from these helpers, so the properties every mode inherits +- quality filtering, a disjoint train/validation/test partition, reproducibility +from ``random_state`` - are asserted here once, directly on the helpers, rather +than four times over through the registered splitters. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.data.splitters._folds import ( + entity_masks, + group_folds, + observed_mask, + pair_masks, + rows_with_labels, +) +from tests.data.splitters._helpers import MockMuDataset, first_measured_pairs + +FOLD_KWARGS = {"n_splits": 5, "validation_ratio": 0.1, "random_state": 42} + + +@pytest.fixture(scope="module") +def dataset() -> MockMuDataset: + return MockMuDataset() + + +@pytest.fixture(scope="module") +def observed(dataset) -> np.ndarray: + return observed_mask(dataset) + + +class TestObservedMask: + def test_is_a_boolean_matrix_of_the_response_shape(self, dataset, observed): + assert observed.dtype == bool + assert observed.shape == dataset.response_matrix.shape + + def test_marks_the_measured_pairs(self, dataset, observed): + np.testing.assert_array_equal(observed, ~np.isnan(dataset.response_matrix)) + + def test_drops_pairs_that_fail_the_quality_filter(self): + clean = MockMuDataset() + failing = first_measured_pairs(clean, 3) + + filtered = observed_mask(MockMuDataset(failing_pairs=failing)) + + assert not any(filtered[row, column] for row, column in failing) + assert filtered.sum() == observed_mask(clean).sum() - len(failing) + + def test_does_not_mutate_the_response_matrix(self, dataset): + before = dataset.response_matrix.copy() + + observed_mask(dataset) + + np.testing.assert_array_equal(np.isnan(dataset.response_matrix), np.isnan(before)) + + +class TestGroupFolds: + def test_yields_one_triple_per_split(self): + assert len(list(group_folds(10, **FOLD_KWARGS))) == 5 + + def test_every_group_is_in_exactly_one_test_set(self): + test_sets = [test for _, _, test in group_folds(10, **FOLD_KWARGS)] + + assert sorted(int(i) for test in test_sets for i in test) == list(range(10)) + + def test_the_three_index_sets_of_a_fold_are_disjoint(self): + for train, validation, test in group_folds(10, **FOLD_KWARGS): + assert set(train.tolist()).isdisjoint(validation.tolist()) + assert set(train.tolist()).isdisjoint(test.tolist()) + assert set(validation.tolist()).isdisjoint(test.tolist()) + + def test_a_fold_covers_every_group(self): + for train, validation, test in group_folds(10, **FOLD_KWARGS): + assert sorted([*train.tolist(), *validation.tolist(), *test.tolist()]) == list(range(10)) + + def test_holds_out_at_least_one_validation_group_when_the_ratio_is_positive(self): + for _, validation, _ in group_folds(10, **FOLD_KWARGS): + assert len(validation) >= 1 + + def test_a_zero_ratio_yields_no_validation_group(self): + for _, validation, _ in group_folds(10, n_splits=5, validation_ratio=0.0, random_state=42): + assert len(validation) == 0 + + def test_a_larger_ratio_holds_out_more(self): + small = [len(v) for _, v, _ in group_folds(20, n_splits=4, validation_ratio=0.1, random_state=0)] + large = [len(v) for _, v, _ in group_folds(20, n_splits=4, validation_ratio=0.5, random_state=0)] + + assert all(a < b for a, b in zip(small, large, strict=True)) + + def test_is_reproducible_for_one_seed(self): + first = [tuple(map(list, fold)) for fold in group_folds(12, **FOLD_KWARGS)] + second = [tuple(map(list, fold)) for fold in group_folds(12, **FOLD_KWARGS)] + + assert first == second + + def test_another_seed_partitions_differently(self): + a = [sorted(test.tolist()) for _, _, test in group_folds(12, n_splits=4, validation_ratio=0.1, random_state=0)] + b = [sorted(test.tolist()) for _, _, test in group_folds(12, n_splits=4, validation_ratio=0.1, random_state=7)] + + assert a != b + + def test_rejects_more_splits_than_groups(self): + with pytest.raises(ValueError, match="number of splits"): + list(group_folds(3, n_splits=5, validation_ratio=0.1, random_state=42)) + + +class TestEntityMasks: + def test_rows_land_in_the_mask_of_their_own_side(self, observed): + masks = entity_masks(observed, train=np.array([0, 1]), validation=np.array([2]), test=np.array([3]), axis=0) + + np.testing.assert_array_equal(masks.train.mask[[0, 1], :], observed[[0, 1], :]) + np.testing.assert_array_equal(masks.val.mask[2, :], observed[2, :]) + np.testing.assert_array_equal(masks.test.mask[3, :], observed[3, :]) + + def test_a_row_split_leaves_foreign_rows_empty(self, observed): + masks = entity_masks(observed, train=np.array([0]), validation=np.array([1]), test=np.array([2]), axis=0) + + assert not masks.train.mask[1:, :].any() + + def test_a_column_split_holds_out_drugs(self, observed): + masks = entity_masks(observed, train=np.array([0, 1]), validation=np.array([2]), test=np.array([3]), axis=1) + + np.testing.assert_array_equal(masks.test.mask[:, 3], observed[:, 3]) + assert not masks.test.mask[:, [0, 1, 2]].any() + + def test_unobserved_pairs_are_never_selected(self, observed): + rows = np.arange(observed.shape[0]) + + masks = entity_masks(observed, train=rows, validation=np.array([], dtype=int), test=rows, axis=0) + + assert not (masks.train.mask & ~observed).any() + + def test_an_empty_index_set_yields_an_empty_mask(self, observed): + masks = entity_masks( + observed, + train=np.array([0]), + validation=np.array([], dtype=int), + test=np.array([1]), + axis=0, + ) + + assert not masks.val.mask.any() + + +class TestPairMasks: + def test_selects_exactly_the_requested_positions(self, observed): + rows, columns = np.where(observed) + + masks = pair_masks( + observed.shape, + rows, + columns, + train=np.array([0, 1]), + validation=np.array([2]), + test=np.array([3]), + ) + + assert masks.train.mask.sum() == 2 + assert masks.val.mask[rows[2], columns[2]] + assert masks.test.mask[rows[3], columns[3]] + + def test_the_three_masks_do_not_overlap(self, observed): + rows, columns = np.where(observed) + + masks = pair_masks( + observed.shape, + rows, + columns, + train=np.arange(5), + validation=np.arange(5, 7), + test=np.arange(7, 10), + ) + + assert not (masks.train.mask & masks.test.mask).any() + assert not (masks.train.mask & masks.val.mask).any() + + def test_an_empty_position_set_yields_an_empty_mask(self, observed): + rows, columns = np.where(observed) + + masks = pair_masks( + observed.shape, + rows, + columns, + train=np.arange(3), + validation=np.array([], dtype=int), + test=np.arange(3, 5), + ) + + assert not masks.val.mask.any() + + def test_masks_have_the_response_shape(self, observed): + rows, columns = np.where(observed) + + masks = pair_masks( + observed.shape, + rows, + columns, + train=np.arange(2), + validation=np.array([2]), + test=np.array([3]), + ) + + assert masks.train.mask.shape == observed.shape + + +class TestRowsWithLabels: + def test_returns_the_rows_carrying_a_selected_label(self): + labels = np.array(["a", "b", "a", "c"]) + + np.testing.assert_array_equal(rows_with_labels(labels, np.array(["a"])), [0, 2]) + + def test_accepts_several_labels(self): + labels = np.array(["a", "b", "a", "c"]) + + np.testing.assert_array_equal(rows_with_labels(labels, np.array(["b", "c"])), [1, 3]) + + def test_no_selected_label_yields_no_rows(self): + labels = np.array(["a", "b"]) + + assert rows_with_labels(labels, np.array([], dtype=labels.dtype)).size == 0 + + def test_an_unknown_label_selects_nothing(self): + labels = np.array(["a", "b"]) + + assert rows_with_labels(labels, np.array(["z"])).size == 0 diff --git a/tests/data/splitters/test_init.py b/tests/data/splitters/test_init.py new file mode 100644 index 000000000..4966f92c8 --- /dev/null +++ b/tests/data/splitters/test_init.py @@ -0,0 +1,89 @@ +"""Tests for the splitter registry surface and cross-mode fold validation. + +The individual splitters live in ``test_lpo.py`` / ``test_lco.py`` / +``test_ldo.py`` / ``test_lto.py``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.registry.splitter import ( + SplitValidationError, + splitter_registry, +) +from drevalpy.registry.splitter import get as get_splitter +from drevalpy.registry.splitter._validation import validate_folds +from drevalpy.types import SplitMask, SplitMasks +from tests.data.splitters._helpers import MockMuDataset, build_mask + + +class TestSplitterRegistry: + def test_builtin_modes_registered(self): + assert "LPO" in splitter_registry.modes + assert "LCO" in splitter_registry.modes + assert "LDO" in splitter_registry.modes + assert "LTO" in splitter_registry.modes + + def test_get_returns_callable(self): + splitter = splitter_registry.get("LPO") + assert callable(splitter) + + def test_get_unknown_raises(self): + with pytest.raises(ValueError, match="Unknown"): + splitter_registry.get("NONEXISTENT") + + def test_resolve_string(self): + splitter = splitter_registry.resolve("LCO") + assert callable(splitter) + + def test_resolve_callable_passthrough(self): + def my_fn(mudataset, n_splits=5, validation_ratio=0.1, random_state=42): + return [] + + assert splitter_registry.resolve(my_fn) is my_fn + + def test_get_splitter_alias(self): + assert get_splitter("LPO") is splitter_registry.get("LPO") + + def test_repr_shows_modes(self): + output = repr(splitter_registry) + assert "LPO" in output + assert "LCO" in output + + +class TestValidation: + def test_lco_valid_passes(self, mock_dataset: MockMuDataset): + folds = splitter_registry.get("LCO")(mock_dataset, n_splits=3) + validate_folds(folds, "LCO", mock_dataset) + + def test_lco_invalid_raises(self, mock_dataset: MockMuDataset): + shape = mock_dataset.response_matrix.shape + bad_fold = SplitMasks( + train=build_mask(shape, (0, 0), (1, 1)), + test=build_mask(shape, (0, 2)), + val=SplitMask(np.zeros(shape, dtype=bool)), + ) + with pytest.raises(SplitValidationError, match="LCO"): + validate_folds([bad_fold], "LCO", mock_dataset) + + def test_ldo_invalid_raises(self, mock_dataset: MockMuDataset): + shape = mock_dataset.response_matrix.shape + bad_fold = SplitMasks( + train=build_mask(shape, (0, 0), (1, 0)), + test=build_mask(shape, (2, 0)), + val=SplitMask(np.zeros(shape, dtype=bool)), + ) + with pytest.raises(SplitValidationError, match="LDO"): + validate_folds([bad_fold], "LDO", mock_dataset) + + def test_lpo_invalid_raises(self, mock_dataset: MockMuDataset): + shape = mock_dataset.response_matrix.shape + bad_fold = SplitMasks( + train=build_mask(shape, (0, 0), (1, 1)), + test=build_mask(shape, (0, 0)), + val=SplitMask(np.zeros(shape, dtype=bool)), + ) + with pytest.raises(SplitValidationError, match="LPO"): + validate_folds([bad_fold], "LPO", mock_dataset) diff --git a/tests/data/splitters/test_lco.py b/tests/data/splitters/test_lco.py new file mode 100644 index 000000000..aafca28b2 --- /dev/null +++ b/tests/data/splitters/test_lco.py @@ -0,0 +1,44 @@ +"""Tests for the leave-cell-line-out splitter.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.registry.splitter import splitter_registry +from tests.data.splitters._helpers import MockMuDataset, covered_pairs, first_measured_pairs + + +class TestLeaveCellLineOut: + def test_produces_folds(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LCO") + folds = splitter(mock_dataset, n_splits=3) + assert len(folds) == 3 + + def test_no_cell_line_in_both_train_and_test(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LCO") + folds = splitter(mock_dataset, n_splits=3) + for fold in folds: + train_rows = set(np.where(fold.train.mask.any(axis=1))[0].tolist()) + test_rows = set(np.where(fold.test.mask.any(axis=1))[0].tolist()) + assert train_rows & test_rows == set() + + def test_all_indices_within_bounds(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LCO") + folds = splitter(mock_dataset, n_splits=3) + shape = mock_dataset.response_matrix.shape + for fold in folds: + assert fold.train.shape == shape + assert fold.test.shape == shape + assert fold.val.shape == shape + + def test_low_quality_pairs_appear_in_no_fold(self, mock_dataset: MockMuDataset) -> None: + """Measured pairs whose curve fails the thresholds are never split into.""" + failing = first_measured_pairs(mock_dataset, 3) + splitter = splitter_registry.get("LCO") + + without_filtering = covered_pairs(splitter(mock_dataset, n_splits=3)) + with_filtering = covered_pairs(splitter(MockMuDataset(failing_pairs=failing), n_splits=3)) + + for row, column in failing: + assert without_filtering[row, column] + assert not with_filtering[row, column] diff --git a/tests/data/splitters/test_ldo.py b/tests/data/splitters/test_ldo.py new file mode 100644 index 000000000..2c7a799b6 --- /dev/null +++ b/tests/data/splitters/test_ldo.py @@ -0,0 +1,35 @@ +"""Tests for the leave-drug-out splitter.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.registry.splitter import splitter_registry +from tests.data.splitters._helpers import MockMuDataset, covered_pairs, first_measured_pairs + + +class TestLeaveDrugOut: + def test_produces_folds(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LDO") + folds = splitter(mock_dataset, n_splits=3) + assert len(folds) == 3 + + def test_no_drug_in_both_train_and_test(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LDO") + folds = splitter(mock_dataset, n_splits=3) + for fold in folds: + train_cols = set(np.where(fold.train.mask.any(axis=0))[0].tolist()) + test_cols = set(np.where(fold.test.mask.any(axis=0))[0].tolist()) + assert train_cols & test_cols == set() + + def test_low_quality_pairs_appear_in_no_fold(self, mock_dataset: MockMuDataset) -> None: + """Measured pairs whose curve fails the thresholds are never split into.""" + failing = first_measured_pairs(mock_dataset, 3) + splitter = splitter_registry.get("LDO") + + without_filtering = covered_pairs(splitter(mock_dataset, n_splits=3)) + with_filtering = covered_pairs(splitter(MockMuDataset(failing_pairs=failing), n_splits=3)) + + for row, column in failing: + assert without_filtering[row, column] + assert not with_filtering[row, column] diff --git a/tests/data/splitters/test_lpo.py b/tests/data/splitters/test_lpo.py new file mode 100644 index 000000000..761f2340a --- /dev/null +++ b/tests/data/splitters/test_lpo.py @@ -0,0 +1,53 @@ +"""Tests for the leave-pair-out splitter.""" + +from __future__ import annotations + +from drevalpy.registry.splitter import splitter_registry +from tests.data.splitters._helpers import MockMuDataset, covered_pairs, first_measured_pairs + + +class TestLeavePairOut: + def test_produces_correct_number_of_folds(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LPO") + folds = splitter(mock_dataset, n_splits=3) + assert len(folds) == 3 + + def test_all_folds_are_2d_bool(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LPO") + folds = splitter(mock_dataset, n_splits=3) + shape = mock_dataset.response_matrix.shape + for fold in folds: + assert fold.train.shape == shape + assert fold.test.shape == shape + assert fold.val.shape == shape + assert fold.train.mask.dtype == bool + assert fold.test.mask.dtype == bool + assert fold.val.mask.dtype == bool + + def test_no_pair_in_both_train_and_test(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LPO") + folds = splitter(mock_dataset, n_splits=3) + for fold in folds: + assert not (fold.train & fold.test).any() + + def test_metadata_injected(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LPO") + folds = splitter(mock_dataset, n_splits=3) + for i, fold in enumerate(folds): + assert fold.metadata["mode"] == "LPO" + assert fold.metadata["fold_index"] == i + assert fold.metadata["n_splits"] == 3 + + def test_low_quality_pairs_appear_in_no_fold(self, mock_dataset: MockMuDataset) -> None: + """Measured pairs whose curve fails the thresholds are never split into.""" + failing = first_measured_pairs(mock_dataset, 3) + splitter = splitter_registry.get("LPO") + + without_filtering = covered_pairs(splitter(mock_dataset, n_splits=3)) + with_filtering = covered_pairs(splitter(MockMuDataset(failing_pairs=failing), n_splits=3)) + + for row, column in failing: + # Present when every curve passes, gone once it does not: the + # absence is the filter's doing, not an artefact of the fixture. + assert without_filtering[row, column] + assert not with_filtering[row, column] diff --git a/tests/data/splitters/test_lto.py b/tests/data/splitters/test_lto.py new file mode 100644 index 000000000..c1fc731e3 --- /dev/null +++ b/tests/data/splitters/test_lto.py @@ -0,0 +1,38 @@ +"""Tests for the leave-tissue-out splitter.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.registry.splitter import splitter_registry +from tests.data.splitters._helpers import MockMuDataset, covered_pairs, first_measured_pairs + + +class TestLeaveTissueOut: + def test_produces_folds(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LTO") + folds = splitter(mock_dataset, n_splits=3) + assert len(folds) == 3 + + def test_no_tissue_in_both_train_and_test(self, mock_dataset: MockMuDataset) -> None: + splitter = splitter_registry.get("LTO") + folds = splitter(mock_dataset, n_splits=3) + tissues = mock_dataset.get_tissue(mock_dataset.cell_line_ids) + for fold in folds: + train_rows = np.where(fold.train.mask.any(axis=1))[0] + test_rows = np.where(fold.test.mask.any(axis=1))[0] + train_tissues = set(tissues[train_rows].tolist()) + test_tissues = set(tissues[test_rows].tolist()) + assert train_tissues & test_tissues == set() + + def test_low_quality_pairs_appear_in_no_fold(self, mock_dataset: MockMuDataset) -> None: + """Measured pairs whose curve fails the thresholds are never split into.""" + failing = first_measured_pairs(mock_dataset, 3) + splitter = splitter_registry.get("LTO") + + without_filtering = covered_pairs(splitter(mock_dataset, n_splits=3)) + with_filtering = covered_pairs(splitter(MockMuDataset(failing_pairs=failing), n_splits=3)) + + for row, column in failing: + assert without_filtering[row, column] + assert not with_filtering[row, column] diff --git a/tests/data/test_artifacts.py b/tests/data/test_artifacts.py new file mode 100644 index 000000000..d68ecd742 --- /dev/null +++ b/tests/data/test_artifacts.py @@ -0,0 +1,126 @@ +"""Tests for artifact location resolution and local caching.""" + +from __future__ import annotations + +import pytest +from upath import UPath + +from drevalpy.data.artifacts import ( + _DEFAULT_ARTIFACTS_URI, + get_artifact, + get_artifact_dir, + get_artifacts_storage_options, + get_artifacts_uri, +) + + +@pytest.fixture(autouse=True) +def _isolate_env(monkeypatch, tmp_path): + """Point cache and artifact env vars at temporary locations.""" + monkeypatch.setenv("DREVALPY_CACHE_DIR", str(tmp_path / "cache")) + monkeypatch.delenv("DREVALPY_ARTIFACTS_URI", raising=False) + monkeypatch.delenv("DREVALPY_ARTIFACTS_STORAGE_OPTIONS", raising=False) + + +class TestArtifactsUri: + """Resolution of the artifacts base URI.""" + + def test_defaults_to_bundled_bucket(self): + assert get_artifacts_uri() == _DEFAULT_ARTIFACTS_URI + + def test_env_var_overrides(self, monkeypatch): + monkeypatch.setenv("DREVALPY_ARTIFACTS_URI", "s3://mirror/artifacts/") + assert get_artifacts_uri() == "s3://mirror/artifacts/" + + def test_blank_env_var_falls_back(self, monkeypatch): + monkeypatch.setenv("DREVALPY_ARTIFACTS_URI", " ") + assert get_artifacts_uri() == _DEFAULT_ARTIFACTS_URI + + +class TestArtifactsStorageOptions: + """Resolution of fsspec storage options.""" + + def test_empty_by_default(self): + """No hardcoded credentials, so the ambient credential chain applies.""" + assert get_artifacts_storage_options() == {} + + def test_parses_json_object(self, monkeypatch): + monkeypatch.setenv("DREVALPY_ARTIFACTS_STORAGE_OPTIONS", '{"profile": "dev", "anon": false}') + assert get_artifacts_storage_options() == {"profile": "dev", "anon": False} + + def test_invalid_json_is_ignored(self, monkeypatch): + monkeypatch.setenv("DREVALPY_ARTIFACTS_STORAGE_OPTIONS", "{nope") + assert get_artifacts_storage_options() == {} + + def test_non_object_json_is_ignored(self, monkeypatch): + monkeypatch.setenv("DREVALPY_ARTIFACTS_STORAGE_OPTIONS", '["a", "b"]') + assert get_artifacts_storage_options() == {} + + +class TestGetArtifact: + """Downloading and caching of single-file artifacts.""" + + @pytest.fixture + def remote(self, tmp_path, monkeypatch): + """Serve artifacts from a local directory instead of S3.""" + remote_dir = tmp_path / "remote" + remote_dir.mkdir() + monkeypatch.setenv("DREVALPY_ARTIFACTS_URI", f"{remote_dir}/") + return remote_dir + + def test_downloads_file(self, remote): + (remote / "weights.bin").write_bytes(b"payload") + local = get_artifact("weights.bin") + assert local.read_bytes() == b"payload" + assert local.name == "weights.bin" + + def test_reuses_cached_file(self, remote): + (remote / "weights.bin").write_bytes(b"payload") + first = get_artifact("weights.bin") + (remote / "weights.bin").unlink() + assert get_artifact("weights.bin").read_bytes() == first.read_bytes() + + def test_leaves_no_partial_files(self, remote): + (remote / "weights.bin").write_bytes(b"payload") + local = get_artifact("weights.bin") + assert not list(UPath(local.parent).glob("*.part")) + + def test_missing_artifact_raises(self, remote): + with pytest.raises(FileNotFoundError): + get_artifact("absent.bin") + + +class TestGetArtifactDir: + """Downloading and caching of multi-file artifacts.""" + + @pytest.fixture + def remote(self, tmp_path, monkeypatch): + """Serve a two-file artifact directory from a local directory.""" + remote_dir = tmp_path / "remote" / "model" + remote_dir.mkdir(parents=True) + (remote_dir / "config.json").write_text("{}") + (remote_dir / "weights.bin").write_bytes(b"payload") + monkeypatch.setenv("DREVALPY_ARTIFACTS_URI", f"{remote_dir.parent}/") + return remote_dir + + def test_downloads_all_files(self, remote): + local = get_artifact_dir("model", ("config.json", "weights.bin")) + assert (local / "config.json").read_text() == "{}" + assert (local / "weights.bin").read_bytes() == b"payload" + + def test_reuses_complete_cache(self, remote): + get_artifact_dir("model", ("config.json", "weights.bin")) + (remote / "weights.bin").unlink() + local = get_artifact_dir("model", ("config.json", "weights.bin")) + assert (local / "weights.bin").read_bytes() == b"payload" + + def test_incomplete_cache_is_refetched(self, remote): + local = get_artifact_dir("model", ("config.json", "weights.bin")) + (local / "weights.bin").unlink() + refetched = get_artifact_dir("model", ("config.json", "weights.bin")) + assert (refetched / "weights.bin").read_bytes() == b"payload" + + def test_only_requested_files_are_fetched(self, remote): + local = get_artifact_dir("model", ("config.json",)) + assert (local / "config.json").exists() + assert not (local / "weights.bin").exists() diff --git a/tests/data/test_init.py b/tests/data/test_init.py new file mode 100644 index 000000000..731dd209a --- /dev/null +++ b/tests/data/test_init.py @@ -0,0 +1,55 @@ +"""Tests for the public :mod:`drevalpy.data` package surface. + +``drevalpy.data`` reaches the dataset and splitter registry singletons through a +module-level ``__getattr__`` rather than a top-level import, because +``drevalpy.registry`` imports back into ``drevalpy.data`` during built-in +registration. That indirection is part of the surface: a rename behind it would +turn into a runtime ``AttributeError`` at the call site instead of an +``ImportError`` here, so both the resolving path and the unknown-name path are +pinned - the first by the shared origin table below, the second by +:func:`test_unknown_name_raises_rather_than_returning_none`. + +Only the surface is asserted. ``split``'s fold hashing and ``load``'s dataset +resolution are tested in ``tests/data/splitters/`` and +``tests/data/datasets/`` respectively. +""" + +from __future__ import annotations + +import pytest + +from drevalpy import data +from tests._barrel_surface import DeclaredSurface + +#: Names bound at import time -> a module holding the same object. The barrel +#: imports ``load`` from ``datasets._load``, so it is recorded against the +#: ``datasets`` barrel instead: a re-export compared with the module it was +#: imported from cannot fail. +EAGER_ORIGINS: dict[str, str] = { + "curve_quality_mask": "drevalpy.data.quality", + "load": "drevalpy.data.datasets", +} + +#: Names served by the module-level ``__getattr__`` -> the leaf module that +#: constructs the singleton, not the registry barrel ``__getattr__`` imports from. +LAZY_ORIGINS: dict[str, str] = { + "dataset_registry": "drevalpy.registry.dataset._registry", + "splitter_registry": "drevalpy.registry.splitter._registry", +} + + +class TestDataSurface(DeclaredSurface): + barrel = data + origins = {**EAGER_ORIGINS, **LAZY_ORIGINS} + unpinned_names = ("split",) + + +def test_split_is_defined_by_the_barrel_itself() -> None: + assert callable(data.split) + assert data.split.__module__ == "drevalpy.data" + + +def test_unknown_name_raises_rather_than_returning_none() -> None: + """A module-level ``__getattr__`` must not turn a typo into a silent ``None``.""" + with pytest.raises(AttributeError, match="drevalpy.data"): + getattr(data, "definitely_not_a_real_symbol") # noqa: B009 diff --git a/tests/data/test_paths.py b/tests/data/test_paths.py new file mode 100644 index 000000000..90b129640 --- /dev/null +++ b/tests/data/test_paths.py @@ -0,0 +1,35 @@ +"""Tests for drevalpy.data._paths cache directory resolution.""" + +from pathlib import Path + +from drevalpy.data._paths import get_default_data_dir + + +def test_env_var_override(monkeypatch, tmp_path): + """DREVALPY_CACHE_DIR should be used verbatim when set.""" + custom = str(tmp_path / "custom_cache") + monkeypatch.setenv("DREVALPY_CACHE_DIR", custom) + assert get_default_data_dir() == Path(custom) + + +def test_env_var_whitespace_stripped(monkeypatch, tmp_path): + """Surrounding whitespace in the env var should be stripped.""" + custom = str(tmp_path / "custom_cache") + monkeypatch.setenv("DREVALPY_CACHE_DIR", f" {custom} ") + assert get_default_data_dir() == Path(custom) + + +def test_empty_env_var_uses_platformdirs(monkeypatch): + """An empty (but set) env var should fall back to platformdirs.""" + monkeypatch.setenv("DREVALPY_CACHE_DIR", "") + from platformdirs import user_cache_dir + + assert get_default_data_dir() == Path(user_cache_dir("drevalpy")) + + +def test_unset_env_var_uses_platformdirs(monkeypatch): + """An unset env var should fall back to platformdirs.""" + monkeypatch.delenv("DREVALPY_CACHE_DIR", raising=False) + from platformdirs import user_cache_dir + + assert get_default_data_dir() == Path(user_cache_dir("drevalpy")) diff --git a/tests/data/test_quality.py b/tests/data/test_quality.py new file mode 100644 index 000000000..cfcc9bfd4 --- /dev/null +++ b/tests/data/test_quality.py @@ -0,0 +1,418 @@ +"""Tests for :mod:`drevalpy.data.quality`. + +The threshold checks are parametrized over :data:`~drevalpy.data.quality._RULES` +rather than written out one per option. That is deliberate: it means a new rule +cannot be added to the table without inheriting a boundary test and an +off-by-default test, so the fifteen options cannot drift apart in coverage. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.curation._anndata import _REGULATION_ENCODING +from drevalpy.data.quality import _RESPONSE_MATRIX, _RULES, curve_quality_mask + +_SHAPE = (2, 3) + +#: A metric value that passes each rule comfortably, per option. +_PASSING: dict[str, float] = { + "min_relevance_score": 9.0, + "min_abs_fold_change": -2.0, + "max_p_value": 1e-9, + "min_log_p_value": 9.0, + "min_f_value": 400.0, + "min_f_value_sam": 80.0, + "min_r2": 0.99, + "max_rmse": 0.02, + "min_signal_quality": 1.0, + "min_abs_slope": 3.0, + "max_abs_slope": 3.0, + "min_front": 1.0, + "max_back": 0.05, + "min_pec50": 6.0, + "max_pec50": 6.0, +} + +#: A threshold that the matching :data:`_PASSING` value satisfies, and a second +#: one it fails, for every option. The passing threshold is set to exactly the +#: value under test wherever the comparison allows, which pins inclusivity. +_THRESHOLDS: dict[str, tuple[float, float]] = { + # (satisfied, violated) + "min_relevance_score": (9.0, 9.5), + "min_abs_fold_change": (2.0, 2.5), + "max_p_value": (1e-9, 1e-12), + "min_log_p_value": (9.0, 9.5), + "min_f_value": (400.0, 500.0), + "min_f_value_sam": (80.0, 100.0), + "min_r2": (0.99, 0.995), + "max_rmse": (0.02, 0.01), + "min_signal_quality": (1.0, 1.5), + "min_abs_slope": (3.0, 3.5), + "max_abs_slope": (3.0, 2.5), + "min_front": (1.0, 1.5), + "max_back": (0.05, 0.01), + "min_pec50": (6.0, 6.5), + "max_pec50": (6.0, 5.5), +} + + +class FakeDataset: + """Minimal ``MuDataLike`` whose layers are set per test. + + Only the members the quality filter touches are implemented; the splitters' + axis accessors are irrelevant here. + """ + + def __init__(self, layers: dict[str, np.ndarray], response: np.ndarray | None = None) -> None: + """Store the layers and the response matrix backing ``pEC50``.""" + self._layers = layers + self._response = response if response is not None else np.full(_SHAPE, 6.0) + + @property + def cell_line_ids(self) -> np.ndarray: + """Row identifiers.""" + return np.array([f"CL_{i}" for i in range(_SHAPE[0])]) + + @property + def drug_ids(self) -> np.ndarray: + """Column identifiers.""" + return np.array([f"D_{i}" for i in range(_SHAPE[1])]) + + @property + def response_matrix(self) -> np.ndarray: + """Response matrix, which is where ``pEC50`` lives.""" + return self._response + + def get_tissue(self, ids: np.ndarray) -> np.ndarray: + """One tissue for every cell line.""" + return np.array(["Lung"] * len(ids)) + + def response_layer_names(self) -> list[str]: + """Names of the layers this fake carries.""" + return list(self._layers) + + def get_response_layer(self, name: str) -> np.ndarray: + """Return a layer, raising like ``Dataset`` does when it is absent.""" + if name not in self._layers: + raise KeyError(f"Response layer '{name}' not found. Available: {list(self._layers)}") + return self._layers[name] + + +def _dataset_for(option: str, value: float) -> FakeDataset: + """Build a dataset where the layer *option* reads carries *value* throughout.""" + layer, _comparison, _transform = _RULES[option] + matrix = np.full(_SHAPE, value) + if layer == _RESPONSE_MATRIX: + return FakeDataset({}, response=matrix) + return FakeDataset({layer: matrix}) + + +def _only(option: str, threshold: float) -> dict[str, float | None]: + """Request exactly one check, disabling the two that are on by default.""" + return {"min_relevance_score": None, "min_abs_fold_change": None, option: threshold} + + +def _passing_dataset() -> FakeDataset: + """A dataset that passes every rule in the table.""" + layers: dict[str, np.ndarray] = {} + for option, value in _PASSING.items(): + layer, _comparison, _transform = _RULES[option] + if layer != _RESPONSE_MATRIX: + layers[layer] = np.full(_SHAPE, value) + return FakeDataset(layers) + + +class TestEveryOptionIsWiredUp: + """One boundary and one off-by-default case per entry in ``_RULES``.""" + + def test_the_table_and_the_expectations_cover_the_same_options(self) -> None: + """Guards the guard: a new rule must arrive with its test values.""" + assert sorted(_RULES) == sorted(_PASSING) + assert sorted(_RULES) == sorted(_THRESHOLDS) + + @pytest.mark.parametrize("option", sorted(_RULES)) + def test_a_satisfied_threshold_keeps_every_pair(self, option: str) -> None: + satisfied, _violated = _THRESHOLDS[option] + dataset = _dataset_for(option, _PASSING[option]) + + assert curve_quality_mask(dataset, **_only(option, satisfied)).all() + + @pytest.mark.parametrize("option", sorted(_RULES)) + def test_a_violated_threshold_drops_every_pair(self, option: str) -> None: + _satisfied, violated = _THRESHOLDS[option] + dataset = _dataset_for(option, _PASSING[option]) + + assert not curve_quality_mask(dataset, **_only(option, violated)).any() + + @pytest.mark.parametrize("option", sorted(_RULES)) + def test_the_threshold_is_inclusive(self, option: str) -> None: + """``>=``/``<=``, never ``>``/``<``: a value exactly at the cut passes.""" + satisfied, _violated = _THRESHOLDS[option] + _layer, _comparison, transform = _RULES[option] + # The satisfied threshold equals the metric under test, modulo the + # absolute-value transform, so this is the boundary itself. + value = _PASSING[option] + expected = abs(value) if transform is not None else value + assert satisfied == pytest.approx(expected) + + dataset = _dataset_for(option, value) + assert curve_quality_mask(dataset, **_only(option, satisfied)).all() + + @pytest.mark.parametrize("option", sorted(_RULES)) + def test_an_option_left_at_none_is_not_checked(self, option: str) -> None: + """A metric bad enough to fail its own rule is ignored while it is off.""" + _satisfied, violated = _THRESHOLDS[option] + dataset = _dataset_for(option, _PASSING[option]) + + # Sanity: this threshold really would reject every pair if requested. + assert not curve_quality_mask(dataset, **_only(option, violated)).any() + + assert curve_quality_mask( + dataset, + min_relevance_score=None, + min_abs_fold_change=None, + ).all() + + +class TestDefaults: + def test_the_default_rule_uses_relevance_score_and_fold_change(self) -> None: + """Nothing else may be consulted by default, or unrelated data breaks.""" + dataset = FakeDataset( + { + "relevance_score": np.full(_SHAPE, 9.0), + "fold_change": np.full(_SHAPE, -2.0), + } + ) + + assert curve_quality_mask(dataset).all() + + def test_the_default_relevance_threshold_is_minus_log10_alpha(self) -> None: + """``alpha = 0.05`` in ``drevalpy/curation/_fit.py``.""" + cut = -np.log10(0.05) + dataset = FakeDataset( + { + "relevance_score": np.array([[cut, cut * 0.999, 0.0]] * 2), + "fold_change": np.full(_SHAPE, -2.0), + } + ) + + mask = curve_quality_mask(dataset) + + assert mask[:, 0].all() + assert not mask[:, 1].any() + assert not mask[:, 2].any() + + def test_the_default_fold_change_threshold_is_fc_lim(self) -> None: + """``fc_lim = 0.45``, applied to the magnitude of an already-log2 layer.""" + dataset = FakeDataset( + { + "relevance_score": np.full(_SHAPE, 9.0), + "fold_change": np.array([[0.45, -0.45, 0.44]] * 2), + } + ) + + mask = curve_quality_mask(dataset) + + assert mask[:, 0].all() + assert mask[:, 1].all() + assert not mask[:, 2].any() + + +class TestNanFailsClosed: + @pytest.mark.parametrize("option", sorted(_RULES)) + def test_a_nan_metric_never_passes(self, option: str) -> None: + """A curve CurveCurator could not score is not a curve worth keeping.""" + satisfied, _violated = _THRESHOLDS[option] + dataset = _dataset_for(option, np.nan) + + assert not curve_quality_mask(dataset, **_only(option, satisfied)).any() + + def test_a_nan_in_one_metric_does_not_condemn_the_others(self) -> None: + dataset = FakeDataset( + { + "relevance_score": np.array([[9.0, np.nan, 9.0]] * 2), + "fold_change": np.full(_SHAPE, -2.0), + } + ) + + mask = curve_quality_mask(dataset) + + assert mask[:, 0].all() + assert not mask[:, 1].any() + assert mask[:, 2].all() + + +class TestCombiningOptions: + def test_two_options_are_anded(self) -> None: + dataset = FakeDataset( + { + "relevance_score": np.full(_SHAPE, 9.0), + "fold_change": np.full(_SHAPE, -2.0), + "R2": np.array([[0.99, 0.5, 0.99]] * 2), + } + ) + + mask = curve_quality_mask(dataset, min_r2=0.9) + + # Column 1 passes the default rule but fails the added R2 floor. + assert mask[:, 0].all() + assert not mask[:, 1].any() + assert mask[:, 2].all() + + def test_every_option_at_once_still_keeps_a_passing_dataset(self) -> None: + dataset = _passing_dataset() + options = {option: _THRESHOLDS[option][0] for option in _RULES} + + assert curve_quality_mask(dataset, **options).all() + + def test_disabling_everything_keeps_every_pair(self) -> None: + """Including pairs whose metrics are missing entirely.""" + dataset = FakeDataset({}) + + mask = curve_quality_mask(dataset, min_relevance_score=None, min_abs_fold_change=None) + + assert mask.shape == _SHAPE + assert mask.all() + + +class TestMissingLayers: + def test_a_requested_layer_that_is_absent_raises(self) -> None: + """No capability check and no silent fallback: the format guarantees it.""" + dataset = FakeDataset({"relevance_score": np.full(_SHAPE, 9.0)}) + + with pytest.raises(KeyError, match="fold_change"): + curve_quality_mask(dataset) + + def test_an_unused_layer_may_be_absent(self) -> None: + dataset = FakeDataset( + { + "relevance_score": np.full(_SHAPE, 9.0), + "fold_change": np.full(_SHAPE, -2.0), + } + ) + + assert curve_quality_mask(dataset).all() + + +class TestRegulation: + def _dataset(self) -> FakeDataset: + return FakeDataset( + { + "relevance_score": np.full(_SHAPE, 9.0), + "fold_change": np.full(_SHAPE, -2.0), + "regulation": np.array( + [ + [_REGULATION_ENCODING["up"], _REGULATION_ENCODING["down"], _REGULATION_ENCODING["not"]], + [np.nan, _REGULATION_ENCODING["down"], _REGULATION_ENCODING["not"]], + ], + dtype=float, + ), + } + ) + + @pytest.mark.parametrize( + ("labels", "expected"), + [ + (["up"], [[True, False, False], [False, False, False]]), + (["down"], [[False, True, False], [False, True, False]]), + (["not"], [[False, False, True], [False, False, True]]), + (["up", "down"], [[True, True, False], [False, True, False]]), + ], + ) + def test_labels_select_the_matching_pairs(self, labels: list[str], expected: list[list[bool]]) -> None: + mask = curve_quality_mask( + self._dataset(), + min_relevance_score=None, + min_abs_fold_change=None, + regulation=labels, + ) + + assert mask.tolist() == expected + + def test_an_undetermined_curve_is_in_no_category(self) -> None: + """NaN regulation means CurveCurator reached no verdict, so it is dropped.""" + mask = curve_quality_mask( + self._dataset(), + min_relevance_score=None, + min_abs_fold_change=None, + regulation=["up", "down", "not"], + ) + + assert not mask[1, 0] + + def test_an_unknown_label_raises(self) -> None: + with pytest.raises(ValueError, match="sideways"): + curve_quality_mask(self._dataset(), regulation=["sideways"]) + + def test_the_error_lists_the_valid_labels(self) -> None: + with pytest.raises(ValueError, match="down.*not.*up"): + curve_quality_mask(self._dataset(), regulation=["bogus"]) + + def test_the_default_rule_reproduces_curvecurators_verdict(self) -> None: + """The whole reason the defaults are what they are. + + With ``quality_min = -inf`` and ``pEC50_filter = [-inf, inf]`` - the + resolved config in ``drevalpy/curation/_fit.py`` - CurveCurator's + ``regulation`` label reduces exactly to relevance-score-plus-fold-change, + so recomputing it must agree pair for pair. + """ + rng = np.random.default_rng(20260814) + shape = (16, 8) + relevance = rng.uniform(0.0, 5.0, size=shape) + fold_change = rng.uniform(-3.0, 1.0, size=shape) + + significant = relevance >= -np.log10(0.05) + large_effect = np.abs(fold_change) >= 0.45 + regulated = significant & large_effect + regulation = np.where(regulated, np.sign(fold_change), 0.0) + + dataset = FakeDataset( + { + "relevance_score": relevance, + "fold_change": fold_change, + "regulation": regulation, + }, + response=np.full(shape, 6.0), + ) + + mask = curve_quality_mask(dataset) + + assert mask.tolist() == regulated.tolist() + assert mask.tolist() == (regulation != 0).tolist() + + +class TestMaskShape: + def test_the_mask_matches_the_response_matrix(self) -> None: + dataset = _passing_dataset() + + mask = curve_quality_mask(dataset) + + assert mask.shape == dataset.response_matrix.shape + assert mask.dtype == np.bool_ + + def test_the_inverse_blanks_a_response_matrix_without_reshaping_it(self) -> None: + """The idiom every splitter uses.""" + dataset = FakeDataset( + { + "relevance_score": np.array([[9.0, 0.0, 9.0]] * 2), + "fold_change": np.full(_SHAPE, -2.0), + } + ) + + response = dataset.response_matrix.copy() + response[~curve_quality_mask(dataset)] = np.nan + + assert response.shape == _SHAPE + assert np.isnan(response[:, 1]).all() + assert not np.isnan(response[:, [0, 2]]).any() + + def test_the_dataset_is_not_mutated(self) -> None: + dataset = _passing_dataset() + before = {name: dataset.get_response_layer(name).copy() for name in dataset.response_layer_names()} + + curve_quality_mask(dataset) + + for name, values in before.items(): + assert np.array_equal(dataset.get_response_layer(name), values) diff --git a/tests/data/test_transfer.py b/tests/data/test_transfer.py new file mode 100644 index 000000000..71cb815a4 --- /dev/null +++ b/tests/data/test_transfer.py @@ -0,0 +1,170 @@ +"""Tests for streaming downloads through ``drevalpy.data._transfer``. + +The "remote" is a plain ``tmp_path`` directory: ``UPath`` resolves it through the +local fsspec filesystem, so the same ``fs.open`` / ``fs.size`` code path runs as +for S3 without any network access. +""" + +from __future__ import annotations + +import os + +import pytest +from upath import UPath + +from drevalpy.data._transfer import _CHUNK_SIZE, _progress, _stream, download_file, download_files + + +@pytest.fixture +def remote(tmp_path) -> UPath: + """Serve files from a local directory instead of S3.""" + remote_dir = UPath(tmp_path) / "remote" + remote_dir.mkdir() + return remote_dir + + +@pytest.fixture +def local(tmp_path) -> UPath: + """Destination directory, deliberately not created up front.""" + return UPath(tmp_path) / "local" + + +class TestDownloadFile: + """Single-file downloads.""" + + def test_copies_the_payload(self, remote: UPath, local: UPath) -> None: + (remote / "weights.bin").write_bytes(b"payload") + + result = download_file(remote / "weights.bin", local / "weights.bin", "weights") + + assert result.read_bytes() == b"payload" + + def test_returns_the_destination_path(self, remote: UPath, local: UPath) -> None: + (remote / "weights.bin").write_bytes(b"payload") + + result = download_file(remote / "weights.bin", local / "weights.bin", "weights") + + assert result == local / "weights.bin" + + def test_creates_missing_parent_directories(self, remote: UPath, local: UPath) -> None: + (remote / "weights.bin").write_bytes(b"payload") + + download_file(remote / "weights.bin", local / "nested" / "weights.bin", "weights") + + assert (local / "nested").is_dir() + + def test_leaves_no_partial_files(self, remote: UPath, local: UPath) -> None: + (remote / "weights.bin").write_bytes(b"payload") + + download_file(remote / "weights.bin", local / "weights.bin", "weights") + + assert not list(local.glob("*.part")) + + def test_copies_payloads_larger_than_one_chunk(self, remote: UPath, local: UPath) -> None: + payload = os.urandom(_CHUNK_SIZE + 17) + (remote / "weights.bin").write_bytes(payload) + + result = download_file(remote / "weights.bin", local / "weights.bin", "weights") + + assert result.read_bytes() == payload + + def test_missing_source_raises(self, remote: UPath, local: UPath) -> None: + with pytest.raises(FileNotFoundError): + download_file(remote / "absent.bin", local / "absent.bin", "absent") + + def test_missing_source_leaves_no_partial_file(self, remote: UPath, local: UPath) -> None: + with pytest.raises(FileNotFoundError): + download_file(remote / "absent.bin", local / "absent.bin", "absent") + + assert not list(local.glob("*.part")) + + +class TestDownloadFiles: + """Multi-file downloads into a shared directory.""" + + def test_copies_every_requested_file(self, remote: UPath, local: UPath) -> None: + (remote / "config.json").write_text("{}") + (remote / "weights.bin").write_bytes(b"payload") + + result = download_files(remote, local, "model", ("config.json", "weights.bin")) + + assert (result / "config.json").read_text() == "{}" + assert (result / "weights.bin").read_bytes() == b"payload" + + def test_skips_files_that_were_not_requested(self, remote: UPath, local: UPath) -> None: + (remote / "config.json").write_text("{}") + (remote / "weights.bin").write_bytes(b"payload") + + download_files(remote, local, "model", ("config.json",)) + + assert not (local / "weights.bin").exists() + + def test_creates_the_destination_directory(self, remote: UPath, local: UPath) -> None: + (remote / "config.json").write_text("{}") + + result = download_files(remote, local, "model", ("config.json",)) + + assert result.is_dir() + + def test_empty_filename_list_still_creates_the_directory(self, remote: UPath, local: UPath) -> None: + result = download_files(remote, local, "model", ()) + + assert result.is_dir() + assert list(result.iterdir()) == [] + + def test_leaves_no_partial_files(self, remote: UPath, local: UPath) -> None: + (remote / "config.json").write_text("{}") + (remote / "weights.bin").write_bytes(b"payload") + + download_files(remote, local, "model", ("config.json", "weights.bin")) + + assert not list(local.glob("*.part")) + + def test_a_missing_member_leaves_no_partial_file_behind(self, remote: UPath, local: UPath) -> None: + (remote / "config.json").write_text("{}") + + with pytest.raises(FileNotFoundError): + download_files(remote, local, "model", ("config.json", "absent.bin")) + + assert not list(local.glob("*.part")) + + +class TestStream: + """Staging behaviour of the private copy helper.""" + + def test_stages_through_a_pid_scoped_part_file(self, remote: UPath, local: UPath) -> None: + (remote / "weights.bin").write_bytes(b"payload") + local.mkdir(parents=True) + observed: list[str] = [] + + with _progress() as progress: + _stream(remote / "weights.bin", local / "weights.bin", "weights", progress) + observed.extend(path.name for path in local.iterdir()) + + assert observed == ["weights.bin"] + + def test_destination_only_appears_once_complete(self, remote: UPath, local: UPath, monkeypatch) -> None: + """``os.replace`` publishes the file, so a failed write leaves nothing.""" + (remote / "weights.bin").write_bytes(b"payload") + local.mkdir(parents=True) + + def _failing_replace(src: object, dst: object) -> None: + raise OSError("cross-device link") + + monkeypatch.setattr("drevalpy.data._transfer.os.replace", _failing_replace) + + with _progress() as progress, pytest.raises(OSError, match="cross-device link"): + _stream(remote / "weights.bin", local / "weights.bin", "weights", progress) + + assert not (local / "weights.bin").exists() + assert not list(local.glob("*.part")) + + def test_overwrites_an_existing_destination(self, remote: UPath, local: UPath) -> None: + (remote / "weights.bin").write_bytes(b"new") + local.mkdir(parents=True) + (local / "weights.bin").write_bytes(b"stale") + + with _progress() as progress: + _stream(remote / "weights.bin", local / "weights.bin", "weights", progress) + + assert (local / "weights.bin").read_bytes() == b"new" diff --git a/tests/data/test_utils.py b/tests/data/test_utils.py new file mode 100644 index 000000000..891caabed --- /dev/null +++ b/tests/data/test_utils.py @@ -0,0 +1,25 @@ +"""Tests for the dataset identifier constants.""" + +from __future__ import annotations + +from drevalpy.data.utils import ( + CELL_LINE_IDENTIFIER, + DRUG_IDENTIFIER, + TISSUE_IDENTIFIER, +) + + +class TestIdentifiers: + """The column names datasets are keyed by.""" + + def test_drug_identifier(self) -> None: + assert DRUG_IDENTIFIER == "pubchem_id" + + def test_cell_line_identifier(self) -> None: + assert CELL_LINE_IDENTIFIER == "cell_line_name" + + def test_tissue_identifier(self) -> None: + assert TISSUE_IDENTIFIER == "tissue" + + def test_identifiers_are_distinct(self) -> None: + assert len({DRUG_IDENTIFIER, CELL_LINE_IDENTIFIER, TISSUE_IDENTIFIER}) == 3 diff --git a/tests/datasets/split_helpers.py b/tests/datasets/split_helpers.py deleted file mode 100644 index c2621c417..000000000 --- a/tests/datasets/split_helpers.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Shared helpers for split provider tests.""" - -from __future__ import annotations - -import numpy as np - -from drevalpy.datasets.dataset import DrugResponseDataset - - -def sample_dataset(n_cell_lines: int = 6, n_drugs: int = 4) -> DrugResponseDataset: - """ - Build a small synthetic response dataset for split tests. - - :param n_cell_lines: number of distinct cell lines - :param n_drugs: number of distinct drugs per cell line block - :returns: synthetic ``DrugResponseDataset`` with tissue annotations - """ - cell_lines = np.repeat([f"CL-{i}" for i in range(n_cell_lines)], n_drugs) - drugs = np.tile([f"D-{i}" for i in range(n_drugs)], n_cell_lines) - tissues = np.repeat([f"T-{i % 3}" for i in range(n_cell_lines)], n_drugs) - return DrugResponseDataset( - response=np.random.default_rng(0).random(len(cell_lines)), - cell_line_ids=cell_lines, - drug_ids=drugs, - tissues=tissues, - dataset_name="testset", - ) - - -def role_from_groups( - dataset: DrugResponseDataset, - *, - train_groups: set[str], - val_groups: set[str], - test_groups: set[str], - group_col: str, -) -> dict[str, DrugResponseDataset]: - """ - Build one validated split dict from explicit train/validation/test groups. - - :param dataset: source dataset to subset - :param train_groups: group identifiers assigned to the train role - :param val_groups: group identifiers assigned to the validation role - :param test_groups: group identifiers assigned to the test role - :param group_col: grouping column, one of ``cell_line``, ``drug``, or ``tissue`` - :returns: split dict with ``train``, ``validation``, and ``test`` datasets - """ - if group_col == "cell_line": - groups = dataset.cell_line_ids - elif group_col == "drug": - groups = dataset.drug_ids - else: - assert dataset.tissue is not None - groups = dataset.tissue - - def subset(selected: set[str]) -> DrugResponseDataset: - mask = np.isin(groups, list(selected)) - return DrugResponseDataset( - response=dataset.response[mask], - cell_line_ids=dataset.cell_line_ids[mask], - drug_ids=dataset.drug_ids[mask], - tissues=dataset.tissue[mask] if dataset.tissue is not None else None, - dataset_name=dataset.dataset_name, - ) - - return { - "train": subset(train_groups), - "validation": subset(val_groups), - "test": subset(test_groups), - } diff --git a/tests/datasets/splits/test_manifest.py b/tests/datasets/splits/test_manifest.py deleted file mode 100644 index 982e1ae74..000000000 --- a/tests/datasets/splits/test_manifest.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Tests for drevalpy.datasets.splits.manifest.""" - -from __future__ import annotations - -import json -from pathlib import Path - -from drevalpy.datasets.splits import ( - MANIFEST_FILENAME, - SplitParams, - read_manifest_test_mode, - read_split_manifest, - write_split_manifest, -) - - -def _sample_params(**overrides: object) -> SplitParams: - defaults = { - "test_mode": "LCO", - "n_cv_splits": 2, - "validation_ratio": 0.1, - "random_state": 42, - "split_early_stopping": True, - } - defaults.update(overrides) - return SplitParams(**defaults) # type: ignore[arg-type] - - -def test_write_split_manifest(tmp_path: Path) -> None: - """ - Write split metadata to split_manifest.json. - - :param tmp_path: Temporary path provided by pytest. - """ - params = _sample_params() - write_split_manifest( - tmp_path, - params=params, - split_label="scaling-lco", - splits=[{"split_index": 0, "fraction": 0.5}], - ) - payload = json.loads((tmp_path / MANIFEST_FILENAME).read_text(encoding="utf-8")) - assert payload["split_label"] == "scaling-lco" - assert payload["test_mode"] == "LCO" - assert payload["n_cv_splits"] == 1 - assert payload["splits"][0]["fraction"] == 0.5 - assert "test_mode" not in payload["splits"][0] - - -def test_write_split_manifest_supports_nested_metadata(tmp_path: Path) -> None: - """ - Persist nested metadata from external split scripts. - - :param tmp_path: Temporary path provided by pytest. - """ - params = _sample_params() - write_split_manifest( - tmp_path, - params=params, - split_label="scaling-lco", - splits=[ - { - "split_index": 0, - "groups": {"train": ["CL-0"], "validation": ["CL-1"], "test": ["CL-2"]}, - } - ], - ) - payload = json.loads((tmp_path / MANIFEST_FILENAME).read_text(encoding="utf-8")) - assert payload["splits"][0]["groups"]["train"] == ["CL-0"] - - -def test_write_split_manifest_writes_test_mode_when_metadata_empty(tmp_path: Path) -> None: - """ - Write a minimal manifest when no split metadata is provided. - - :param tmp_path: Temporary path provided by pytest. - """ - params = _sample_params() - write_split_manifest(tmp_path, params=params, split_label="LCO", splits=[]) - manifest_path = tmp_path / MANIFEST_FILENAME - assert manifest_path.is_file() - assert read_manifest_test_mode(manifest_path) == "LCO" - payload = read_split_manifest(manifest_path) - assert payload is not None - assert payload["splits"] == [] - - -def test_read_split_manifest_returns_none_for_missing_file(tmp_path: Path) -> None: - """ - Return ``None`` when the manifest file does not exist. - - :param tmp_path: Temporary path provided by pytest. - """ - assert read_split_manifest(tmp_path / MANIFEST_FILENAME) is None diff --git a/tests/datasets/splits/test_providers.py b/tests/datasets/splits/test_providers.py deleted file mode 100644 index 43c48692f..000000000 --- a/tests/datasets/splits/test_providers.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Tests for drevalpy.datasets.splits.providers.""" - -from __future__ import annotations - -import os -import pickle -from pathlib import Path - -import pytest - -from drevalpy.datasets.splits import ( - MANIFEST_FILENAME, - SplitError, - SplitParams, - create_and_record_splits, - create_splits, - load_external_splitter, - read_split_manifest, - run_builtin_splitter, - run_external_splitter, - validate_split_label, -) -from tests.datasets.split_helpers import sample_dataset - - -def test_validate_split_label_rejects_path_separators() -> None: - """Reject split labels that contain path separators.""" - with pytest.raises(SplitError): - validate_split_label("scaling/lco") - - -def test_load_external_splitter_requires_create_splits(tmp_path: Path) -> None: - """ - Require a module-level create_splits function in external scripts. - - :param tmp_path: Temporary path provided by pytest. - """ - script = tmp_path / "bad.py" - script.write_text("def other():\n pass\n", encoding="utf-8") - with pytest.raises(AttributeError, match="create_splits"): - load_external_splitter(script) - - -def test_run_external_splitter_adds_early_stopping_roles(tmp_path: Path) -> None: - """ - Add early-stopping roles when running an external splitter script. - - :param tmp_path: Temporary path provided by pytest. - """ - script = tmp_path / "splitter.py" - script.write_text( - """ -import numpy as np -from drevalpy.datasets.dataset import DrugResponseDataset - -def create_splits(response_data, params): - mask_train = response_data.cell_line_ids == "CL-0" - mask_val = response_data.cell_line_ids == "CL-1" - mask_test = response_data.cell_line_ids == "CL-2" - def pick(mask): - return DrugResponseDataset( - response=response_data.response[mask], - cell_line_ids=response_data.cell_line_ids[mask], - drug_ids=response_data.drug_ids[mask], - tissues=response_data.tissue[mask], - dataset_name=response_data.dataset_name, - ) - return [{"train": pick(mask_train), "validation": pick(mask_val), "test": pick(mask_test)}] -""", - encoding="utf-8", - ) - dataset = sample_dataset(n_cell_lines=3, n_drugs=2) - params = SplitParams( - test_mode="LCO", - n_cv_splits=1, - validation_ratio=0.25, - random_state=7, - split_early_stopping=True, - ) - splits, metadata = run_external_splitter(dataset, script, params) - assert "validation_es" in splits[0] - assert "early_stopping" in splits[0] - assert metadata[0]["split_index"] == 0 - assert "test_mode" not in metadata[0] - - -def test_run_builtin_splitter_returns_split_level_metadata() -> None: - """Built-in split creation returns fold dicts and per-split metadata only.""" - dataset = sample_dataset(n_cell_lines=4, n_drugs=2) - params = SplitParams( - test_mode="LPO", - n_cv_splits=2, - validation_ratio=0.2, - random_state=11, - split_early_stopping=False, - ) - splits, metadata = run_builtin_splitter(dataset, params) - assert len(splits) == 2 - assert {"train", "validation", "test"}.issubset(splits[0]) - assert metadata[0] == {"split_index": 0} - assert metadata[1] == {"split_index": 1} - - -def test_create_splits_builtin_matches_run_builtin_splitter() -> None: - """Shared split provider uses built-in splitting when no external script is given.""" - dataset = sample_dataset(n_cell_lines=4, n_drugs=2) - splits, metadata = create_splits( - dataset, - test_mode="LPO", - n_cv_splits=2, - validation_ratio=0.2, - random_state=11, - split_early_stopping=False, - ) - assert len(splits) == 2 - assert metadata[0]["split_index"] == 0 - - -def test_create_and_record_splits_attaches_splits_and_writes_manifest(tmp_path: Path) -> None: - """ - Create splits, attach them to the dataset, and persist the manifest. - - :param tmp_path: Temporary path provided by pytest. - """ - dataset = sample_dataset(n_cell_lines=4, n_drugs=2) - splits, metadata = create_and_record_splits( - dataset, - split_path=tmp_path, - split_label="scaling-lco", - test_mode="LPO", - n_cv_splits=2, - validation_ratio=0.2, - random_state=11, - split_early_stopping=False, - ) - assert len(splits) == 2 - assert dataset.cv_splits is splits - assert metadata[0]["split_index"] == 0 - manifest = read_split_manifest(tmp_path / MANIFEST_FILENAME) - assert manifest is not None - assert manifest["split_label"] == "scaling-lco" - assert manifest["test_mode"] == "LPO" - - -def test_make_cv_pkls_with_builtin_splitter_writes_manifest(tmp_path: Path) -> None: - """ - Generate split pickle files and a manifest for built-in splitting. - - :param tmp_path: Temporary path provided by pytest. - """ - from drevalpy.cli_run_cv import run_cv_split - - dataset = sample_dataset(n_cell_lines=12, n_drugs=2) - response_pkl = tmp_path / "response.pkl" - with response_pkl.open("wb") as handle: - pickle.dump(dataset, handle) - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - run_cv_split( - response=str(response_pkl.name), - n_cv_splits=2, - test_mode="LCO", - validation_ratio=0.33, - ) - assert (tmp_path / "split_0.pkl").is_file() - manifest = read_split_manifest(tmp_path / MANIFEST_FILENAME) - assert manifest is not None - assert manifest["test_mode"] == "LCO" - assert manifest["split_label"] == "LCO" - assert manifest["n_cv_splits"] == 2 - finally: - os.chdir(cwd) - - -def test_make_cv_pkls_with_external_splitter(tmp_path: Path) -> None: - """ - Generate split pickle files from an external splitter via run_cv_split. - - :param tmp_path: Temporary path provided by pytest. - """ - from drevalpy.cli_run_cv import run_cv_split - - dataset = sample_dataset(n_cell_lines=4, n_drugs=2) - response_pkl = tmp_path / "response.pkl" - with response_pkl.open("wb") as handle: - pickle.dump(dataset, handle) - - script = tmp_path / "splitter.py" - script.write_text( - """ -from drevalpy.datasets.dataset import DrugResponseDataset - -def create_splits(response_data, params): - train = response_data.cell_line_ids == "CL-0" - val = response_data.cell_line_ids == "CL-1" - test = response_data.cell_line_ids == "CL-2" - def subset(mask): - return DrugResponseDataset( - response=response_data.response[mask], - cell_line_ids=response_data.cell_line_ids[mask], - drug_ids=response_data.drug_ids[mask], - tissues=response_data.tissue[mask], - dataset_name=response_data.dataset_name, - ) - return [ - {"train": subset(train), "validation": subset(val), "test": subset(test)}, - { - "train": subset(response_data.cell_line_ids == "CL-3"), - "validation": subset(val), - "test": subset(test), - }, - ] -""", - encoding="utf-8", - ) - - cwd = Path.cwd() - try: - os.chdir(tmp_path) - run_cv_split( - response=str(response_pkl.name), - n_cv_splits=2, - test_mode="LCO", - custom_splitter_path=str(script), - ) - assert (tmp_path / "split_0.pkl").is_file() - assert (tmp_path / "split_1.pkl").is_file() - with (tmp_path / "split_0.pkl").open("rb") as handle: - split = pickle.load(handle) - assert {"train", "validation", "test", "validation_es", "early_stopping"}.issubset(split) - finally: - os.chdir(cwd) - - -def test_run_external_splitter_forwards_params_to_script(tmp_path: Path) -> None: - """ - Forward SplitParams fields to create_splits scripts. - - :param tmp_path: Temporary path provided by pytest. - """ - script = tmp_path / "params_splitter.py" - script.write_text( - """ -from drevalpy.datasets.dataset import DrugResponseDataset - -def create_splits(response_data, params): - def pick(cell_line): - mask = response_data.cell_line_ids == cell_line - return DrugResponseDataset( - response=response_data.response[mask], - cell_line_ids=response_data.cell_line_ids[mask], - drug_ids=response_data.drug_ids[mask], - tissues=response_data.tissue[mask], - dataset_name=response_data.dataset_name, - ) - return [{ - "train": pick("CL-0"), - "validation": pick("CL-1"), - "test": pick("CL-2"), - "metadata": { - "random_state": params.random_state, - "validation_ratio": params.validation_ratio, - "n_cv_splits": params.n_cv_splits, - "test_mode": params.test_mode, - "split_early_stopping": params.split_early_stopping, - }, - }] -""", - encoding="utf-8", - ) - dataset = sample_dataset(n_cell_lines=3, n_drugs=2) - params = SplitParams( - test_mode="LCO", - n_cv_splits=3, - validation_ratio=0.2, - random_state=99, - split_early_stopping=False, - ) - splits, metadata = run_external_splitter(dataset, script, params) - assert metadata[0]["random_state"] == 99 - assert metadata[0]["validation_ratio"] == 0.2 - assert metadata[0]["n_cv_splits"] == 3 - assert metadata[0]["test_mode"] == "LCO" - assert metadata[0]["split_early_stopping"] is False - assert "validation_es" not in splits[0] - assert "early_stopping" not in splits[0] diff --git a/tests/datasets/splits/test_validation.py b/tests/datasets/splits/test_validation.py deleted file mode 100644 index ebebc4c98..000000000 --- a/tests/datasets/splits/test_validation.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Tests for drevalpy.datasets.splits.validation.""" - -from __future__ import annotations - -import numpy as np -import pytest - -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.datasets.splits import SplitError, validate_splits -from tests.datasets.split_helpers import role_from_groups, sample_dataset - - -def test_validate_lco_rejects_shared_cell_line() -> None: - """Reject LCO splits that share cell lines across roles.""" - dataset = sample_dataset() - split = role_from_groups( - dataset, - train_groups={"CL-0", "CL-1"}, - val_groups={"CL-1", "CL-2"}, - test_groups={"CL-3"}, - group_col="cell_line", - ) - with pytest.raises(SplitError, match="overlap|leakage"): - validate_splits([split], "LCO") - - -def test_validate_ldo_rejects_shared_drug() -> None: - """Reject LDO splits that share drugs across roles.""" - dataset = sample_dataset() - split = role_from_groups( - dataset, - train_groups={"D-0", "D-1"}, - val_groups={"D-1", "D-2"}, - test_groups={"D-3"}, - group_col="drug", - ) - with pytest.raises(SplitError, match="overlap|leakage"): - validate_splits([split], "LDO") - - -def test_validate_lto_requires_tissue_and_disjointness() -> None: - """Accept valid LTO splits with disjoint tissue groups.""" - dataset = sample_dataset() - split = role_from_groups( - dataset, - train_groups={"T-0"}, - val_groups={"T-1"}, - test_groups={"T-2"}, - group_col="tissue", - ) - validated, metadata = validate_splits([split], "LTO") - assert len(validated) == 1 - assert metadata[0]["split_index"] == 0 - - -def test_validate_lpo_rejects_shared_pair() -> None: - """Reject LPO splits that share cell-line/drug pairs across roles.""" - split = { - "train": DrugResponseDataset( - response=np.array([1.0]), - cell_line_ids=np.array(["CL-0"]), - drug_ids=np.array(["D-0"]), - dataset_name="testset", - ), - "validation": DrugResponseDataset( - response=np.array([2.0]), - cell_line_ids=np.array(["CL-0"]), - drug_ids=np.array(["D-0"]), - dataset_name="testset", - ), - "test": DrugResponseDataset( - response=np.array([3.0]), - cell_line_ids=np.array(["CL-1"]), - drug_ids=np.array(["D-1"]), - dataset_name="testset", - ), - } - with pytest.raises(SplitError, match="LPO leakage"): - validate_splits([split], "LPO") diff --git a/tests/datasets/test_custom_splits.py b/tests/datasets/test_custom_splits.py deleted file mode 100644 index 220b38d8a..000000000 --- a/tests/datasets/test_custom_splits.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Compatibility tests for drevalpy.datasets.custom_splits.""" - -from __future__ import annotations - -from pathlib import Path - -from drevalpy.datasets import custom_splits -from drevalpy.datasets.custom_splits import ( - CustomSplitError, - CustomSplitParams, - SplitError, - SplitParams, - load_custom_splitter, - run_splitter, -) -from tests.datasets.split_helpers import sample_dataset - - -def test_custom_splits_exports_aliases() -> None: - """Legacy custom_splits names remain available as aliases.""" - assert CustomSplitError is SplitError - assert CustomSplitParams is SplitParams - assert custom_splits.load_custom_splitter is custom_splits.load_external_splitter - assert custom_splits.validate_cv_splits is custom_splits.validate_splits - - -def test_load_custom_splitter_alias(tmp_path: Path) -> None: - """ - Legacy loader alias resolves external split scripts. - - :param tmp_path: Temporary path provided by pytest. - """ - script = tmp_path / "splitter.py" - script.write_text( - """ -def create_splits(response_data, params): - return [] -""", - encoding="utf-8", - ) - assert callable(load_custom_splitter(script)) - - -def test_run_splitter_alias_delegates_to_create_splits() -> None: - """Compatibility alias still creates built-in splits.""" - dataset = sample_dataset(n_cell_lines=4, n_drugs=2) - splits, metadata = run_splitter( - dataset, - test_mode="LPO", - n_cv_splits=2, - validation_ratio=0.2, - random_state=11, - split_early_stopping=False, - ) - assert len(splits) == 2 - assert metadata[0]["split_index"] == 0 diff --git a/tests/docs/test_docs_structure.py b/tests/docs/test_docs_structure.py new file mode 100644 index 000000000..ae8adc893 --- /dev/null +++ b/tests/docs/test_docs_structure.py @@ -0,0 +1,307 @@ +"""Policy checks for the restructured Sphinx documentation tree.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import pytest +import yaml + +from tests._trusted_subprocess import run_trusted_python + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCS = REPO_ROOT / "docs" +ZOO_DIR = REPO_ROOT / "drevalpy" / "models" / "zoo" + +if str(DOCS) not in sys.path: + sys.path.insert(0, str(DOCS)) + +WORKFLOW_DIRS = ( + DOCS / "cli", + DOCS / "python", + DOCS / "concepts", + DOCS / "getting_started", +) + +EXEMPT_SUFFIXES = { + "index.rst", + "reference.rst", + "_generated_reference.rst", + "_generated_cell_line_featurizers.rst", + "_generated_drug_featurizers.rst", + "_generated_examples.rst", + "_generated_model_zoo.rst", + "_generated_predictors.rst", +} + +EXEMPT_RELATIVE = { + "python/api", +} + +#: Sub-apps that must exist. Kept explicit so removing one fails, while the +#: docs-coverage and shell-example checks derive the real list from the live app +#: and therefore cannot go stale when a sub-app is added. +REQUIRED_CLI_GROUPS = {"data", "experiments", "list"} + + +def _cli_command_groups() -> tuple[str, ...]: + """Return the names of the CLI's sub-apps, read from the live Typer app. + + Derived rather than hand-listed: an allowlist here silently stops matching + when a new sub-app lands, which is exactly what happened to the ``list`` + group. Imported lazily so collecting this module does not pull in the CLI. + + Returns: + Every registered sub-app name, e.g. ``("data", "experiments", "list")``. + """ + from drevalpy.cli.main import app + + return tuple(sorted(group.name for group in app.registered_groups if group.name)) + + +def _rst_files(root: Path) -> list[Path]: + return sorted(path for path in root.rglob("*.rst") if path.is_file()) + + +def _is_exempt(path: Path) -> bool: + rel = path.relative_to(DOCS).as_posix() + if path.name in EXEMPT_SUFFIXES: + return True + return any(rel.startswith(prefix) for prefix in EXEMPT_RELATIVE) + + +def _workflow_pages() -> list[Path]: + pages: list[Path] = [] + for directory in WORKFLOW_DIRS: + for path in _rst_files(directory): + if not _is_exempt(path): + pages.append(path) + return pages + + +def test_backward_compatibility_sections_are_final_and_substantive() -> None: + """If a page has Backward compatibility, it must be last and non-empty.""" + misplaced: list[str] = [] + empty: list[str] = [] + for path in _workflow_pages(): + text = path.read_text(encoding="utf-8") + if "Backward compatibility" not in text: + continue + matches = list( + re.finditer( + r"^(?P\S.*)\n(?P<underline>-{3,})\s*$", + text, + flags=re.MULTILINE, + ) + ) + rel = path.relative_to(DOCS).as_posix() + if not matches or matches[-1].group("title").strip() != "Backward compatibility": + misplaced.append(rel) + continue + section = text[matches[-1].start() :] + if re.search(r"no branch-specific", section, flags=re.IGNORECASE): + empty.append(f"{rel} (no-op branch note)") + if re.search(r"under-listed|incorrectly stated", section, flags=re.IGNORECASE): + empty.append(f"{rel} (docs-only note)") + if "Before 1.6.0" not in section and "before 1.6.0" not in section: + empty.append(f"{rel} (missing before 1.6.0)") + assert not misplaced, "Backward compatibility must be the final top-level section:\n" + "\n".join(misplaced) + assert not empty, "Empty or docs-only Backward compatibility sections:\n" + "\n".join(empty) + + +def test_zoo_presets_documented_in_model_zoo() -> None: + from _model_zoo import generate_model_zoo_rst + + zoo_names = {path.stem for path in ZOO_DIR.glob("*.yaml")} + catalog = (DOCS / "concepts" / "model_zoo.rst").read_text(encoding="utf-8") + assert "_generated_model_zoo.rst" in catalog + generated = generate_model_zoo_rst() + missing = sorted(name for name in zoo_names if name not in generated) + assert not missing, f"Zoo presets missing from generated model zoo catalog: {missing}" + assert "``scaledGeneExpression:singleDrugElasticNet``" in generated + assert "``scaledGeneExpression:singleDrugRandomForest``" in generated + assert "``molirOmics:molir``" in generated + assert "``superfeltrOmics:superfeltr``" in generated + + +@pytest.mark.slow +def test_component_catalog_is_registry_driven_and_synchronized() -> None: + """Extended tier: regenerates the catalog in a fresh interpreter (~2.2s). + + The child process is the point - the generator must produce the committed RST + from a clean registry, not from whatever this session has registered. + """ + from drevalpy.registry import _builtins as rb + from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry + from drevalpy.registry.drug_featurizer import drug_featurizer_registry + from drevalpy.registry.predictor import predictor_registry + + catalog = (DOCS / "concepts" / "component_catalog.rst").read_text(encoding="utf-8") + for include in ( + "_generated_cell_line_featurizers.rst", + "_generated_drug_featurizers.rst", + "_generated_predictors.rst", + ): + assert f".. include:: {include}" in catalog + assert ".. list-table::" not in catalog + assert "Example recipe" not in catalog + + script = ( + "import json, sys\n" + f"sys.path.insert(0, {str(DOCS)!r})\n" + "from _component_catalog import generate_component_catalog_rsts\n" + "sys.stdout.write(json.dumps(generate_component_catalog_rsts(), sort_keys=True))\n" + ) + completed = run_trusted_python(script, cwd=str(REPO_ROOT)) + assert completed.returncode == 0, completed.stderr + generated = json.loads(completed.stdout) + + rb.register_builtin_components() + expected_featurizers = { + "cell_line": { + row["name"]: ( + row["output_format"], + "\u2705" if row["precompute"] else "\u274c", + " ".join(row["description"].split()), + ) + for row in cell_line_featurizer_registry.list_metadata() + if row["name"] in rb.BUILTIN_CELL_LINE_FEATURIZER_NAMES + }, + "drug": { + row["name"]: ( + row["output_format"], + "\u2705" if row["precompute"] else "\u274c", + " ".join(row["description"].split()), + ) + for row in drug_featurizer_registry.list_metadata() + if row["name"] in rb.BUILTIN_DRUG_FEATURIZER_NAMES + }, + } + expected_predictors = { + row["name"]: ( + row["input_interface"].replace("_", "-").capitalize(), + " ".join(row["description"].split()), + ) + for row in predictor_registry.list_metadata() + if row["name"] in rb.BUILTIN_PREDICTOR_NAMES + } + for registry_name, expected in expected_featurizers.items(): + matches = re.findall( + r"^ \* - ``([^`]+)``\n - ``([^`]+)``\n - ([^\n]*)\n - ([^\n]+)$", + generated[registry_name], + flags=re.MULTILINE, + ) + observed = { + name: (output_format, precompute, description) for name, output_format, precompute, description in matches + } + assert observed == expected + + predictor_matches = re.findall( + r"^ \* - ``([^`]+)``\n - ([^\n]+)\n - ([^\n]+)$", + generated["predictor"], + flags=re.MULTILINE, + ) + observed_predictors = {name: (interface, description) for name, interface, description in predictor_matches} + assert len(expected_featurizers["cell_line"]) == len(rb.BUILTIN_CELL_LINE_FEATURIZER_NAMES) == 17 + assert len(expected_featurizers["drug"]) == len(rb.BUILTIN_DRUG_FEATURIZER_NAMES) == 10 + assert len(expected_predictors) == len(rb.BUILTIN_PREDICTOR_NAMES) == 27 + assert observed_predictors == expected_predictors + + +def test_cli_pages_have_no_python_code_blocks() -> None: + offenders: list[str] = [] + for path in _rst_files(DOCS / "cli"): + text = path.read_text(encoding="utf-8") + if "code-block:: python" in text: + offenders.append(path.relative_to(DOCS).as_posix()) + assert not offenders, f"CLI pages must not contain Python code blocks: {offenders}" + + +def test_python_guide_pages_have_no_shell_cli_blocks() -> None: + offenders: list[str] = [] + cli_commands = _cli_command_groups() + cli_invocation = re.compile( + r"^\s*drevalpy\s+(?:--|" + "|".join(re.escape(cmd) for cmd in cli_commands) + r")\b", + flags=re.MULTILINE, + ) + for path in _rst_files(DOCS / "python"): + if path.relative_to(DOCS).as_posix().startswith("python/api"): + continue + text = path.read_text(encoding="utf-8") + if "code-block:: bash" in text or "code-block:: shell" in text: + offenders.append(path.relative_to(DOCS).as_posix()) + if cli_invocation.search(text): + offenders.append(path.relative_to(DOCS).as_posix()) + assert not offenders, f"Python guides must not contain CLI shell examples: {offenders}" + + +def test_concept_pages_have_no_interface_code_blocks() -> None: + """Concept pages stay interface-neutral except composition notation tabs. + + ``from_components_to_models`` may show ``ModelConfig`` Python snippets as + one of three equivalent notations (recipe / YAML / ModelConfig), not as a + Python-API tutorial. + """ + offenders: list[str] = [] + allowed_python = {"concepts/from_components_to_models.rst"} + for path in _rst_files(DOCS / "concepts"): + rel = path.relative_to(DOCS).as_posix() + text = path.read_text(encoding="utf-8") + banned = ["code-block:: bash", "code-block:: shell"] + if rel not in allowed_python: + banned.append("code-block:: python") + if any(token in text for token in banned): + offenders.append(rel) + assert not offenders, f"Concept pages must stay interface-neutral: {offenders}" + + +def test_compatibility_wording_avoids_stale_version_labels() -> None: + stale: list[str] = [] + for path in _workflow_pages(): + text = path.read_text(encoding="utf-8") + if "1.5.1" in text or "modularity release" in text.lower(): + stale.append(path.relative_to(DOCS).as_posix()) + assert not stale, "Compatibility wording issues:\n" + "\n".join(stale) + + +def test_cli_reference_documents_all_subcommands() -> None: + from _cli_click import generate_cli_reference_rst + from typer.main import get_command + + from drevalpy.cli.main import app + + reference = (DOCS / "cli" / "reference.rst").read_text(encoding="utf-8") + assert "_generated_reference.rst" in reference + + generated = generate_cli_reference_rst() + missing_in_docs = [cmd for cmd in _cli_command_groups() if f"drevalpy {cmd}" not in generated] + assert not missing_in_docs, f"Generated CLI reference missing commands: {missing_in_docs}" + + click_app = get_command(app) + names = set(getattr(click_app, "commands", {})) + # A floor, not a mirror of the app: deriving both sides would make this + # vacuous. It catches a group being dropped, while the docs check above + # catches one being added without documentation. + missing = sorted(REQUIRED_CLI_GROUPS - names) + assert not missing, f"Typer app missing expected commands: {missing}" + + +def test_zoo_yaml_files_are_valid() -> None: + for path in sorted(ZOO_DIR.glob("*.yaml")): + loaded = yaml.safe_load(path.read_text(encoding="utf-8")) + assert loaded is not None, path.name + + +@pytest.mark.parametrize( + ("path", "forbidden"), + [ + (DOCS / "python" / "api" / "index.rst", "Backward compatibility"), + (DOCS / "cli" / "reference.rst", "Backward compatibility"), + ], +) +def test_generated_reference_pages_omit_compatibility(path: Path, forbidden: str) -> None: + text = path.read_text(encoding="utf-8") + assert forbidden not in text diff --git a/tests/docs/test_examples.py b/tests/docs/test_examples.py new file mode 100644 index 000000000..9e212e6a4 --- /dev/null +++ b/tests/docs/test_examples.py @@ -0,0 +1,144 @@ +"""Guard the runnable examples the extensions page includes. + +``docs/python/extensions.rst`` shows its examples with ``literalinclude``, which +keeps page and code identical but proves nothing about either: a +``literalinclude`` of a module that no longer imports renders happily. The docs +build closes that gap by calling ``docs/_examples.verify_documented_examples``, +and this module runs the same verification under pytest so a broken example does +not have to wait for a docs build to be noticed. + +Importing the examples registers components, which would break the exact-count +assertions in ``tests/test_featurizer_block_policy.py``. The verification +therefore runs in a subprocess; only the RST cross-checks happen in-process, +where they touch no registry. + +Both facts the subprocess establishes - what the examples register, and that the +registries look the same afterwards - start from the same pristine interpreter, +so one child process reports both and the two tests assert on their own half of +its output. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +import pytest + +from tests._trusted_subprocess import run_trusted_python + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCS = REPO_ROOT / "docs" +EXAMPLES = DOCS / "examples" +EXTENSIONS_PAGE = DOCS / "python" / "extensions.rst" + +if str(DOCS) not in sys.path: + sys.path.insert(0, str(DOCS)) + +#: Runs the docs build's own verification in a fresh interpreter and reports the +#: registry sizes on either side of it along with what the examples registered. +#: The registry imports come before ``_examples`` so ``before`` is a genuine +#: pre-verification reading. +_VERIFY_SCRIPT = ( + "import json, sys\n" + f"sys.path.insert(0, {str(DOCS)!r})\n" + "from drevalpy.registry import cell_line_featurizer, drug_featurizer, predictor\n" + "before = [len(cell_line_featurizer.list()), len(drug_featurizer.list()), len(predictor.list())]\n" + "from _examples import verify_documented_examples\n" + "registered = {k: list(v) for k, v in verify_documented_examples().items()}\n" + "after = [len(cell_line_featurizer.list()), len(drug_featurizer.list()), len(predictor.list())]\n" + "sys.stdout.write(json.dumps({'registered': registered, 'before': before, 'after': after}))\n" +) + + +@pytest.fixture(scope="module") +def verification() -> dict[str, Any]: + """Report of one pristine-interpreter run of ``verify_documented_examples``. + + :returns: ``{"registered": ..., "before": ..., "after": ...}`` as reported by + the child process. + """ + completed = run_trusted_python(_VERIFY_SCRIPT, cwd=str(REPO_ROOT)) + assert completed.returncode == 0, completed.stderr + return json.loads(completed.stdout) + + +def _literalincluded_paths() -> list[str]: + """Return every path the page pulls in, indented (inside a tab) or not.""" + text = EXTENSIONS_PAGE.read_text(encoding="utf-8") + return re.findall(r"^\s*\.\. literalinclude:: /(examples/\S+)$", text, flags=re.MULTILINE) + + +class TestVerificationInAPristineInterpreter: + """Extended tier: the shared ``verification`` fixture spawns an interpreter. + + Both tests read the same child-process report, so the ~2.4s is only saved when + both are deselected - hence a class-level marker rather than two per-test ones. + """ + + pytestmark = pytest.mark.slow + + def test_examples_import_register_and_conform(self, verification: dict[str, Any]) -> None: + """The examples import, register what is expected, and pass every check.""" + from _examples import EXPECTED_REGISTRATIONS + + observed = {key: tuple(value) for key, value in verification["registered"].items()} + assert observed == EXPECTED_REGISTRATIONS + + def test_verifying_the_examples_leaves_the_registries_alone(self, verification: dict[str, Any]) -> None: + """The verification must not be observable afterwards. + + ``tests/test_featurizer_block_policy.py`` asserts exact registry counts, and + the docs build's generated component catalogs do the same, so an example that + stayed registered would break both. + """ + assert verification["after"] == verification["before"] + + +def test_every_example_module_is_shown_on_the_page() -> None: + """An example nobody reads is dead code; the page must include each one.""" + from _examples import EXAMPLE_MODULES + + included = set(_literalincluded_paths()) + # toy_conformance.py is the harness that checks the others, so it is + # described in prose rather than shown; everything else is on the page. + expected = {f"examples/{name}.py" for name in EXAMPLE_MODULES} - {"examples/toy_conformance.py"} + assert expected <= included, f"Example modules missing from extensions.rst: {sorted(expected - included)}" + + +def test_the_page_only_includes_files_that_exist() -> None: + """A renamed example must not leave a dangling ``literalinclude``.""" + missing = [target for target in _literalincluded_paths() if not (DOCS / target).is_file()] + assert not missing, f"extensions.rst literalincludes missing files: {missing}" + + +def test_every_example_module_is_listed_in_the_driver() -> None: + """A module the driver does not import is never checked.""" + from _examples import EXAMPLE_MODULES + + on_disk = {path.stem for path in EXAMPLES.glob("*.py") if path.name != "__init__.py"} + assert on_disk == set(EXAMPLE_MODULES) + + +#: Import paths and hooks the page used to teach that never existed or no longer +#: do. Kept as a regression guard because every one of them was published. +DEAD_REFERENCES = ( + "drevalpy.components.core", + "drevalpy.components.featurizers.cell_line.base", + "drevalpy.components.predictors.abstract", + "drevalpy.registry.cell_line_featurizer import register", + "drevalpy.registry.predictor import register", + "drevalpy.visualization.base import Visualization", +) + + +def test_the_page_teaches_the_supported_import_surface() -> None: + """Prose and snippets must route plugin authors through ``drevalpy.plugin``.""" + text = EXTENSIONS_PAGE.read_text(encoding="utf-8") + found = [reference for reference in DEAD_REFERENCES if reference in text] + assert not found, f"extensions.rst points at unsupported import paths: {found}" + assert "drevalpy.plugin" in text + assert "drevalpy.testing" in text diff --git a/tests/docs/test_generated_io.py b/tests/docs/test_generated_io.py new file mode 100644 index 000000000..64f2f4519 --- /dev/null +++ b/tests/docs/test_generated_io.py @@ -0,0 +1,24 @@ +"""Tests for docs-only generated include writers.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCS = REPO_ROOT / "docs" + +if str(DOCS) not in sys.path: + sys.path.insert(0, str(DOCS)) + + +def test_write_text_if_changed_skips_identical_content(tmp_path: Path) -> None: + from _generated_io import write_text_if_changed + + path = tmp_path / "generated.rst" + assert write_text_if_changed(path, "same\n") is True + mtime = path.stat().st_mtime_ns + assert write_text_if_changed(path, "same\n") is False + assert path.stat().st_mtime_ns == mtime + assert write_text_if_changed(path, "changed\n") is True + assert path.read_text(encoding="utf-8") == "changed\n" diff --git a/tests/experiment/test_init.py b/tests/experiment/test_init.py new file mode 100644 index 000000000..412c9ae04 --- /dev/null +++ b/tests/experiment/test_init.py @@ -0,0 +1,22 @@ +"""Tests for the public surface of the experiment package.""" + +from __future__ import annotations + +from drevalpy import experiment +from drevalpy.experiment import randomization, robustness +from drevalpy.types.results.run import RunResult +from drevalpy.types.results.trial import TrialResult + + +def test_all_lists_the_documented_surface() -> None: + assert sorted(experiment.__all__) == ["randomization", "robustness"] + + +def test_re_exports_the_experiment_helpers() -> None: + assert experiment.randomization is randomization + assert experiment.robustness is robustness + + +def test_re_exports_the_result_types() -> None: + assert experiment.RunResult is RunResult + assert experiment.TrialResult is TrialResult diff --git a/tests/experiment/test_randomization.py b/tests/experiment/test_randomization.py new file mode 100644 index 000000000..846494b0a --- /dev/null +++ b/tests/experiment/test_randomization.py @@ -0,0 +1,178 @@ +"""Tests for randomized-dataset generation used by the feature-importance tests.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pytest + +from drevalpy.experiment._randomization import ( + _complement_view_tests, + _single_view_tests, + randomization, +) +from drevalpy.models import construct_model + + +@dataclass +class _StubConfig: + """Minimal stand-in for the ``ModelConfig`` surface ``randomization`` reads.""" + + cell_lines: list[str] = field(default_factory=list) + drugs: list[str] = field(default_factory=list) + + def cell_line_views(self) -> list[str]: + return list(self.cell_lines) + + def drug_views(self) -> list[str]: + return list(self.drugs) + + +class _StubDataset: + """Records every ``with_randomized_views`` call and returns a tagged sentinel.""" + + def __init__(self) -> None: + self.calls: list[tuple[list[str], dict[str, Any]]] = [] + + def with_randomized_views(self, views: list[str], **kwargs: Any) -> str: + self.calls.append((list(views), kwargs)) + return f"dataset({','.join(views)})" + + +def _stub_model(*, cell_lines: list[str] | None = None, drugs: list[str] | None = None) -> type: + config = _StubConfig(cell_lines=cell_lines or [], drugs=drugs or []) + + class _StubModel: + @classmethod + def model_config(cls) -> _StubConfig: + return config + + return _StubModel + + +class TestSingleViewTests: + def test_randomizes_one_view_per_test(self) -> None: + assert _single_view_tests(["a", "b"], "SVRC") == { + ("SVRC", "a"): ["a"], + ("SVRC", "b"): ["b"], + } + + def test_no_views_yields_no_tests(self) -> None: + assert _single_view_tests([], "SVRC") == {} + + +class TestComplementViewTests: + def test_randomizes_everything_but_the_named_view(self) -> None: + assert _complement_view_tests(["a", "b", "c"], "SVCC") == { + ("SVCC", "a"): ["b", "c"], + ("SVCC", "b"): ["a", "c"], + ("SVCC", "c"): ["a", "b"], + } + + def test_a_single_view_leaves_nothing_to_randomize(self) -> None: + assert _complement_view_tests(["a"], "SVCC") == {("SVCC", "a"): []} + + +class TestRandomization: + @pytest.mark.parametrize( + ("mode", "expected"), + [ + pytest.param("SVRC", [["expr"], ["mut"]], id="SVRC-single-cell-line-view"), + pytest.param("SVCC", [["mut"], ["expr"]], id="SVCC-complement-cell-line-view"), + ], + ) + def test_cell_line_modes_select_the_right_views(self, mode: str, expected: list[list[str]]) -> None: + dataset = _StubDataset() + + randomization(_stub_model(cell_lines=["expr", "mut"]), dataset, [mode]) + + assert [views for views, _ in dataset.calls] == expected + + @pytest.mark.parametrize( + ("mode", "expected"), + [ + pytest.param("SVRD", [["fp"], ["smiles"]], id="SVRD-single-drug-view"), + pytest.param("SVCD", [["smiles"], ["fp"]], id="SVCD-complement-drug-view"), + ], + ) + def test_drug_modes_select_the_right_views(self, mode: str, expected: list[list[str]]) -> None: + dataset = _StubDataset() + + randomization(_stub_model(drugs=["fp", "smiles"]), dataset, [mode]) + + assert [views for views, _ in dataset.calls] == expected + + def test_returns_one_dataset_per_test(self) -> None: + model = _stub_model(cell_lines=["expr", "mut"], drugs=["fp"]) + + results = randomization(model, _StubDataset(), ["SVRC", "SVRD"]) + + assert results == ["dataset(expr)", "dataset(mut)", "dataset(fp)"] + + def test_tags_each_dataset_with_its_mode_and_view(self) -> None: + dataset = _StubDataset() + + randomization(_stub_model(cell_lines=["expr", "mut"]), dataset, ["SVRC"]) + + assert [kwargs["randomization"] for _, kwargs in dataset.calls] == [ + ("SVRC", "expr"), + ("SVRC", "mut"), + ] + + def test_forwards_the_randomization_type_and_seed(self) -> None: + dataset = _StubDataset() + + randomization( + _stub_model(cell_lines=["expr"]), + dataset, + ["SVRC"], + randomization_type="invariant", + random_state=7, + ) + + _, kwargs = dataset.calls[0] + assert kwargs["randomization_type"] == "invariant" + assert kwargs["random_state"] == 7 + + def test_defaults_to_permutation_without_a_seed(self) -> None: + dataset = _StubDataset() + + randomization(_stub_model(cell_lines=["expr"]), dataset, ["SVRC"]) + + _, kwargs = dataset.calls[0] + assert kwargs["randomization_type"] == "permutation" + assert kwargs["random_state"] is None + + def test_unknown_modes_are_ignored(self) -> None: + dataset = _StubDataset() + + results = randomization(_stub_model(cell_lines=["expr"]), dataset, ["NOPE"]) + + assert results == [] + assert dataset.calls == [] + + def test_no_modes_yields_no_datasets(self) -> None: + assert randomization(_stub_model(cell_lines=["expr"]), _StubDataset(), []) == [] + + def test_later_modes_override_a_colliding_key(self) -> None: + dataset = _StubDataset() + model = _stub_model(cell_lines=["expr", "mut"]) + + randomization(model, dataset, ["SVRC", "SVRC"]) + + assert len(dataset.calls) == 2 + + def test_reads_views_from_a_real_model_config(self) -> None: + dataset = _StubDataset() + + randomization(construct_model("ElasticNet"), dataset, ["SVRC", "SVRD"]) + + assert [views for views, _ in dataset.calls] == [["gene_expression"], ["morgan_fingerprint"]] + + def test_produces_tagged_datasets_from_a_real_dataset(self, synthetic_dataset) -> None: + results = randomization(construct_model("ElasticNet"), synthetic_dataset, ["SVRC"], random_state=0) + + assert [ds.randomization for ds in results] == [("SVRC", "gene_expression")] + assert results[0] is not synthetic_dataset + assert synthetic_dataset.randomization is None diff --git a/tests/experiment/test_robustness.py b/tests/experiment/test_robustness.py new file mode 100644 index 000000000..15ab38357 --- /dev/null +++ b/tests/experiment/test_robustness.py @@ -0,0 +1,79 @@ +"""Tests for robustness-trial generation via pair-order shuffling.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.experiment import robustness +from drevalpy.types import SplitMask, SplitMasks + + +@pytest.fixture +def split_masks() -> SplitMasks: + rng = np.random.default_rng(0) + shape = (6, 5) + train = rng.random(shape) < 0.6 + test = (~train) & (rng.random(shape) < 0.5) + val = ~train & ~test + return SplitMasks( + train=SplitMask(train), + test=SplitMask(test), + val=SplitMask(val), + metadata={"fold_index": 3, "split_mode": "LPO"}, + ) + + +def test_generates_one_variant_per_permutation(split_masks: SplitMasks) -> None: + assert len(robustness(split_masks, 4)) == 4 + + +def test_zero_permutations_yields_nothing(split_masks: SplitMasks) -> None: + assert robustness(split_masks, 0) == [] + + +def test_each_variant_records_its_trial_index(split_masks: SplitMasks) -> None: + variants = robustness(split_masks, 3) + + assert [v.metadata["robustness_trial"] for v in variants] == [0, 1, 2] + + +def test_original_metadata_is_carried_over(split_masks: SplitMasks) -> None: + variant = robustness(split_masks, 1)[0] + + assert variant.metadata["fold_index"] == 3 + assert variant.metadata["split_mode"] == "LPO" + + +def test_original_metadata_is_not_mutated(split_masks: SplitMasks) -> None: + robustness(split_masks, 2) + + assert "robustness_trial" not in split_masks.metadata + + +def test_mask_contents_are_unchanged(split_masks: SplitMasks) -> None: + variant = robustness(split_masks, 1)[0] + + np.testing.assert_array_equal(variant.train.mask, split_masks.train.mask) + np.testing.assert_array_equal(variant.test.mask, split_masks.test.mask) + np.testing.assert_array_equal(variant.val.mask, split_masks.val.mask) + + +def test_pair_order_differs_from_the_original(split_masks: SplitMasks) -> None: + variant = robustness(split_masks, 2)[1] + + assert not np.array_equal(variant.train.pairs, split_masks.train.pairs) + + +def test_trial_index_is_used_as_the_shuffle_seed(split_masks: SplitMasks) -> None: + variant = robustness(split_masks, 3)[2] + + np.testing.assert_array_equal(variant.train.pairs, split_masks.train.shuffled(seed=2).pairs) + np.testing.assert_array_equal(variant.test.pairs, split_masks.test.shuffled(seed=2).pairs) + np.testing.assert_array_equal(variant.val.pairs, split_masks.val.shuffled(seed=2).pairs) + + +def test_distinct_trials_produce_distinct_orders(split_masks: SplitMasks) -> None: + first, second = robustness(split_masks, 2) + + assert not np.array_equal(first.train.pairs, second.train.pairs) diff --git a/tests/fixtures/sparsego/TOYv1/gene2ind.txt b/tests/fixtures/sparsego/TOYv1/gene2ind.txt new file mode 100644 index 000000000..645620349 --- /dev/null +++ b/tests/fixtures/sparsego/TOYv1/gene2ind.txt @@ -0,0 +1,13 @@ +0 AURKB +1 BAX +2 CAPN1 +3 CASP3 +4 CDK1 +5 DLD +6 HDAC2 +7 NMT1 +8 POLR1C +9 PTK2 +10 SMARCA4 +11 TOP2A +12 USP7 diff --git a/tests/fixtures/sparsego/TOYv1/sparseGO_ont.txt b/tests/fixtures/sparsego/TOYv1/sparseGO_ont.txt new file mode 100644 index 000000000..1ba69fb1a --- /dev/null +++ b/tests/fixtures/sparsego/TOYv1/sparseGO_ont.txt @@ -0,0 +1,82 @@ +GO:0006259 AURKB gene +GO:0006259 BAX gene +GO:0006259 CASP3 gene +GO:0006259 CDK1 gene +GO:0006259 SMARCA4 gene +GO:0006259 TOP2A gene +GO:0006259 USP7 gene +GO:0006338 AURKB gene +GO:0006338 CDK1 gene +GO:0006338 HDAC2 gene +GO:0006338 SMARCA4 gene +GO:0006338 USP7 gene +GO:0006351 AURKB gene +GO:0006351 CDK1 gene +GO:0006351 HDAC2 gene +GO:0006351 POLR1C gene +GO:0006351 SMARCA4 gene +GO:0006974 BAX gene +GO:0006974 CDK1 gene +GO:0006974 SMARCA4 gene +GO:0006974 TOP2A gene +GO:0006974 USP7 gene +GO:0007399 CASP3 gene +GO:0007399 CDK1 gene +GO:0007399 HDAC2 gene +GO:0007399 PTK2 gene +GO:0007399 SMARCA4 gene +GO:0008150 AURKB gene +GO:0008150 BAX gene +GO:0008150 CAPN1 gene +GO:0008150 CASP3 gene +GO:0008150 CDK1 gene +GO:0008150 DLD gene +GO:0008150 GO:0006259 default +GO:0008150 GO:0006338 default +GO:0008150 GO:0006351 default +GO:0008150 GO:0006974 default +GO:0008150 GO:0007399 default +GO:0008150 GO:0008284 default +GO:0008150 GO:0030154 default +GO:0008150 GO:0032880 default +GO:0008150 GO:0043066 default +GO:0008150 GO:0051239 default +GO:0008150 GO:1902531 default +GO:0008150 HDAC2 gene +GO:0008150 NMT1 gene +GO:0008150 POLR1C gene +GO:0008150 PTK2 gene +GO:0008150 SMARCA4 gene +GO:0008150 TOP2A gene +GO:0008150 USP7 gene +GO:0008284 CAPN1 gene +GO:0008284 CDK1 gene +GO:0008284 HDAC2 gene +GO:0008284 PTK2 gene +GO:0008284 SMARCA4 gene +GO:0030154 CASP3 gene +GO:0030154 CDK1 gene +GO:0030154 HDAC2 gene +GO:0030154 PTK2 gene +GO:0030154 SMARCA4 gene +GO:0032880 AURKB gene +GO:0032880 CASP3 gene +GO:0032880 CDK1 gene +GO:0032880 NMT1 gene +GO:0032880 USP7 gene +GO:0043066 AURKB gene +GO:0043066 BAX gene +GO:0043066 CDK1 gene +GO:0043066 HDAC2 gene +GO:0043066 PTK2 gene +GO:0051239 CASP3 gene +GO:0051239 CDK1 gene +GO:0051239 HDAC2 gene +GO:0051239 PTK2 gene +GO:0051239 SMARCA4 gene +GO:1902531 AURKB gene +GO:1902531 BAX gene +GO:1902531 HDAC2 gene +GO:1902531 PTK2 gene +GO:1902531 SMARCA4 gene +GO:1902531 USP7 gene diff --git a/tests/fixtures/sparsego/TOYv2/gene2ind.txt b/tests/fixtures/sparsego/TOYv2/gene2ind.txt new file mode 100644 index 000000000..645620349 --- /dev/null +++ b/tests/fixtures/sparsego/TOYv2/gene2ind.txt @@ -0,0 +1,13 @@ +0 AURKB +1 BAX +2 CAPN1 +3 CASP3 +4 CDK1 +5 DLD +6 HDAC2 +7 NMT1 +8 POLR1C +9 PTK2 +10 SMARCA4 +11 TOP2A +12 USP7 diff --git a/tests/fixtures/sparsego/TOYv2/sparseGO_ont.txt b/tests/fixtures/sparsego/TOYv2/sparseGO_ont.txt new file mode 100644 index 000000000..1ba69fb1a --- /dev/null +++ b/tests/fixtures/sparsego/TOYv2/sparseGO_ont.txt @@ -0,0 +1,82 @@ +GO:0006259 AURKB gene +GO:0006259 BAX gene +GO:0006259 CASP3 gene +GO:0006259 CDK1 gene +GO:0006259 SMARCA4 gene +GO:0006259 TOP2A gene +GO:0006259 USP7 gene +GO:0006338 AURKB gene +GO:0006338 CDK1 gene +GO:0006338 HDAC2 gene +GO:0006338 SMARCA4 gene +GO:0006338 USP7 gene +GO:0006351 AURKB gene +GO:0006351 CDK1 gene +GO:0006351 HDAC2 gene +GO:0006351 POLR1C gene +GO:0006351 SMARCA4 gene +GO:0006974 BAX gene +GO:0006974 CDK1 gene +GO:0006974 SMARCA4 gene +GO:0006974 TOP2A gene +GO:0006974 USP7 gene +GO:0007399 CASP3 gene +GO:0007399 CDK1 gene +GO:0007399 HDAC2 gene +GO:0007399 PTK2 gene +GO:0007399 SMARCA4 gene +GO:0008150 AURKB gene +GO:0008150 BAX gene +GO:0008150 CAPN1 gene +GO:0008150 CASP3 gene +GO:0008150 CDK1 gene +GO:0008150 DLD gene +GO:0008150 GO:0006259 default +GO:0008150 GO:0006338 default +GO:0008150 GO:0006351 default +GO:0008150 GO:0006974 default +GO:0008150 GO:0007399 default +GO:0008150 GO:0008284 default +GO:0008150 GO:0030154 default +GO:0008150 GO:0032880 default +GO:0008150 GO:0043066 default +GO:0008150 GO:0051239 default +GO:0008150 GO:1902531 default +GO:0008150 HDAC2 gene +GO:0008150 NMT1 gene +GO:0008150 POLR1C gene +GO:0008150 PTK2 gene +GO:0008150 SMARCA4 gene +GO:0008150 TOP2A gene +GO:0008150 USP7 gene +GO:0008284 CAPN1 gene +GO:0008284 CDK1 gene +GO:0008284 HDAC2 gene +GO:0008284 PTK2 gene +GO:0008284 SMARCA4 gene +GO:0030154 CASP3 gene +GO:0030154 CDK1 gene +GO:0030154 HDAC2 gene +GO:0030154 PTK2 gene +GO:0030154 SMARCA4 gene +GO:0032880 AURKB gene +GO:0032880 CASP3 gene +GO:0032880 CDK1 gene +GO:0032880 NMT1 gene +GO:0032880 USP7 gene +GO:0043066 AURKB gene +GO:0043066 BAX gene +GO:0043066 CDK1 gene +GO:0043066 HDAC2 gene +GO:0043066 PTK2 gene +GO:0051239 CASP3 gene +GO:0051239 CDK1 gene +GO:0051239 HDAC2 gene +GO:0051239 PTK2 gene +GO:0051239 SMARCA4 gene +GO:1902531 AURKB gene +GO:1902531 BAX gene +GO:1902531 HDAC2 gene +GO:1902531 PTK2 gene +GO:1902531 SMARCA4 gene +GO:1902531 USP7 gene diff --git a/tests/models/config/_stubs.py b/tests/models/config/_stubs.py new file mode 100644 index 000000000..95356409a --- /dev/null +++ b/tests/models/config/_stubs.py @@ -0,0 +1,164 @@ +"""Stub component registrations shared by the ``models/config`` tests. + +Config validation is mostly about what the registries say about a component, not +about what the component computes, so almost every test here first registers a +throwaway featurizer or predictor whose only real content is its contract. Those +registrations were written out per test, which is what made ``test_validation.py`` +and ``test_block_specs.py`` read as near-clones of each other. + +Plain ``_``-prefixed module, per the test-layout rules in ``AGENTS.md``: the +underscore keeps it out of collection, and the mirror policy walks ``drevalpy/`` +only, so no mirrored test is demanded for it. + +Every function here registers into the process-global component registries, so a +test using them must be under ``isolated_component_registries`` from +``tests/registry/_helpers.py``. +""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.models.config import PredictionMode +from drevalpy.registry.cell_line_featurizer import register as register_cell_line_featurizer +from drevalpy.registry.drug_featurizer import register as register_drug_featurizer +from drevalpy.registry.predictor import register as register_predictor +from drevalpy.types.data.batch.feature_block import BlockSpec + +REGRESSION_ONLY = frozenset({PredictionMode.REGRESSION}) + + +def register_featurizer_stub( + name: str, + *, + side: str, + contract: FeatureFormat = FeatureFormat.NUMERIC_MATRIX, + output_block_specs: tuple[BlockSpec, ...] | None = None, +) -> type: + """Register a featurizer that declares a contract and computes nothing. + + :param name: Registry name. + :param side: ``"cell_line"`` or ``"drug"``. + :param contract: Output feature format the registration declares. + :param output_block_specs: Declared block specs; omitted leaves the + view-fallback path in play. + :returns: The registered stub class. + """ + register = register_cell_line_featurizer if side == "cell_line" else register_drug_featurizer + + @register(name, description=f"{side} stub", contract=contract) + class Stub: + pass + + if output_block_specs is not None: + Stub.output_block_specs = output_block_specs + return Stub + + +def register_matrix_predictor_stub( + name: str = "densePred", + *, + cell_line_contract: FeatureFormat = FeatureFormat.NUMERIC_MATRIX, + drug_contract: FeatureFormat = FeatureFormat.NUMERIC_MATRIX, +) -> type: + """Register a regression-only ``MatrixPredictor`` that predicts zeros. + + :param name: Registry name. + :param cell_line_contract: Cell-line contract the registration declares. + :param drug_contract: Drug contract the registration declares. + :returns: The registered stub class. + """ + + @register_predictor( + name, + description="matrix stub", + cell_line_contract=cell_line_contract, + drug_contract=drug_contract, + ) + class Stub(MatrixPredictor): + supported_modes = REGRESSION_ONLY + + def _fit_matrix(self, x, y) -> None: + return None + + def _predict_matrix(self, x): + return np.zeros(len(x), dtype=np.float64) + + return Stub + + +def register_block_predictor_stub( + name: str = "blockPred", + *, + cell_line_contract: FeatureFormat = FeatureFormat.NUMERIC_MATRIX, + drug_contract: FeatureFormat = FeatureFormat.NUMERIC_MATRIX, + required_cell_line_block_specs: tuple[BlockSpec, ...] | None = None, + required_drug_block_specs: tuple[BlockSpec, ...] | None = None, +) -> type: + """Register a regression-only ``BlockPredictor`` that predicts zeros. + + :param name: Registry name. + :param cell_line_contract: Cell-line contract the registration declares. + :param drug_contract: Drug contract the registration declares. + :param required_cell_line_block_specs: Block specs the predictor demands. + :param required_drug_block_specs: Block specs the predictor demands. + :returns: The registered stub class. + """ + + @register_predictor( + name, + description="block stub", + cell_line_contract=cell_line_contract, + drug_contract=drug_contract, + ) + class Stub(BlockPredictor): + supported_modes = REGRESSION_ONLY + + def _fit(self, batch) -> None: + return None + + def _predict(self, batch): + return np.zeros(batch.n_pairs, dtype=np.float64) + + if required_cell_line_block_specs is not None: + Stub.required_cell_line_block_specs = required_cell_line_block_specs + if required_drug_block_specs is not None: + Stub.required_drug_block_specs = required_drug_block_specs + return Stub + + +def register_feature_free_predictor_stub(name: str = "naiveMean") -> type: + """Register a ``FeatureFreePredictor`` that predicts zeros. + + :param name: Registry name. + :returns: The registered stub class. + """ + + @register_predictor( + name, + description="feature-free stub", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class Stub(FeatureFreePredictor): + def _fit(self, batch) -> None: + return None + + def _predict(self, batch): + return np.zeros(batch.n_pairs, dtype=np.float64) + + return Stub + + +def register_dense_trio() -> None: + """Register the dense cell-line / dense drug / matrix-predictor triple. + + The baseline every contract-mismatch test varies one member of. + """ + register_featurizer_stub("denseCellLine", side="cell_line") + register_featurizer_stub("denseDrug", side="drug") + register_matrix_predictor_stub("densePred") diff --git a/tests/models/config/test_block_specs.py b/tests/models/config/test_block_specs.py new file mode 100644 index 000000000..07236c094 --- /dev/null +++ b/tests/models/config/test_block_specs.py @@ -0,0 +1,92 @@ +"""Tests for featurizer config → output block-spec resolution.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.models.config import CellLineFeaturizerConfig, DrugFeaturizerConfig, FeaturizerConfig +from drevalpy.models.config._block_specs import resolve_output_block_specs +from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry +from drevalpy.types.data.batch.feature_block import BlockSpec +from tests.models.config._stubs import register_featurizer_stub +from tests.registry._helpers import isolated_component_registries + + +@pytest.fixture(autouse=True) +def _clear_registries() -> Iterator[None]: + yield from isolated_component_registries() + + +def test_view_fallback_emits_named_block() -> None: + register_featurizer_stub("viewCell", side="cell_line") + + config = CellLineFeaturizerConfig(name="viewCell", view="gene_expression") + + assert resolve_output_block_specs(config) == (BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX),) + + +def test_declared_output_block_specs_win() -> None: + register_featurizer_stub( + "declaredDrug", + side="drug", + output_block_specs=(BlockSpec("fingerprints", FeatureFormat.NUMERIC_MATRIX),), + ) + + config = DrugFeaturizerConfig(name="declaredDrug", view="ignored") + + assert resolve_output_block_specs(config) == (BlockSpec("fingerprints", FeatureFormat.NUMERIC_MATRIX),) + + +def test_nested_concat_flattens_child_blocks() -> None: + from drevalpy.components.featurizers.shared.concat import CellLineConcatFeaturizer + + register_featurizer_stub("denseCellLine", side="cell_line") + cell_line_featurizer_registry.register_existing("concatFeaturizers", CellLineConcatFeaturizer) + + config = CellLineFeaturizerConfig.model_validate( + { + "name": "concatFeaturizers", + "featurizers": [ + {"name": "denseCellLine", "view": "gene_expression"}, + { + "name": "concatFeaturizers", + "featurizers": [ + {"name": "denseCellLine", "view": "mutations"}, + ], + }, + ], + } + ) + assert resolve_output_block_specs(config) == ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX), + BlockSpec("mutations", FeatureFormat.NUMERIC_MATRIX), + ) + + +def test_sparsego_expression_and_mutations_block_names() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + expression = FeaturizerConfig( + name="sparsegoOntology", + registry="cell_line", + hyperparameter_space={ + "input_type": {"type": "categorical", "choices": ["expression", "mutations"], "default": "expression"} + }, + ) + mutations = FeaturizerConfig( + name="sparsegoOntology", + registry="cell_line", + hyperparameter_space={ + "input_type": {"type": "categorical", "choices": ["expression", "mutations"], "default": "mutations"} + }, + ) + assert resolve_output_block_specs(expression) == ( + BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX, metadata=True), + ) + assert resolve_output_block_specs(mutations) == ( + BlockSpec("mutations", FeatureFormat.NUMERIC_MATRIX, metadata=True), + ) diff --git a/tests/models/config/test_featurizer.py b/tests/models/config/test_featurizer.py new file mode 100644 index 000000000..1fca6eaf2 --- /dev/null +++ b/tests/models/config/test_featurizer.py @@ -0,0 +1,60 @@ +"""Tests for featurizer config schema validation.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from drevalpy.models.config import CellLineFeaturizerConfig, FeaturizerConfig + + +def test_empty_view_string_fails() -> None: + with pytest.raises(ValidationError, match="view must be a non-empty string when set"): + CellLineFeaturizerConfig(name="denseCellLine", view=" ") + + +def test_views_plural_is_rejected_as_unknown_field() -> None: + """The vestigial ``views`` field is gone; ``extra="forbid"`` must reject it.""" + with pytest.raises(ValidationError): + FeaturizerConfig(name="landmarkGenes", views=["gene_expression"]) # type: ignore[call-arg] + + +def test_non_empty_view_is_accepted() -> None: + config = CellLineFeaturizerConfig(name="denseCellLine", view="gene_expression") + assert config.view == "gene_expression" + + +@pytest.mark.parametrize("cls", [FeaturizerConfig, CellLineFeaturizerConfig]) +def test_one_key_shorthand_is_accepted_by_base_and_pinned(cls: type[FeaturizerConfig]) -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = cls.model_validate({"pca[methylation]": {"n_components": 42}}) + assert (config.name, config.view, config.registry) == ("pca", "methylation", "cell_line") + assert config.hyperparameter_space is not None + assert config.hyperparameter_space["n_components"]["default"] == 42 + + +def test_list_sequence_fields_are_stored_as_tuples() -> None: + """Pydantic coerces incoming lists to tuples, so no hand-written validator is needed.""" + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = CellLineFeaturizerConfig.model_validate( + {"name": "concatFeaturizers", "featurizers": ["raw[gene_expression]", "raw[mutations]"]}, + ) + assert isinstance(config.featurizers, tuple) + assert [child.view for child in config.featurizers] == ["gene_expression", "mutations"] + + +def test_json_dump_renders_sequences_as_lists() -> None: + """``mode="json"`` must yield plain lists so exported YAML stays hand-editable.""" + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = CellLineFeaturizerConfig.model_validate( + {"name": "concatFeaturizers", "featurizers": ["raw[gene_expression]", "raw[mutations]"]}, + ) + dumped = config.model_dump(mode="json") + assert isinstance(dumped["featurizers"], list) + assert [child["view"] for child in dumped["featurizers"]] == ["gene_expression", "mutations"] diff --git a/tests/models/config/test_featurizer_parse.py b/tests/models/config/test_featurizer_parse.py new file mode 100644 index 000000000..0d4772ea6 --- /dev/null +++ b/tests/models/config/test_featurizer_parse.py @@ -0,0 +1,255 @@ +"""Tests for featurizer mapping normalization. + +Recipe strings are expanded by ``drevalpy.models.config._recipe`` before they reach the +normalizer, so recipe notation itself is covered in ``test_recipe.py``. What is tested here is +the mapping side, plus the fact that an expanded recipe and the equivalent YAML normalize +identically. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.models.config._featurizer_parse import normalize_featurizer_config +from drevalpy.models.config._recipe import expand_featurizer_recipe + + +def _normalize_recipe(recipe: str, *, default_registry: str) -> dict: + """Normalize a recipe string the way a config slot does. + + :param recipe: Recipe string to expand and normalize. + :param default_registry: Registry to resolve names against. + :returns: Normalized featurizer mapping. + """ + return normalize_featurizer_config( + expand_featurizer_recipe(recipe), + default_registry=default_registry, + ) + + +def test_normalize_named_mapping() -> None: + payload = normalize_featurizer_config({"name": "fingerprints"}, default_registry="drug") + assert payload == {"name": "fingerprints", "registry": "drug"} + + +def test_normalize_rejects_a_recipe_string() -> None: + """The normalizer takes mappings; a recipe is expanded before it gets here.""" + with pytest.raises(TypeError, match="list or mapping"): + normalize_featurizer_config("fingerprints", default_registry="drug") + + +def test_normalize_list_shorthand() -> None: + payload = normalize_featurizer_config( + ["scaledGeneExpression", "raw[mutations]"], + default_registry="cell_line", + ) + assert payload["name"] == "concatFeaturizers" + assert payload["registry"] == "cell_line" + children = payload["featurizers"] + assert children[0]["name"] == "scaledGeneExpression" + assert children[1]["name"] == "raw" + assert children[1]["view"] == "mutations" + assert all(child["registry"] == "cell_line" for child in children) + + +def test_normalize_list_with_parameterized_child() -> None: + payload = normalize_featurizer_config( + [ + "scaledGeneExpression", + {"pca[methylation]": {"n_components": 64}}, + ], + default_registry="cell_line", + ) + children = payload["featurizers"] + assert children[1]["name"] == "pca" + assert children[1]["view"] == "methylation" + assert children[1]["hyperparameter_space"]["n_components"]["default"] == 64 + + +def test_normalize_rejects_empty_list() -> None: + with pytest.raises(ValueError, match="non-empty"): + normalize_featurizer_config([], default_registry="cell_line") + + +def test_a_recipe_and_the_equivalent_yaml_normalize_alike() -> None: + """The two notations are documented as interchangeable, down to the resolved view.""" + from_recipe = _normalize_recipe("raw[expression]+pca[methylation]", default_registry="cell_line") + from_yaml = normalize_featurizer_config( + { + "name": "concatFeaturizers", + "featurizers": [ + {"name": "raw", "view": "expression"}, + {"name": "pca", "view": "methylation"}, + ], + }, + default_registry="cell_line", + ) + assert from_recipe == from_yaml + assert [child["view"] for child in from_recipe["featurizers"]] == ["expression", "methylation"] + + +def test_normalize_rejects_invalid_shape() -> None: + with pytest.raises(TypeError, match="list or mapping"): + normalize_featurizer_config(123) + + +def test_normalize_records_the_registry_on_every_child() -> None: + payload = _normalize_recipe("fingerprints+identity", default_registry="drug") + children = payload["featurizers"] + assert [child["name"] for child in children] == ["fingerprints", "identity"] + assert all(child["registry"] == "drug" for child in children) + + +def test_normalize_resolves_a_view_alias_written_out_in_full() -> None: + """A spelled-out view is passed through unchanged (alias resolution removed).""" + payload = normalize_featurizer_config({"name": "raw", "view": "expression"}, default_registry="cell_line") + assert payload == { + "name": "raw", + "view": "expression", + "registry": "cell_line", + } + + +def test_normalize_leaves_an_already_canonical_view_alone() -> None: + payload = normalize_featurizer_config({"name": "pca", "view": "proteomics"}, default_registry="cell_line") + assert payload["view"] == "proteomics" + + +def test_normalize_leaves_a_custom_view_untouched() -> None: + """A view may name a matrix shipped with a dataset, which is no alias and no typo.""" + payload = normalize_featurizer_config({"name": "raw", "view": "custom_test_view"}, default_registry="cell_line") + assert payload["view"] == "custom_test_view" + + +def test_a_bracket_and_an_explicit_view_field_are_indistinguishable() -> None: + """A bracket is shorthand for the field, so neither is treated more strictly than the other.""" + for view in ("expression", "custom_test_view"): + assert _normalize_recipe(f"raw[{view}]", default_registry="cell_line") == normalize_featurizer_config( + {"name": "raw", "view": view}, + default_registry="cell_line", + ) + + +def test_normalize_leaves_a_view_alone_for_featurizers_that_are_not_view_parametric() -> None: + """Elsewhere ``view`` names an output block, which is not an omics alias.""" + payload = normalize_featurizer_config({"name": "fingerprints", "view": "ignored"}, default_registry="drug") + assert payload["view"] == "ignored" + + +def test_normalize_one_key_mapping_with_brackets() -> None: + payload = normalize_featurizer_config( + {"pca[methylation]": {"n_components": 64}}, + default_registry="cell_line", + ) + assert payload["name"] == "pca" + assert payload["view"] == "methylation" + assert payload["hyperparameter_space"]["n_components"]["default"] == 64 + + +def test_normalize_rejects_bare_raw_or_pca() -> None: + with pytest.raises(ValueError, match="requires an explicit view"): + _normalize_recipe("raw", default_registry="cell_line") + with pytest.raises(ValueError, match="requires an explicit view"): + _normalize_recipe("pca", default_registry="cell_line") + + +_EXPLICIT_SPACE = {"n_components": {"type": "int", "low": 2, "high": 99, "default": 5}} + + +@pytest.mark.parametrize( + ("body", "expected_space_default", "expected_options"), + [ + ({"n_components": 8}, 8, None), + ({"hyperparameter_space": _EXPLICIT_SPACE, "n_components": 8}, 5, None), + ({"options": {"foo": 1}, "bar": 2}, None, {"foo": 1, "bar": 2}), + ], + ids=["simple-value", "explicit-space-wins", "options-and-simple"], +) +def test_one_key_body_folds_loose_values( + body: dict, + expected_space_default: int | None, + expected_options: dict | None, +) -> None: + """A loose value moves a declared default; anything undeclared becomes a fixed option. + + :param body: Body of the one-key mapping form. + :param expected_space_default: Expected ``n_components`` default, when relevant. + :param expected_options: Expected ``options`` mapping, when relevant. + """ + payload = normalize_featurizer_config({"pca[methylation]": body}, default_registry="cell_line") + if expected_space_default is not None: + assert payload["hyperparameter_space"]["n_components"]["default"] == expected_space_default + if expected_options is not None: + assert payload["options"] == expected_options + + +def test_non_atom_one_key_falls_back_to_the_registry_error() -> None: + """A key that is not a single atom is looked up verbatim, so the registry reports it.""" + with pytest.raises(ValueError, match="Unknown Cell line featurizer: 'a\\+b'"): + normalize_featurizer_config({"a+b": {"foo": 1}}, default_registry="cell_line") + + +def test_unparsable_one_key_falls_back_to_the_registry_error() -> None: + """A key the grammar rejects outright is also looked up verbatim, not reported as syntax.""" + with pytest.raises(ValueError, match="Unknown Cell line featurizer: 'raw\\['"): + normalize_featurizer_config({"raw[": {"foo": 1}}, default_registry="cell_line") + + +def test_one_key_loose_values_resolve_against_the_drug_registry() -> None: + """Fingerprints declares n_bits in its HP space, so the value overrides the default.""" + payload = normalize_featurizer_config({"fingerprints": {"n_bits": 512}}, default_registry="drug") + assert payload["registry"] == "drug" + assert payload["hyperparameter_space"]["n_bits"]["default"] == 512 + + +def test_named_mapping_without_a_view_is_rejected() -> None: + with pytest.raises(ValueError, match="requires an explicit view"): + normalize_featurizer_config({"name": "pca"}, default_registry="cell_line") + + +def test_named_mapping_with_a_blank_view_is_rejected() -> None: + with pytest.raises(ValueError, match="requires an explicit view"): + normalize_featurizer_config({"name": "pca", "view": " "}, default_registry="cell_line") + + +def test_one_key_with_a_bracket_records_the_view_without_judging_it() -> None: + """A bracketed key is read structurally, so a view lands on whatever featurizer was named.""" + payload = normalize_featurizer_config( + {"scaledGeneExpression[gene_expression]": {"foo": 1}}, + default_registry="cell_line", + ) + assert payload["name"] == "scaledGeneExpression" + assert payload["view"] == "gene_expression" + assert payload["options"] == {"foo": 1} + + +def test_one_key_body_must_be_a_mapping() -> None: + with pytest.raises(ValueError, match="must be a mapping when provided"): + normalize_featurizer_config({"scaledGeneExpression": 5}, default_registry="cell_line") + + +def test_one_key_body_may_be_null() -> None: + payload = normalize_featurizer_config({"scaledGeneExpression": None}, default_registry="cell_line") + assert payload == {"name": "scaledGeneExpression", "registry": "cell_line"} + + +def test_one_key_body_may_declare_children() -> None: + payload = normalize_featurizer_config( + {"concatFeaturizers": {"featurizers": ["scaledGeneExpression", "raw[mutations]"]}}, + default_registry="cell_line", + ) + assert payload["name"] == "concatFeaturizers" + assert [child["name"] for child in payload["featurizers"]] == ["scaledGeneExpression", "raw"] + + +def test_children_must_be_a_list() -> None: + with pytest.raises(ValueError, match="featurizers must be a list when set"): + normalize_featurizer_config( + {"name": "concatFeaturizers", "featurizers": "scaledGeneExpression"}, + default_registry="cell_line", + ) + + +def test_mapping_without_name_or_one_key_shape_is_rejected() -> None: + with pytest.raises(ValueError, match="list, one-key mapping, or dict with 'name'"): + normalize_featurizer_config({"view": "methylation", "options": {}}, default_registry="cell_line") diff --git a/tests/models/config/test_hp_key_validation.py b/tests/models/config/test_hp_key_validation.py new file mode 100644 index 000000000..b9c11f82d --- /dev/null +++ b/tests/models/config/test_hp_key_validation.py @@ -0,0 +1,182 @@ +"""Tests for merged hyperparameter key validation. + +``validate_merged_mapping`` is the single public entry of +``drevalpy.models.config._hp_key_validation``; the accepted-key index it builds +is asserted through it rather than separately. The key grammar it delegates to +is covered in ``tests/models/test_hp_key_grammar.py``. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.models.config import ( + CellLineFeaturizerConfig, + DrugFeaturizerConfig, + ModelConfig, + PredictorConfig, +) +from drevalpy.models.config._hp_key_validation import ( + _predictor_accepted_keys, + validate_merged_mapping, +) +from drevalpy.registry._builtins import register_builtin_components + + +@pytest.fixture(autouse=True) +def _registry() -> None: + """Register the built-ins the accepted-key index is derived from.""" + register_builtin_components() + + +@pytest.fixture +def config() -> ModelConfig: + """A PCA / fingerprints / elastic-net stack, all three slots tunable.""" + return ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="pca", view="gene_expression"), + drug_featurizer=DrugFeaturizerConfig(name="fingerprints"), + predictor=PredictorConfig(name="elasticNet"), + ) + + +class TestValidateMergedMapping: + """Accepted keys are exactly those the configured components declare.""" + + def test_an_empty_mapping_passes(self, config: ModelConfig) -> None: + assert validate_merged_mapping(config, {}) is None + + @pytest.mark.parametrize( + "key", + [ + pytest.param("predictor.elasticNet.alpha", id="tunable-predictor-param"), + pytest.param("predictor.elasticNet.max_iter", id="non-tunable-predictor-param"), + pytest.param("cell_line_featurizer.pca[gene_expression].n_components", id="cell-line-featurizer-param"), + pytest.param("drug_featurizer.fingerprints.radius", id="drug-featurizer-param"), + ], + ) + def test_accepts_a_declared_key(self, config: ModelConfig, key: str) -> None: + assert validate_merged_mapping(config, {key: 1}) is None + + def test_accepts_several_keys_at_once(self, config: ModelConfig) -> None: + merged = { + "predictor.elasticNet.alpha": 0.5, + "cell_line_featurizer.pca[gene_expression].n_components": 32, + "drug_featurizer.fingerprints.n_bits": 1024, + } + + assert validate_merged_mapping(config, merged) is None + + @pytest.mark.parametrize( + "key", + [ + pytest.param("predictor.elasticNet.not_a_knob", id="unknown-predictor-param"), + pytest.param("predictor.randomForest.n_estimators", id="another-predictors-param"), + pytest.param("alpha", id="unqualified-key"), + pytest.param("cell_line_featurizer.pca.n_components", id="missing-view-qualifier"), + pytest.param("drug_featurizer.fingerprints[smiles].radius", id="unexpected-view-qualifier"), + ], + ) + def test_rejects_an_undeclared_key(self, config: ModelConfig, key: str) -> None: + with pytest.raises(ValueError, match="Unknown hyperparameter"): + validate_merged_mapping(config, {key: 1}) + + def test_the_indexed_form_reports_the_migration_error(self, config: ModelConfig) -> None: + """The indexed check runs before the membership check, so the hint wins.""" + with pytest.raises(ValueError, match="no longer supported"): + validate_merged_mapping(config, {"cell_line_featurizer.pca.0.n_components": 32}) + + def test_expands_concat_children_into_leaf_selectors(self) -> None: + config = ModelConfig.model_validate( + { + "cell_line_featurizer": ["scaledGeneExpression", {"pca[methylation]": {"n_components": 32}}], + "drug_featurizer": "fingerprints", + "predictor": "elasticNet", + } + ) + + assert validate_merged_mapping(config, {"cell_line_featurizer.pca[methylation].n_components": 8}) is None + + def test_the_concat_parent_itself_is_not_addressable(self) -> None: + config = ModelConfig.model_validate( + { + "cell_line_featurizer": ["scaledGeneExpression", {"pca[methylation]": {"n_components": 32}}], + "drug_featurizer": "fingerprints", + "predictor": "elasticNet", + } + ) + + with pytest.raises(ValueError, match="Unknown hyperparameter"): + validate_merged_mapping(config, {"cell_line_featurizer.concatFeaturizers.n_components": 8}) + + def test_a_featurizer_without_a_declared_space_accepts_nothing(self, config: ModelConfig) -> None: + no_knobs = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression", view="gene_expression"), + drug_featurizer=DrugFeaturizerConfig(name="fingerprints"), + predictor=PredictorConfig(name="elasticNet"), + ) + + with pytest.raises(ValueError, match="Unknown hyperparameter"): + validate_merged_mapping( + no_knobs, {"cell_line_featurizer.scaledGeneExpression[gene_expression].n_components": 8} + ) + + def test_a_feature_free_stack_accepts_no_featurizer_keys(self) -> None: + config = ModelConfig( + cell_line_featurizer=None, + drug_featurizer=None, + predictor=PredictorConfig(name="naiveMean"), + ) + + with pytest.raises(ValueError, match="Unknown hyperparameter"): + validate_merged_mapping(config, {"cell_line_featurizer.pca[gene_expression].n_components": 8}) + + +class TestPredictorAcceptedKeys: + """``non_tunable_hyperparameters`` is accepted in either declared shape.""" + + def test_merges_defaults_and_space(self) -> None: + class Predictor: + @staticmethod + def get_default_hyperparameters() -> dict[str, object]: + return {"alpha": 1.0} + + @staticmethod + def get_hyperparameter_space() -> dict[str, object]: + return {"l1_ratio": {"default": 0.5}} + + assert _predictor_accepted_keys(Predictor) == {"alpha", "l1_ratio"} + + @pytest.mark.parametrize( + "non_tunable", + [ + pytest.param({"max_iter": 1000}, id="mapping"), + pytest.param(frozenset({"max_iter"}), id="frozenset"), + pytest.param(["max_iter"], id="list"), + pytest.param(("max_iter",), id="tuple"), + ], + ) + def test_includes_non_tunable_hyperparameters(self, non_tunable: object) -> None: + class Predictor: + non_tunable_hyperparameters = non_tunable + + @staticmethod + def get_default_hyperparameters() -> dict[str, object]: + return {"alpha": 1.0} + + @staticmethod + def get_hyperparameter_space() -> dict[str, object]: + return {} + + assert _predictor_accepted_keys(Predictor) == {"alpha", "max_iter"} + + def test_tolerates_an_absent_declaration(self) -> None: + class Predictor: + @staticmethod + def get_default_hyperparameters() -> dict[str, object]: + return {} + + @staticmethod + def get_hyperparameter_space() -> dict[str, object]: + return {} + + assert _predictor_accepted_keys(Predictor) == set() diff --git a/tests/models/config/test_immutable.py b/tests/models/config/test_immutable.py new file mode 100644 index 000000000..0c65e503d --- /dev/null +++ b/tests/models/config/test_immutable.py @@ -0,0 +1,158 @@ +"""Tests for immutable ModelConfig and featurizer/predictor templates.""" + +from __future__ import annotations + +from types import MappingProxyType + +import pytest +from pydantic import ValidationError + +from drevalpy.models.config import ( + CellLineFeaturizerConfig, + DrugFeaturizerConfig, + FeaturizerConfig, + ModelConfig, + PredictorConfig, + ResolvedModelConfig, +) +from drevalpy.models.zoo import get_zoo_config +from drevalpy.registry._builtins import register_builtin_components + + +def test_model_config_rejects_field_assignment() -> None: + register_builtin_components() + config = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=DrugFeaturizerConfig(name="fingerprints"), + predictor=PredictorConfig(name="elasticNet"), + ) + with pytest.raises(ValidationError, match="frozen"): + config.drug_featurizer = None + + +def test_model_config_scope_is_a_read_only_property() -> None: + register_builtin_components() + assert isinstance(ModelConfig.scope, property) + assert ModelConfig.scope.fset is None + + +def test_featurizer_config_rejects_deep_options_mutation() -> None: + register_builtin_components() + config = FeaturizerConfig.model_validate( + {"name": "tissue", "options": {"allow_missing": True}}, + ) + assert config.options is not None + with pytest.raises(TypeError): + config.options["allow_missing"] = False # type: ignore[index] + + +def test_concat_children_are_tuple_not_list() -> None: + register_builtin_components() + config = CellLineFeaturizerConfig.model_validate( + ["scaledGeneExpression", {"pca[methylation]": {"n_components": 32}}], + ) + assert config.name == "concatFeaturizers" + assert isinstance(config.featurizers, tuple) + assert config.featurizers is not None + assert config.featurizers[1].name == "pca" + with pytest.raises(TypeError): + config.featurizers[0] = CellLineFeaturizerConfig(name="raw", view="expression") # type: ignore[index] + + +def test_predictor_shorthand_writes_hyperparameter_space_defaults() -> None: + register_builtin_components() + config = ModelConfig.model_validate( + { + "cell_line_featurizer": "scaledGeneExpression", + "drug_featurizer": "fingerprints", + "predictor": {"randomForest": {"n_estimators": 10}}, + } + ) + space = config.predictor.hyperparameter_space + assert space is not None + assert space["n_estimators"]["default"] == 10 + with pytest.raises(AttributeError): + _ = config.predictor.hyperparameters # type: ignore[attr-defined] + + +def test_zoo_config_copy_isolation() -> None: + register_builtin_components() + first = get_zoo_config("MultiViewLightGBM") + second = get_zoo_config("MultiViewLightGBM") + assert first == second + assert first is not second + assert first.cell_line_featurizer is not None + assert first.cell_line_featurizer.featurizers is not None + child = first.cell_line_featurizer.featurizers[1] + assert child.hyperparameter_space is not None + with pytest.raises(TypeError): + child.hyperparameter_space["n_components"] = {"default": 8} # type: ignore[index] + + +def test_frozen_mapping_fields_are_deeply_frozen_views() -> None: + register_builtin_components() + featurizer = FeaturizerConfig.model_validate( + { + "name": "tissue", + "options": {"allow_missing": True, "nested": {"inner": [1, {"deep": 2}]}}, + }, + ) + predictor = PredictorConfig.model_validate( + {"name": "randomForest", "hyperparameter_space": {"n_estimators": {"default": 5, "options": [1, 2]}}}, + ) + resolved = ResolvedModelConfig( + template=ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=DrugFeaturizerConfig(name="fingerprints"), + predictor=PredictorConfig(name="elasticNet"), + ), + values={"predictor.elasticNet.alpha": 0.5}, + ) + + assert featurizer.options is not None + assert featurizer.hyperparameter_space is None + assert predictor.hyperparameter_space is not None + frozen_mappings = [ + featurizer.options, + featurizer.options["nested"], + predictor.hyperparameter_space, + predictor.hyperparameter_space["n_estimators"], + resolved.values, + ] + for mapping in frozen_mappings: + assert isinstance(mapping, MappingProxyType) + with pytest.raises(TypeError): + mapping["injected"] = 1 # type: ignore[index] + assert featurizer.options["nested"]["inner"] == (1, MappingProxyType({"deep": 2})) + assert predictor.hyperparameter_space["n_estimators"]["options"] == (1, 2) + + +def test_frozen_mapping_default_is_frozen() -> None: + register_builtin_components() + resolved = ResolvedModelConfig( + template=ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=DrugFeaturizerConfig(name="fingerprints"), + predictor=PredictorConfig(name="elasticNet"), + ), + ) + assert isinstance(resolved.values, MappingProxyType) + with pytest.raises(TypeError): + resolved.values["predictor.elasticNet.alpha"] = 1.0 # type: ignore[index] + + +def test_frozen_mapping_dumps_plain_containers() -> None: + register_builtin_components() + featurizer = FeaturizerConfig.model_validate( + { + "name": "tissue", + "options": {"nested": {"inner": [1, {"deep": 2}]}}, + }, + ) + dumped = featurizer.model_dump(mode="python") + assert dumped["options"] == {"nested": {"inner": [1, {"deep": 2}]}} + assert isinstance(dumped["options"], dict) + assert isinstance(dumped["options"]["nested"]["inner"], list) + assert isinstance(dumped["options"]["nested"]["inner"][1], dict) + assert featurizer.model_dump(mode="json")["options"] == dumped["options"] + assert PredictorConfig(name="elasticNet").model_dump(mode="python")["hyperparameter_space"] is None diff --git a/tests/models/config/test_init.py b/tests/models/config/test_init.py new file mode 100644 index 000000000..cf331b835 --- /dev/null +++ b/tests/models/config/test_init.py @@ -0,0 +1,59 @@ +"""Tests for the public :mod:`drevalpy.models.config` package surface. + +This is the highest fan-in barrel in the package: about sixty-six modules reach +``ModelConfig``, the ``FeaturizerConfig`` pair and the three ``from_*`` +constructors through it rather than through the modules that define them. Its +``__all__`` is therefore a compatibility promise, and a rename that forgets the +barrel breaks every one of those dependents at import time. + +Only the re-export surface and the two primary constructors are asserted here. +Each config model's validation behaviour is tested beside its defining module - +``test_model.py``, ``test_featurizer.py``, ``test_predictor.py``, +``test_resolved.py``, ``test_io.py`` and ``test_validation.py``. + +Origins are recorded against the *leaf* module that defines each name, never +against a sibling barrel: ``ModelScope`` and ``PredictionMode`` are pinned to +``drevalpy.types.enums.*`` rather than to the ``drevalpy.types`` barrel, which +also re-exports them, because comparing one re-export with another cannot fail. +The four assertions driven by that table live in ``tests/_barrel_surface.py``. + +The package's private modules - ``_recipe``, ``_featurizer_parse``, +``_predictor_parse``, ``_hp_key_validation`` and friends - are deliberately +absent from the surface, and +:meth:`~tests._barrel_surface.DeclaredSurface.test_all_matches_the_recorded_surface` +is what keeps them absent. +""" + +from __future__ import annotations + +from drevalpy.models import config +from tests._barrel_surface import DeclaredSurface + +#: ``exported name -> module that defines it``. +EXPECTED_ORIGINS: dict[str, str] = { + "CellLineFeaturizerConfig": "drevalpy.models.config.featurizer", + "DrugFeaturizerConfig": "drevalpy.models.config.featurizer", + "FeaturizerConfig": "drevalpy.models.config.featurizer", + "ModelConfig": "drevalpy.models.config.model", + "ModelScope": "drevalpy.types.enums.model_scope", + "PredictionMode": "drevalpy.types.enums.prediction_mode", + "PredictorConfig": "drevalpy.models.config.predictor", + "ResolvedModelConfig": "drevalpy.models.config.resolved", + "from_dict": "drevalpy.models.config.io", + "from_spec": "drevalpy.models.config.io", + "from_yaml": "drevalpy.models.config.io", + "validate": "drevalpy.models.config.validation", +} + + +class TestConfigSurface(DeclaredSurface): + barrel = config + origins = EXPECTED_ORIGINS + callable_names = ("from_dict", "from_spec", "from_yaml", "validate") + + +def test_from_spec_and_validate() -> None: + cfg = config.from_spec("ElasticNet") + assert isinstance(cfg, config.ModelConfig) + assert cfg.predictor.name == "elasticNet" + config.validate(cfg) diff --git a/tests/models/config/test_io.py b/tests/models/config/test_io.py new file mode 100644 index 000000000..08f938d64 --- /dev/null +++ b/tests/models/config/test_io.py @@ -0,0 +1,153 @@ +"""Tests for drevalpy.models.config.io.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import yaml + +from drevalpy.models.config.io import ( + from_dict, + from_spec, + from_yaml, +) +from drevalpy.models.config.model import ModelConfig +from drevalpy.registry._builtins import register_builtin_components + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_model_config_from_predictor_only_spec() -> None: + config = from_spec("naiveMean") + assert isinstance(config, ModelConfig) + assert config.predictor.name == "naiveMean" + assert config.cell_line_featurizer is None + assert config.drug_featurizer is None + + +def test_model_config_from_triple_spec() -> None: + config = from_spec("scaledGeneExpression:fingerprints:elasticNet") + assert isinstance(config, ModelConfig) + assert config.cell_line_featurizer is not None + assert config.cell_line_featurizer.name == "scaledGeneExpression" + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "fingerprints" + assert config.predictor.name == "elasticNet" + + +def test_model_config_from_triple_spec_with_plus_concat() -> None: + config = from_spec("raw[expression]+raw[mutations]:fingerprints+identity:randomForest") + assert isinstance(config, ModelConfig) + assert config.cell_line_featurizer is not None + assert config.cell_line_featurizer.name == "concatFeaturizers" + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "concatFeaturizers" + assert config.predictor.name == "randomForest" + + +def test_model_config_from_zoo_name() -> None: + config = from_spec("ElasticNet") + assert isinstance(config, ModelConfig) + assert config.predictor.name == "elasticNet" + + +def test_from_dict_with_sections() -> None: + config = from_dict( + { + "cell_line_featurizer": "scaledGeneExpression", + "drug_featurizer": "fingerprints", + "predictor": {"randomForest": {"n_estimators": 10}}, + } + ) + assert config.predictor.hyperparameter_space is not None + assert config.predictor.hyperparameter_space["n_estimators"]["default"] == 10 + + +def test_from_dict_accepts_recipe_strings_in_slots() -> None: + """Slots may hold recipe strings, which is what lets ``from_spec`` reuse this.""" + config = from_dict( + { + "cell_line_featurizer": "raw[expression]+raw[mutations]", + "drug_featurizer": "fingerprints", + "predictor": "randomForest", + } + ) + assert config.cell_line_featurizer is not None + assert config.cell_line_featurizer.name == "concatFeaturizers" + assert config.predictor.name == "randomForest" + + +def test_from_yaml(tmp_path: Path) -> None: + path = tmp_path / "model.yaml" + path.write_text( + yaml.safe_dump( + { + "cell_line_featurizer": "constant", + "drug_featurizer": "identity", + "predictor": "naiveDrugMean", + } + ), + encoding="utf-8", + ) + config = from_yaml(path) + assert config.predictor.name == "naiveDrugMean" + + +def test_from_dict_requires_predictor() -> None: + with pytest.raises(ValueError, match="predictor"): + from_dict({}) + + +def test_from_dict_predictor_shorthand() -> None: + config = from_dict({"predictor": "naiveMean"}) + assert config.predictor.name == "naiveMean" + + +def test_from_dict_rejects_unknown_keys() -> None: + with pytest.raises(ValueError, match="unknown_key"): + from_dict({"predictor": "naiveMean", "unknown_key": True}) + + +def test_from_dict_rejects_invalid_prediction_mode() -> None: + with pytest.raises(ValueError, match="prediction_mode"): + from_dict({"predictor": "naiveMean", "prediction_mode": "invalid"}) + + +def test_from_dict_source_label_is_included_in_the_error() -> None: + with pytest.raises(ValueError, match=r"Invalid model config in my-label:"): + from_dict({"predictor": "naiveMean", "unknown_key": True}, source="my-label") + + +def test_from_dict_error_without_source_omits_the_location() -> None: + with pytest.raises(ValueError, match=r"Invalid model config: "): + from_dict({"predictor": "naiveMean", "unknown_key": True}) + + +def test_from_dict_field_level_error_names_the_field() -> None: + with pytest.raises(ValueError, match=r"predictor: "): + from_dict({"predictor": 123}) + + +def test_from_dict_model_level_error_has_no_empty_field_prefix() -> None: + """Whole-model errors carry an empty ``loc``, which must not render as a bare colon.""" + with pytest.raises(ValueError, match=r"Invalid model config: Value error, Predictor 'elasticNet' requires"): + from_dict({"predictor": "elasticNet"}) + + +def test_from_yaml_reports_path_on_error(tmp_path: Path) -> None: + path = tmp_path / "bad.yaml" + path.write_text("predictor: naiveMean\nunknown_key: true\n", encoding="utf-8") + with pytest.raises(ValueError, match=re.escape(str(path))): + from_yaml(path) + + +def test_from_yaml_rejects_non_mapping_top_level(tmp_path: Path) -> None: + path = tmp_path / "list.yaml" + path.write_text("- naiveMean\n", encoding="utf-8") + with pytest.raises(TypeError, match=re.escape(str(path))): + from_yaml(path) diff --git a/tests/models/config/test_model.py b/tests/models/config/test_model.py new file mode 100644 index 000000000..1460a6b46 --- /dev/null +++ b/tests/models/config/test_model.py @@ -0,0 +1,258 @@ +"""Tests for drevalpy.models.config.model.""" + +import pytest +from pydantic import ValidationError + +from drevalpy.models.config import ( + CellLineFeaturizerConfig, + DrugFeaturizerConfig, + FeaturizerConfig, + ModelConfig, + ModelScope, + PredictionMode, + PredictorConfig, + from_dict, + from_spec, +) + + +def test_featurizer_config_compact_string_shorthand() -> None: + config = FeaturizerConfig.model_validate("fingerprints") + assert config.name == "fingerprints" + assert config.registry == "cell_line" + + +def test_cell_line_and_drug_featurizer_configs_fix_registry() -> None: + cell = CellLineFeaturizerConfig(name="scaledGeneExpression") + drug = DrugFeaturizerConfig(name="fingerprints") + assert cell.registry == "cell_line" + assert drug.registry == "drug" + assert isinstance(cell, FeaturizerConfig) + assert isinstance(drug, FeaturizerConfig) + + +def test_slot_subclasses_override_mismatched_registry() -> None: + cell = CellLineFeaturizerConfig.model_validate( + {"name": "scaledGeneExpression", "registry": "drug"}, + ) + drug = DrugFeaturizerConfig.model_validate( + {"name": "fingerprints", "registry": "cell_line"}, + ) + assert cell.registry == "cell_line" + assert drug.registry == "drug" + + +def test_featurizer_config_compact_one_key_mapping() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = FeaturizerConfig.model_validate( + { + "pca[methylation]": {"n_components": 64}, + } + ) + assert config.name == "pca" + assert config.view == "methylation" + assert config.hyperparameter_space is not None + assert config.hyperparameter_space["n_components"]["default"] == 64 + + +def test_featurizer_config_preserves_view_fields() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + cell_line = CellLineFeaturizerConfig( + name="pca", + view="gene_expression", + hyperparameter_space={"n_components": {"type": "int", "low": 8, "high": 512, "default": 128}}, + ) + assert cell_line.view == "gene_expression" + + +def test_model_id_for_full_triple() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=DrugFeaturizerConfig(name="identity"), + predictor=PredictorConfig(name="randomForest"), + ) + assert config.model_id == "scaledGeneExpression:identity:randomForest" + + +def test_model_id_for_predictor_only_baseline() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = ModelConfig( + cell_line_featurizer=None, + drug_featurizer=None, + predictor=PredictorConfig(name="naiveMean"), + ) + assert config.model_id == "naiveMean" + + +def test_model_id_none_for_partial_multi_drug_config() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + with pytest.raises(ValidationError, match="requires.*drug_featurizer|requires featurizers"): + ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=None, + predictor=PredictorConfig(name="randomForest"), + ) + + +def test_model_id_for_implicit_identity_single_drug() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=None, + predictor=PredictorConfig(name="singleDrugElasticNet"), + ) + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "identity" + assert config.model_id == "scaledGeneExpression:singleDrugElasticNet" + + +def test_single_drug_does_not_override_explicit_drug_featurizer() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + with pytest.raises(ValidationError, match="requires drug_featurizer='identity'"): + ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=DrugFeaturizerConfig(name="fingerprints"), + predictor=PredictorConfig(name="singleDrugElasticNet"), + ) + + +def test_multi_drug_scope_does_not_inject_identity() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + with pytest.raises(ValidationError, match="requires.*drug_featurizer|requires featurizers"): + ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=None, + predictor=PredictorConfig(name="elasticNet"), + ) + + +def test_scope_is_derived_from_the_predictor() -> None: + """A config never states a scope; naming a per-drug predictor is enough.""" + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = ModelConfig.model_validate( + { + "cell_line_featurizer": "scaledGeneExpression", + "predictor": "singleDrugElasticNet", + } + ) + assert config.scope is ModelScope.SINGLE_DRUG + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "identity" + assert "scope" not in config.model_dump() + + +def test_explicit_scope_key_is_rejected() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ModelConfig.model_validate( + { + "cell_line_featurizer": "scaledGeneExpression", + "predictor": "singleDrugElasticNet", + "scope": "single_drug", + } + ) + + +def test_model_config_parses_compact_featurizer_sections() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = ModelConfig.model_validate( + { + "cell_line_featurizer": [ + "scaledGeneExpression", + {"pca[methylation]": {"n_components": 100}}, + "raw[mutations]", + ], + "drug_featurizer": "fingerprints", + "predictor": "randomForest", + } + ) + assert config.cell_line_featurizer is not None + assert isinstance(config.cell_line_featurizer, CellLineFeaturizerConfig) + assert config.cell_line_featurizer.name == "concatFeaturizers" + assert config.drug_featurizer is not None + assert isinstance(config.drug_featurizer, DrugFeaturizerConfig) + assert config.drug_featurizer.name == "fingerprints" + assert config.predictor.name == "randomForest" + + +def test_model_config_parses_predictor_one_key_hyperparameters() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = ModelConfig.model_validate( + { + "cell_line_featurizer": "scaledGeneExpression", + "drug_featurizer": "fingerprints", + "predictor": {"randomForest": {"n_estimators": 10}}, + } + ) + assert config.predictor.name == "randomForest" + assert config.predictor.hyperparameter_space is not None + assert config.predictor.hyperparameter_space["n_estimators"]["default"] == 10 + + +def test_model_config_rejects_base_featurizer_config_in_slots() -> None: + with pytest.raises(ValidationError): + ModelConfig.model_validate( + { + "cell_line_featurizer": FeaturizerConfig(name="scaledGeneExpression", registry="drug"), + "drug_featurizer": FeaturizerConfig(name="fingerprints", registry="cell_line"), + "predictor": PredictorConfig(name="elasticNet"), + } + ) + + +def test_config_is_serializable() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=DrugFeaturizerConfig(name="fingerprints"), + predictor=PredictorConfig( + name="elasticNet", + hyperparameter_space={"alpha": {"type": "float", "low": 1e-4, "high": 10.0, "log": True, "default": 1.0}}, + ), + prediction_mode=PredictionMode.REGRESSION, + ) + payload = config.model_dump(mode="python") + assert payload["cell_line_featurizer"]["name"] == "scaledGeneExpression" + assert payload["predictor"]["name"] == "elasticNet" + assert payload["predictor"]["hyperparameter_space"]["alpha"]["default"] == 1.0 + + +def test_from_spec_classmethod() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = from_spec("NaivePredictor") + assert isinstance(config, ModelConfig) + assert config.predictor.name == "naiveMean" + + +def test_from_dict_classmethod() -> None: + config = from_dict({"predictor": "naiveMean"}) + assert config.predictor.name == "naiveMean" diff --git a/tests/models/config/test_predictor.py b/tests/models/config/test_predictor.py new file mode 100644 index 000000000..118989d5b --- /dev/null +++ b/tests/models/config/test_predictor.py @@ -0,0 +1,157 @@ +"""Tests for the ``PredictorConfig`` pydantic model. + +Asserts the model's own field validation and immutability directly, rather than +through ``ModelConfig``. Recipe-string normalization lives in +``test_predictor_parse.py``; this file only checks that ``PredictorConfig`` +routes through it. +""" + +from __future__ import annotations + +from types import MappingProxyType + +import pytest +from pydantic import ValidationError + +from drevalpy.models.config.predictor import PredictorConfig +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry.predictor import get as get_predictor + + +@pytest.fixture(autouse=True) +def _registry() -> None: + """Register the built-ins that the name lookups resolve against.""" + register_builtin_components() + + +class TestFields: + """Declared fields and their defaults.""" + + def test_name_is_required(self) -> None: + with pytest.raises(ValidationError, match="name"): + PredictorConfig() # type: ignore[call-arg] + + def test_hyperparameter_space_defaults_to_none(self) -> None: + assert PredictorConfig(name="elasticNet").hyperparameter_space is None + + def test_name_is_coerced_to_str(self) -> None: + assert PredictorConfig.model_validate({"name": 5, "hyperparameter_space": None}).name == "5" + + def test_extra_fields_are_forbidden(self) -> None: + with pytest.raises(ValidationError, match="[Ee]xtra"): + PredictorConfig(name="elasticNet", not_a_field=1) # type: ignore[call-arg] + + +class TestRecipeNormalization: + """The ``before`` validator accepts the compact recipe notations.""" + + def test_bare_recipe_string(self) -> None: + assert PredictorConfig.model_validate("elasticNet").name == "elasticNet" + + def test_one_key_mapping_moves_values_into_the_space(self) -> None: + config = PredictorConfig.model_validate({"randomForest": {"n_estimators": 10}}) + + assert config.name == "randomForest" + assert config.hyperparameter_space is not None + assert config.hyperparameter_space["n_estimators"]["default"] == 10 + + def test_canonical_mapping_passes_through(self) -> None: + space = {"n_estimators": {"type": "int", "low": 2, "high": 20, "default": 4}} + + config = PredictorConfig.model_validate({"name": "randomForest", "hyperparameter_space": space}) + + assert config.hyperparameter_space is not None + assert config.hyperparameter_space["n_estimators"]["default"] == 4 + + def test_rejects_a_non_tunable_value(self) -> None: + with pytest.raises(ValidationError, match="non-tunable options"): + PredictorConfig.model_validate({"randomForest": {"not_a_knob": 1}}) + + +class TestHyperparameterSpaceValidation: + """The ``after`` validator enforces the search-space contract.""" + + def test_accepts_entries_declaring_a_default(self) -> None: + config = PredictorConfig.model_validate( + {"name": "randomForest", "hyperparameter_space": {"n_estimators": {"default": 5}}}, + ) + + assert config.hyperparameter_space is not None + + def test_rejects_an_entry_without_a_default(self) -> None: + with pytest.raises(ValidationError, match="default"): + PredictorConfig.model_validate( + {"name": "randomForest", "hyperparameter_space": {"n_estimators": {"low": 1, "high": 5}}}, + ) + + def test_rejects_a_non_mapping_entry(self) -> None: + with pytest.raises(ValidationError, match="n_estimators"): + PredictorConfig.model_validate( + {"name": "randomForest", "hyperparameter_space": {"n_estimators": 5}}, + ) + + def test_error_names_the_offending_config(self) -> None: + with pytest.raises(ValidationError, match=r"PredictorConfig\('randomForest'\).hyperparameter_space"): + PredictorConfig.model_validate( + {"name": "randomForest", "hyperparameter_space": {"n_estimators": {"low": 1}}}, + ) + + def test_an_empty_space_is_accepted(self) -> None: + config = PredictorConfig.model_validate({"name": "elasticNet", "hyperparameter_space": {}}) + + assert config.hyperparameter_space == {} + + +class TestImmutability: + """The model is frozen and its mapping field is deeply frozen.""" + + def test_rejects_field_assignment(self) -> None: + config = PredictorConfig(name="elasticNet") + + with pytest.raises(ValidationError, match="frozen"): + config.name = "randomForest" + + def test_hyperparameter_space_is_a_read_only_view(self) -> None: + config = PredictorConfig.model_validate({"name": "randomForest", "hyperparameter_space": {"a": {"default": 1}}}) + + assert isinstance(config.hyperparameter_space, MappingProxyType) + with pytest.raises(TypeError): + config.hyperparameter_space["b"] = {"default": 2} # type: ignore[index] + + def test_nested_space_entries_are_frozen(self) -> None: + config = PredictorConfig.model_validate({"name": "randomForest", "hyperparameter_space": {"a": {"default": 1}}}) + + assert config.hyperparameter_space is not None + with pytest.raises(TypeError): + config.hyperparameter_space["a"]["default"] = 2 # type: ignore[index] + + def test_is_hashable(self) -> None: + assert hash(PredictorConfig(name="elasticNet")) == hash(PredictorConfig(name="elasticNet")) + + def test_dumps_a_plain_mapping(self) -> None: + config = PredictorConfig.model_validate({"name": "randomForest", "hyperparameter_space": {"a": {"default": 1}}}) + + dumped = config.model_dump(mode="json") + + assert dumped == {"name": "randomForest", "hyperparameter_space": {"a": {"default": 1}}} + assert isinstance(dumped["hyperparameter_space"], dict) + + +class TestCreateInstance: + """Instantiation through the predictor registry.""" + + def test_builds_the_registered_class(self) -> None: + instance = PredictorConfig(name="elasticNet").create_instance({"alpha": 0.1, "l1_ratio": 0.5}) + + assert isinstance(instance, get_predictor("elasticNet")) + + def test_hyperparameters_default_to_empty(self) -> None: + instance = PredictorConfig(name="naiveMeanEffects").create_instance() + + assert instance is not None + + def test_unknown_name_raises_at_instantiation_time(self) -> None: + config = PredictorConfig.model_construct(name="notAPredictor", hyperparameter_space=None) + + with pytest.raises(ValueError, match="notAPredictor"): + config.create_instance() diff --git a/tests/models/config/test_predictor_parse.py b/tests/models/config/test_predictor_parse.py new file mode 100644 index 000000000..21d5892d1 --- /dev/null +++ b/tests/models/config/test_predictor_parse.py @@ -0,0 +1,64 @@ +"""Tests for compact predictor config parsing.""" + +from __future__ import annotations + +import pytest + +from drevalpy.models.config._predictor_parse import normalize_predictor_config +from drevalpy.registry._builtins import register_builtin_components + + +@pytest.fixture(autouse=True) +def _registry() -> None: + """Register the built-in components the one-key notation looks up.""" + register_builtin_components() + + +def test_normalize_string_shorthand() -> None: + payload = normalize_predictor_config("randomForest") + assert payload == {"name": "randomForest"} + + +def test_normalize_one_key_mapping() -> None: + payload = normalize_predictor_config({"randomForest": {"n_estimators": 10}}) + assert payload["name"] == "randomForest" + assert payload["hyperparameter_space"]["n_estimators"]["default"] == 10 + + +def test_normalize_one_key_mapping_may_be_null() -> None: + assert normalize_predictor_config({"randomForest": None}) == {"name": "randomForest"} + + +def test_normalize_canonical_mapping_passes_through() -> None: + """A mapping that already names its predictor normalizes to itself.""" + space = {"n_estimators": {"type": "int", "low": 2, "high": 20, "default": 4}} + payload = normalize_predictor_config({"name": "randomForest", "hyperparameter_space": space}) + assert payload == {"name": "randomForest", "hyperparameter_space": space} + + +def test_explicit_space_wins_over_a_loose_value() -> None: + space = {"n_estimators": {"type": "int", "low": 2, "high": 20, "default": 4}} + payload = normalize_predictor_config( + {"randomForest": {"hyperparameter_space": space, "n_estimators": 10}}, + ) + assert payload["hyperparameter_space"]["n_estimators"]["default"] == 4 + + +def test_non_tunable_values_are_rejected() -> None: + with pytest.raises(ValueError, match="do not accept non-tunable options \\('not_a_knob'\\)"): + normalize_predictor_config({"randomForest": {"not_a_knob": 1}}) + + +def test_one_key_body_must_be_a_mapping() -> None: + with pytest.raises(ValueError, match="must be a mapping when provided"): + normalize_predictor_config({"randomForest": 5}) + + +def test_normalize_rejects_invalid_shape() -> None: + with pytest.raises(TypeError, match="string or mapping"): + normalize_predictor_config(123) + + +def test_mapping_without_name_or_one_key_shape_is_rejected() -> None: + with pytest.raises(ValueError, match="string, one-key mapping, or dict with 'name'"): + normalize_predictor_config({"hyperparameter_space": {}, "extra": 1}) diff --git a/tests/models/config/test_predictor_traits.py b/tests/models/config/test_predictor_traits.py new file mode 100644 index 000000000..71231f2c2 --- /dev/null +++ b/tests/models/config/test_predictor_traits.py @@ -0,0 +1,70 @@ +"""Tests for drevalpy.models.config._predictor_traits.""" + +from __future__ import annotations + +import pytest + +from drevalpy.models.config import PredictorConfig +from drevalpy.models.config._predictor_traits import ( + needs_identity_drug_routing, + scope, +) +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.types.enums.model_scope import ModelScope + + +@pytest.fixture(autouse=True) +def _register_builtins() -> None: + register_builtin_components() + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("elasticNet", ModelScope.MULTI_DRUG), + ("naiveMean", ModelScope.MULTI_DRUG), + ("singleDrugElasticNet", ModelScope.SINGLE_DRUG), + ("singleDrugRandomForest", ModelScope.SINGLE_DRUG), + ], +) +def test_scope_for_predictor_reads_the_class_declaration(name: str, expected: ModelScope) -> None: + assert scope(name) is expected + + +def test_scope_for_predictor_rejects_an_unknown_name() -> None: + with pytest.raises(ValueError, match="Unknown Predictor"): + scope("noSuchPredictor") + + +@pytest.mark.parametrize( + "slot", + [ + "singleDrugElasticNet", + {"singleDrugElasticNet": None}, + {"name": "singleDrugElasticNet"}, + {"singleDrugElasticNet": {"alpha": 0.5}}, + PredictorConfig(name="singleDrugElasticNet"), + ], +) +def test_routing_is_needed_for_every_predictor_spelling(slot: object) -> None: + assert needs_identity_drug_routing(slot) is True + + +def test_routing_is_not_needed_for_a_multi_drug_predictor() -> None: + assert needs_identity_drug_routing("elasticNet") is False + + +@pytest.mark.parametrize( + "slot", + [ + None, + 42, + [], + "noSuchPredictor", + {"name": "noSuchPredictor"}, + {"first": None, "second": None}, + {"name": "singleDrugElasticNet", "unexpected": 1}, + ], +) +def test_routing_is_not_needed_for_unusable_slots(slot: object) -> None: + assert needs_identity_drug_routing(slot) is False diff --git a/tests/models/config/test_recipe.py b/tests/models/config/test_recipe.py new file mode 100644 index 000000000..3aabd18ab --- /dev/null +++ b/tests/models/config/test_recipe.py @@ -0,0 +1,302 @@ +"""Tests for the recipe language: the parsing grammar, expansion, and the formatter. + +Expansion is purely structural, so nothing here needs the component registry. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.models.config._recipe import ( + expand_featurizer_recipe, + format_model_recipe, + parse_featurizer_atoms, + parse_model_recipe, +) + + +@pytest.mark.parametrize( + ("recipe", "expected"), + [ + ("scaledGeneExpression", [("scaledGeneExpression", None)]), + ("raw[expression]", [("raw", "expression")]), + ("raw[copy_number_variation_gistic]", [("raw", "copy_number_variation_gistic")]), + ("fingerprints+identity", [("fingerprints", None), ("identity", None)]), + ("raw[expression]+raw[mutations]", [("raw", "expression"), ("raw", "mutations")]), + ( + "raw[expression]+pca[proteomics]+scaledGeneExpression", + [("raw", "expression"), ("pca", "proteomics"), ("scaledGeneExpression", None)], + ), + (" raw[expression] + raw[mutations] ", [("raw", "expression"), ("raw", "mutations")]), + ], + ids=["bare", "bracket", "long-view", "two-bare", "two-bracket", "three-mixed", "whitespace"], +) +def test_featurizer_atoms_are_parsed(recipe: str, expected: list[tuple[str, str | None]]) -> None: + """Every valid featurizer notation resolves to its ``(name, view)`` atoms. + + :param recipe: Recipe string to parse. + :param expected: Expected ``(name, view)`` pairs. + """ + assert parse_featurizer_atoms(recipe) == expected + + +def test_plus_inside_brackets_stays_one_atom() -> None: + """A ``+`` within a view must not split the recipe, so the bad view is named in the error.""" + assert parse_featurizer_atoms("raw[a+b]") == [("raw", "a+b")] + + +@pytest.mark.parametrize( + "recipe", + ["a+", "+a", "a++b", "a+[", "]+a", "raw[a:b]", "[x]", "a[b][c]", "", " ", "raw[]"], +) +def test_featurizer_recipe_rejects_malformed_shapes(recipe: str) -> None: + """Shapes that are not ``name`` or ``name[view]`` joined by ``+`` are rejected up front. + + :param recipe: Malformed recipe string. + """ + with pytest.raises(ValueError, match="Malformed featurizer recipe"): + parse_featurizer_atoms(recipe) + + +def test_featurizer_error_mentions_non_empty_atoms() -> None: + """The message keeps the wording that callers and older tests match on.""" + with pytest.raises(ValueError, match="non-empty"): + parse_featurizer_atoms("scaledGeneExpression+") + + +def test_unregistered_names_are_left_to_the_registry() -> None: + """Shape-valid names pass through; existence is the registry's question, not the grammar's.""" + assert parse_featurizer_atoms("notARegisteredName") == [("notARegisteredName", None)] + + +@pytest.mark.parametrize( + ("recipe", "expected"), + [ + ("scaledGeneExpression", {"name": "scaledGeneExpression"}), + ("raw[expression]", {"name": "raw", "view": "expression"}), + ( + "landmarkGenes+normalizedProteomics", + { + "name": "concatFeaturizers", + "featurizers": [{"name": "landmarkGenes"}, {"name": "normalizedProteomics"}], + }, + ), + ( + "raw[expression]+pca[methylation]", + { + "name": "concatFeaturizers", + "featurizers": [ + {"name": "raw", "view": "expression"}, + {"name": "pca", "view": "methylation"}, + ], + }, + ), + ], + ids=["bare", "bracket", "two-bare", "two-bracket"], +) +def test_recipe_expands_to_the_mapping_yaml_would_spell_out(recipe: str, expected: dict) -> None: + """A recipe is shorthand for a field mapping, so expansion yields exactly that mapping. + + :param recipe: Recipe string to expand. + :param expected: Expected field mapping. + """ + assert expand_featurizer_recipe(recipe) == expected + + +def test_expansion_rejects_a_blank_token() -> None: + with pytest.raises(ValueError, match="non-empty string"): + expand_featurizer_recipe(" ") + + +@pytest.mark.parametrize( + "recipe", + [ + "scaledGeneExpression[gene_expression]", + "raw[not_a_view]", + "raw[custom_matrix]", + "notARegisteredName", + ], + ids=["bracket-on-non-view-featurizer", "unknown-view", "custom-view", "unknown-name"], +) +def test_expansion_asks_no_semantic_questions(recipe: str) -> None: + """Expansion transcribes; it does not judge. + + Writing a view in brackets says no more than writing one as a ``view`` key, so neither is + checked here. Whether the featurizer exists, takes a view, or can supply that view is + settled downstream, identically for both notations. + + :param recipe: Recipe whose meaning is questionable but whose shape is fine. + """ + payload = expand_featurizer_recipe(recipe) + assert payload["name"] + + +def test_expansion_keeps_the_view_as_written() -> None: + """Resolving is normalization's job, so a bracket and a spelled-out view arrive alike.""" + assert expand_featurizer_recipe("raw[expression]")["view"] == "expression" + + +def test_a_plus_inside_brackets_stays_in_its_atom() -> None: + """The view keeps the ``+``, so a later error names the view, not a truncated featurizer.""" + assert expand_featurizer_recipe("raw[a+b]") == {"name": "raw", "view": "a+b"} + + +def _slots(spec: str) -> tuple[str | None, str | None, str]: + """Read a recipe's payload back as the triple the formatter takes. + + Each featurizer slot is expanded into a mapping, so the slot's name stands in for it; a + concat slot is named after the node it expands to. + + :param spec: Model recipe string. + :returns: Cell-line slot name, drug slot name, and predictor name. + """ + payload = parse_model_recipe(spec) + cell = payload["cell_line_featurizer"] + drug = payload["drug_featurizer"] + return ( + cell["name"] if cell is not None else None, + drug["name"] if drug is not None else None, + payload["predictor"], + ) + + +def test_model_recipe_payload_carries_exactly_the_config_field_keys() -> None: + """The mapping goes straight into ``from_dict``, so it names config fields and nothing else.""" + assert parse_model_recipe("raw[expression]+landmarkGenes:fingerprints:randomForest") == { + "cell_line_featurizer": { + "name": "concatFeaturizers", + "featurizers": [ + {"name": "raw", "view": "expression"}, + {"name": "landmarkGenes"}, + ], + }, + "drug_featurizer": {"name": "fingerprints"}, + "predictor": "randomForest", + } + + +def test_both_slots_are_expanded_the_same_way() -> None: + """No registry is involved, so a slot is transcribed the same wherever it appears.""" + payload = parse_model_recipe("raw[expression]:raw[expression]:randomForest") + assert payload["cell_line_featurizer"] == payload["drug_featurizer"] == {"name": "raw", "view": "expression"} + + +def test_two_part_model_recipe_leaves_the_drug_slot_unset() -> None: + """``ModelConfig`` injects the routing featurizer, so the payload states no drug slot.""" + assert parse_model_recipe("scaledGeneExpression:singleDrugElasticNet") == { + "cell_line_featurizer": {"name": "scaledGeneExpression"}, + "drug_featurizer": None, + "predictor": "singleDrugElasticNet", + } + + +@pytest.mark.parametrize( + ("spec", "expected"), + [ + ("elasticNet", (None, None, "elasticNet")), + ("scaledGeneExpression:singleDrugElasticNet", ("scaledGeneExpression", None, "singleDrugElasticNet")), + ( + "scaledGeneExpression:fingerprints:elasticNet", + ("scaledGeneExpression", "fingerprints", "elasticNet"), + ), + ( + "raw[expression]+raw[mutations]:fingerprints:randomForest", + ("concatFeaturizers", "fingerprints", "randomForest"), + ), + ( + " scaledGeneExpression : fingerprints : elasticNet ", + ("scaledGeneExpression", "fingerprints", "elasticNet"), + ), + ], + ids=["predictor-only", "two-slot", "three-slot", "concat-slot", "whitespace"], +) +def test_model_recipe_slots_are_split(spec: str, expected: tuple[str | None, str | None, str]) -> None: + """Each slot becomes its own featurizer mapping, stripped of padding. + + :param spec: Model recipe string. + :param expected: Expected ``(cell_line, drug, predictor)`` names. + """ + assert _slots(spec) == expected + + +@pytest.mark.parametrize("spec", ["a:b:c:d", " :b:c", ":x", "a::b", "a:", "raw[a:b]:x:y"]) +def test_model_recipe_rejects_malformed_specs(spec: str) -> None: + """Wrong slot counts and empty slots are rejected by the grammar itself. + + :param spec: Malformed model recipe string. + """ + with pytest.raises(ValueError, match="Malformed model recipe"): + parse_model_recipe(spec) + + +@pytest.mark.parametrize("spec", ["", " "]) +def test_model_recipe_rejects_blank_specs(spec: str) -> None: + """A blank recipe is reported as empty rather than as a grammar failure. + + :param spec: Blank model recipe string. + """ + with pytest.raises(ValueError, match="must be a non-empty string"): + parse_model_recipe(spec) + + +def test_colon_inside_a_view_is_not_a_slot_separator() -> None: + """A colon inside brackets is not treated as a slot separator.""" + with pytest.raises(ValueError, match="Malformed model recipe"): + parse_model_recipe("raw[a:b]:fingerprints:randomForest") + + +@pytest.mark.parametrize( + ("slots", "expected"), + [ + ((None, None, "naiveMean"), "naiveMean"), + (("scaledGeneExpression", None, "singleDrugElasticNet"), "scaledGeneExpression:singleDrugElasticNet"), + ( + ("scaledGeneExpression", "fingerprints", "elasticNet"), + "scaledGeneExpression:fingerprints:elasticNet", + ), + ( + ("raw[expression]+raw[mutations]", "fingerprints", "randomForest"), + "raw[expression]+raw[mutations]:fingerprints:randomForest", + ), + ], + ids=["predictor-only", "two-slot", "three-slot", "concat-slot"], +) +def test_model_recipe_is_formatted_from_slots(slots: tuple[str | None, str | None, str], expected: str) -> None: + """Formatting writes the grammar that parsing reads. + + :param slots: ``(cell_line, drug, predictor)`` names to join. + :param expected: Expected recipe string. + """ + assert format_model_recipe(*slots) == expected + + +@pytest.mark.parametrize( + "slots", + [ + (None, None, "naiveMean"), + ("scaledGeneExpression", None, "singleDrugElasticNet"), + ("scaledGeneExpression", "fingerprints", "elasticNet"), + ], + ids=["predictor-only", "two-slot", "three-slot"], +) +def test_formatted_recipes_parse_back_to_the_same_slots(slots: tuple[str | None, str | None, str]) -> None: + """A formatted recipe reads back as the slots it was written from. + + A concat slot has no round trip to check: it expands to a ``concatFeaturizers`` node whose + name is not the recipe it came from, which is the same reason ``ModelConfig.model_id`` + cannot name one. + + :param slots: ``(cell_line, drug, predictor)`` names to join. + """ + assert _slots(format_model_recipe(*slots)) == slots + + +def test_formatting_requires_a_predictor() -> None: + with pytest.raises(ValueError, match="predictor is required"): + format_model_recipe("scaledGeneExpression", "fingerprints", "") + + +def test_formatting_rejects_a_drug_slot_without_a_cell_line_slot() -> None: + """A recipe fills its slots left to right, so this pair has no representation.""" + with pytest.raises(ValueError, match="cell_line is required when drug is set"): + format_model_recipe(None, "fingerprints", "elasticNet") diff --git a/tests/models/config/test_resolved.py b/tests/models/config/test_resolved.py new file mode 100644 index 000000000..4449da877 --- /dev/null +++ b/tests/models/config/test_resolved.py @@ -0,0 +1,43 @@ +"""Tests for ResolvedModelConfig template/value separation.""" + +from __future__ import annotations + +from drevalpy.models.config import ModelConfig, ResolvedModelConfig, from_spec +from drevalpy.models.tuning.search_space import resolve_model_config +from drevalpy.models.zoo import zoo_model_config +from drevalpy.registry._builtins import register_builtin_components + + +def test_resolve_model_config_separates_template_and_values() -> None: + register_builtin_components() + template = from_spec("ElasticNet") + assert isinstance(template, ModelConfig) + resolved = resolve_model_config(template) + assert isinstance(resolved, ResolvedModelConfig) + assert resolved.template is template or resolved.template.model_dump() == template.model_dump() + assert resolved.template.predictor.name == "elasticNet" + assert resolved.predictor_values()["alpha"] == 1.0 + assert "predictor.elasticNet.alpha" in resolved.values + + +def test_explicit_hyperparameters_override_defaults() -> None: + register_builtin_components() + resolved = zoo_model_config("ElasticNet", {"alpha": 0.25}) + assert isinstance(resolved, ResolvedModelConfig) + assert resolved.template.predictor.name == "elasticNet" + assert resolved.predictor_values()["alpha"] == 0.25 + assert resolved.values["predictor.elasticNet.alpha"] == 0.25 + + +def test_featurizer_values_use_qualified_selectors() -> None: + register_builtin_components() + resolved = from_spec( + "pca[expression]+pca[proteomics]:fingerprints:randomForest", + hyperparameters={ + "cell_line_featurizer.pca[expression].n_components": 32, + "cell_line_featurizer.pca[proteomics].n_components": 16, + }, + ) + assert isinstance(resolved, ResolvedModelConfig) + assert resolved.featurizer_values("cell_line", "pca[expression]")["n_components"] == 32 + assert resolved.featurizer_values("cell_line", "pca[proteomics]")["n_components"] == 16 diff --git a/tests/models/config/test_space_defaults.py b/tests/models/config/test_space_defaults.py new file mode 100644 index 000000000..76e439212 --- /dev/null +++ b/tests/models/config/test_space_defaults.py @@ -0,0 +1,58 @@ +"""Tests for drevalpy.models.config._space_defaults.""" + +from __future__ import annotations + +from typing import Any + +from drevalpy.models.config._space_defaults import split_space_and_options + + +class _Component: + """Stand-in component declaring a mixed hyperparameter space.""" + + @staticmethod + def get_hyperparameter_space() -> dict[str, Any]: + """Declare one tunable entry and one non-mapping entry. + + :returns: The declared space. + """ + return { + "alpha": {"type": "float", "low": 0.0, "high": 1.0, "default": 0.5}, + "not_a_spec": "opaque", + } + + +def test_declared_tunable_moves_its_default() -> None: + space, options = split_space_and_options(_Component, {"alpha": 0.9}) + assert space["alpha"]["default"] == 0.9 + assert options == {} + + +def test_undeclared_value_becomes_an_option() -> None: + space, options = split_space_and_options(_Component, {"device": "cpu"}) + assert options == {"device": "cpu"} + assert space["alpha"]["default"] == 0.5 + + +def test_non_mapping_spec_is_treated_as_an_option_target() -> None: + """A declared key whose spec is not a mapping has no ``default`` to move.""" + _, options = split_space_and_options(_Component, {"not_a_spec": 3}) + assert options == {"not_a_spec": 3} + + +def test_the_full_declared_space_is_returned() -> None: + """The result records every declared entry, not only the touched ones.""" + space, _ = split_space_and_options(_Component, {"alpha": 0.1}) + assert set(space) == {"alpha", "not_a_spec"} + + +def test_the_declared_space_is_not_mutated() -> None: + original = _Component.get_hyperparameter_space() + split_space_and_options(_Component, {"alpha": 0.9}) + assert _Component.get_hyperparameter_space() == original + + +def test_empty_values_leave_the_defaults_alone() -> None: + space, options = split_space_and_options(_Component, {}) + assert space["alpha"]["default"] == 0.5 + assert options == {} diff --git a/tests/models/config/test_spec.py b/tests/models/config/test_spec.py new file mode 100644 index 000000000..a8571ba48 --- /dev/null +++ b/tests/models/config/test_spec.py @@ -0,0 +1,323 @@ +"""Tests for drevalpy.models.factory (spec helpers). + +Recipe and zoo resolution is what this module supports, so most cases drive it through +``from_spec``, which composes it. Tests naming ``reject_unknown_spec`` or ``zoo_config`` pin the +individual steps. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from drevalpy.models.config import from_spec, validate +from drevalpy.models.config.model import ModelConfig +from drevalpy.models.factory import reject_unknown_spec, zoo_config +from drevalpy.models.zoo import clear_external_zoo +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry._extensions import load_extensions +from drevalpy.types.enums.prediction_mode import PredictionMode +from tests.registry._helpers import restore_component_registries + + +@pytest.fixture(autouse=True) +def _register_components() -> Iterator[None]: + """Populate the registries before each test, and evict whatever a test added. + + ``test_external_extension_resolved_through_spec`` loads an extension, which + registers into the process-global component registries and the external zoo. + ``register_builtin_components`` only ever *adds*, so without an explicit + eviction that extension outlives its test - and + ``BUILTIN_CELL_LINE_FEATURIZER_NAMES`` and friends in + :mod:`drevalpy.registry._builtins` are lazy singletons that cache on first + access, so whichever test resolves them next counts the extension as a + built-in. That is how a leak here fails + ``tests/docs/test_docs_structure.py`` whenever it happens to run after this + file rather than before it. + """ + register_builtin_components() + yield + restore_component_registries() + clear_external_zoo() + + +def test_build_model_config_from_zoo_name() -> None: + config = from_spec("ElasticNet") + assert isinstance(config, ModelConfig) + assert config.cell_line_featurizer is not None + assert config.cell_line_featurizer.name == "scaledGeneExpression" + assert config.drug_featurizer is not None + assert config.predictor.name == "elasticNet" + + +def test_build_model_config_from_zoo_name_with_hyperparameters() -> None: + from drevalpy.models.config import ResolvedModelConfig + + config = from_spec("ElasticNet", hyperparameters={"alpha": 0.2}) + assert isinstance(config, ResolvedModelConfig) + assert config.predictor_values()["alpha"] == 0.2 + + +def test_zoo_name_prediction_mode_is_threaded_but_ignored_with_hyperparameters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Zoo presets honour ``prediction_mode`` only when no hyperparameters are given. + + The hyperparameters path returns the resolved config without applying the + requested mode; this asserts that long-standing quirk so a future fix is a + deliberate change rather than an accident. + + :param monkeypatch: Pytest fixture used to widen the predictor's supported modes. + """ + from drevalpy.models.config import ResolvedModelConfig + from drevalpy.registry.predictor import get as get_predictor + + monkeypatch.setattr(get_predictor("elasticNet"), "supported_modes", frozenset(PredictionMode)) + + template = from_spec("ElasticNet", prediction_mode=PredictionMode.CLASSIFICATION) + assert not isinstance(template, ResolvedModelConfig) + assert template.prediction_mode == PredictionMode.CLASSIFICATION + + resolved = from_spec( + "ElasticNet", + hyperparameters={"alpha": 0.2}, + prediction_mode=PredictionMode.CLASSIFICATION, + ) + assert isinstance(resolved, ResolvedModelConfig) + assert resolved.template.prediction_mode == PredictionMode.REGRESSION + + +def test_build_model_config_from_baseline_predictor_token() -> None: + config = from_spec("naiveMean") + assert isinstance(config, ModelConfig) + assert config.predictor.name == "naiveMean" + assert config.cell_line_featurizer is None + assert config.drug_featurizer is None + + +def test_build_model_config_from_recipe_triple() -> None: + config = from_spec("scaledGeneExpression:fingerprints:elasticNet") + assert isinstance(config, ModelConfig) + assert config.model_id == "scaledGeneExpression:fingerprints:elasticNet" + + +def test_single_drug_recipe_infers_scope_and_identity_routing() -> None: + config = from_spec("scaledGeneExpression:identity:singleDrugElasticNet") + assert isinstance(config, ModelConfig) + assert config.model_id == "scaledGeneExpression:singleDrugElasticNet" + assert config.scope.value == "single_drug" + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "identity" + + +def test_two_part_single_drug_recipe_matches_explicit_identity() -> None: + two_part = from_spec("scaledGeneExpression:singleDrugElasticNet") + three_part = from_spec("scaledGeneExpression:identity:singleDrugElasticNet") + assert isinstance(two_part, ModelConfig) + assert isinstance(three_part, ModelConfig) + assert two_part.model_id == three_part.model_id == "scaledGeneExpression:singleDrugElasticNet" + assert two_part.drug_featurizer is not None + assert two_part.drug_featurizer.name == "identity" + + +def test_two_part_multi_drug_recipe_rejected() -> None: + """A multi-drug predictor needs its drug featurizer named, as in an equivalent YAML file.""" + with pytest.raises(ValueError, match="Predictor 'elasticNet' requires a drug_featurizer"): + from_spec("scaledGeneExpression:elasticNet") + + +def test_build_model_config_from_recipe_triple_with_plus_concat() -> None: + config = from_spec("raw[expression]+raw[mutations]:fingerprints+identity:randomForest") + assert isinstance(config, ModelConfig) + assert config.cell_line_featurizer is not None + assert config.cell_line_featurizer.name == "concatFeaturizers" + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "concatFeaturizers" + assert config.predictor.name == "randomForest" + cell_children = config.cell_line_featurizer.featurizers + drug_children = config.drug_featurizer.featurizers + assert cell_children is not None and drug_children is not None + assert [child.name for child in cell_children] == ["raw", "raw"] + assert cell_children[0].view == "expression" + assert cell_children[1].view == "mutations" + assert [child.name for child in drug_children] == ["fingerprints", "identity"] + assert config.model_id == "concatFeaturizers:concatFeaturizers:randomForest" + + +def test_build_model_config_from_recipe_triple_with_bracket_views() -> None: + config = from_spec("raw[expression]+pca[proteomics]:identity:randomForest") + assert isinstance(config, ModelConfig) + assert config.cell_line_featurizer is not None + assert config.cell_line_featurizer.name == "concatFeaturizers" + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "identity" + assert config.predictor.name == "randomForest" + cell_children = config.cell_line_featurizer.featurizers + assert cell_children is not None + assert cell_children[0].name == "raw" + assert cell_children[0].view == "expression" + assert cell_children[1].name == "pca" + assert cell_children[1].view == "proteomics" + + +def test_build_model_config_from_literature_zoo_name() -> None: + config = from_spec("DIPK") + assert isinstance(config, ModelConfig) + assert config.predictor.name == "dipk" + assert config.cell_line_featurizer is not None + assert config.cell_line_featurizer.name == "concatFeaturizers" + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "molgnet" + + +def test_prediction_mode_accepts_a_string_or_the_enum() -> None: + """The public entry point takes either, so callers need not import the enum.""" + from_string = from_spec("ElasticNet", prediction_mode="regression") + from_enum = from_spec("ElasticNet", prediction_mode=PredictionMode.REGRESSION) + assert isinstance(from_string, ModelConfig) + assert isinstance(from_enum, ModelConfig) + assert from_string.prediction_mode == from_enum.prediction_mode == PredictionMode.REGRESSION + + +def test_invalid_prediction_mode_string_is_rejected() -> None: + with pytest.raises(ValueError, match="nonsense"): + from_spec("ElasticNet", prediction_mode="nonsense") + + +def test_unknown_spec_raises_helpful_error() -> None: + with pytest.raises(ValueError, match="Unknown model spec"): + from_spec("definitelyNotARealModelName") + + +def test_reject_unknown_spec_passes_a_known_builtin_predictor_through() -> None: + """A predictor drevalpy knows is left for ``from_dict`` to resolve and report on.""" + reject_unknown_spec("randomForest") # should not raise + + +def test_reject_unknown_spec_passes_an_unregistered_builtin_through(monkeypatch: pytest.MonkeyPatch) -> None: + """An optional or literature predictor that never registered keeps the registry's own error. + + Registration is faked away rather than relying on a genuinely missing dependency, so the + built-in catalog is what has to let the name through. + + :param monkeypatch: Pytest fixture used to empty the registered-predictor list. + """ + monkeypatch.setattr("drevalpy.models.factory.list_predictors", lambda: []) + reject_unknown_spec("dipk") # should not raise + + +def test_reject_unknown_spec_passes_a_registered_non_builtin_through(monkeypatch: pytest.MonkeyPatch) -> None: + """An extension predictor is not in the built-in catalog, so the registry has to accept it. + + :param monkeypatch: Pytest fixture used to deny that the name is a built-in. + """ + monkeypatch.setattr("drevalpy.models.factory.is_known_builtin_predictor", lambda name: False) + reject_unknown_spec("randomForest") # should not raise + + +def test_reject_unknown_spec_reports_a_typo_as_an_unknown_spec() -> None: + """A token that names neither a preset nor a predictor is most likely a mistyped zoo name.""" + with pytest.raises(ValueError, match="Unknown model spec 'definitelyNotARealModelName'"): + reject_unknown_spec("definitelyNotARealModelName") + + +def test_zoo_config_returns_none_for_a_name_that_is_not_a_preset() -> None: + """Reporting a miss rather than raising is what lets ``from_spec`` fall through.""" + assert zoo_config("definitelyNotARealModelName", None, PredictionMode.REGRESSION) is None + assert zoo_config("ElasticNet", None, PredictionMode.REGRESSION) is not None + + +def test_bare_predictor_requiring_featurizers_reports_the_missing_featurizers() -> None: + """A registered predictor that needs featurizers is a config error, not an unknown spec.""" + with pytest.raises(ValueError, match="Predictor 'randomForest' requires featurizers"): + from_spec("randomForest") + + +def test_malformed_recipe_keeps_the_grammar_error() -> None: + """With a colon the intent is unambiguous, so the grammar's message survives.""" + with pytest.raises(ValueError, match="Malformed model recipe"): + from_spec("scaledGeneExpression:fingerprints:elasticNet:extra") + + +def test_unknown_predictor_in_a_recipe_names_the_predictor() -> None: + with pytest.raises(ValueError, match="Unknown Predictor: 'bogusPredictor'"): + from_spec("scaledGeneExpression:fingerprints:bogusPredictor") + + +def test_recipe_validation_error_names_the_recipe() -> None: + with pytest.raises(ValueError, match=r"in recipe 'bogusFeaturizer:fingerprints:elasticNet'"): + from_spec("bogusFeaturizer:fingerprints:elasticNet") + + +def test_zoo_name_wins_over_a_bare_predictor_name() -> None: + """``ElasticNet`` is a preset; the recipe path would reject it for missing featurizers.""" + config = from_spec("ElasticNet") + assert isinstance(config, ModelConfig) + assert config.cell_line_featurizer is not None + + +def test_external_extension_resolved_through_spec(tmp_path: Path) -> None: + ext_dir = tmp_path / "ext" + ext_dir.mkdir() + (ext_dir / "components.py").write_text( + """ +import numpy as np +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.types.data.batch.feature_block import numeric_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.registry.predictor import register as register_predictor +from drevalpy.registry.cell_line_featurizer import register as register_cell_line_featurizer + +@register_cell_line_featurizer( + "resolverCellLine", + description="ext", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class ResolverCellLineFeaturizer(CellLineFeaturizer): + entity_id_only = True + + def _fit(self, source, *, entity_ids=None, pair_expanded_ids=None, pair_expanded_es_ids=None): + self._output_dim = 1 + return self + def _transform_blocks(self, source, entity_ids): + return {"ext": numeric_feature_block(np.ones((len(entity_ids), 1), dtype=np.float32))} + @property + def output_dim(self): + return self._output_dim + +@register_predictor( + "resolverPredictor", + description="ext", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class ResolverPredictor(FeatureFreePredictor): + def _fit(self, batch: ModelInputBatch) -> None: + if batch.response is None: + msg = "response required" + raise ValueError(msg) + self._mean = float(np.mean(batch.response)) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.full(batch.n_pairs, self._mean, dtype=np.float64) +""", + encoding="utf-8", + ) + zoo_file = tmp_path / "external_zoo.yaml" + zoo_file.write_text( + """ +resolverEntry: + predictor: resolverPredictor +""", + encoding="utf-8", + ) + load_extensions(directories=[ext_dir], zoo_files=[zoo_file]) + config = from_spec("resolverEntry") + assert isinstance(config, ModelConfig) + assert config.cell_line_featurizer is None + assert config.predictor.name == "resolverPredictor" + validate(config) diff --git a/tests/models/config/test_validation.py b/tests/models/config/test_validation.py new file mode 100644 index 000000000..3ace05bb6 --- /dev/null +++ b/tests/models/config/test_validation.py @@ -0,0 +1,184 @@ +"""Tests for ModelConfig construction-time validation.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from pydantic import ValidationError + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.models.config import ( + CellLineFeaturizerConfig, + DrugFeaturizerConfig, + ModelConfig, + PredictorConfig, + validate, +) +from drevalpy.types.data.batch.feature_block import BlockSpec +from tests.models.config._stubs import ( + register_block_predictor_stub, + register_dense_trio, + register_feature_free_predictor_stub, + register_featurizer_stub, + register_matrix_predictor_stub, +) +from tests.registry._helpers import isolated_component_registries + + +@pytest.fixture(autouse=True) +def _clear_registries() -> Iterator[None]: + yield from isolated_component_registries() + + +def _dense_model_config(**overrides) -> ModelConfig: + """Build the valid dense triple, with named slots replaceable per test. + + :param overrides: Slot values replacing the dense defaults. + :returns: A ``ModelConfig`` over the registered stubs. + """ + slots = { + "cell_line_featurizer": CellLineFeaturizerConfig(name="denseCellLine", view="gene_expression"), + "drug_featurizer": DrugFeaturizerConfig(name="denseDrug", view="fingerprints"), + "predictor": PredictorConfig(name="densePred"), + } + slots.update(overrides) + return ModelConfig(**slots) + + +def test_valid_dense_config_passes() -> None: + register_dense_trio() + + validate(_dense_model_config()) + + +def test_unknown_cell_line_featurizer_fails() -> None: + register_dense_trio() + with pytest.raises((ValueError, ValidationError), match="Unknown Cell line featurizer"): + _dense_model_config(cell_line_featurizer=CellLineFeaturizerConfig(name="missing")) + + +def test_wrong_registry_is_coerced_by_slot_subclasses() -> None: + register_dense_trio() + config = ModelConfig.model_validate( + { + "cell_line_featurizer": {"name": "denseCellLine", "registry": "drug"}, + "drug_featurizer": {"name": "denseDrug", "registry": "cell_line"}, + "predictor": {"name": "densePred"}, + } + ) + assert config.cell_line_featurizer is not None + assert config.cell_line_featurizer.registry == "cell_line" + assert config.drug_featurizer is not None + assert config.drug_featurizer.registry == "drug" + validate(config) + + +def test_graph_featurizer_with_matrix_predictor_fails() -> None: + register_featurizer_stub("graphCellLine", side="cell_line", contract=FeatureFormat.GRAPH) + register_featurizer_stub("denseDrug", side="drug") + register_matrix_predictor_stub("densePred") + + with pytest.raises((ValueError, ValidationError), match="Cell line featurizer contract|numeric_matrix"): + _dense_model_config(cell_line_featurizer=CellLineFeaturizerConfig(name="graphCellLine")) + + +def test_graph_format_match_passes_for_block_predictor() -> None: + register_featurizer_stub("graphCellLine", side="cell_line", contract=FeatureFormat.GRAPH) + register_featurizer_stub("graphDrug", side="drug", contract=FeatureFormat.GRAPH) + register_block_predictor_stub( + "graphPred", + cell_line_contract=FeatureFormat.GRAPH, + drug_contract=FeatureFormat.GRAPH, + ) + + config = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="graphCellLine"), + drug_featurizer=DrugFeaturizerConfig(name="graphDrug"), + predictor=PredictorConfig(name="graphPred"), + ) + validate(config) + + +def test_block_schema_reports_missing_named_block() -> None: + register_featurizer_stub( + "cellBlocks", + side="cell_line", + output_block_specs=(BlockSpec("wrong_name", FeatureFormat.NUMERIC_MATRIX),), + ) + register_featurizer_stub( + "drugBlocks", + side="drug", + output_block_specs=(BlockSpec("fingerprints", FeatureFormat.NUMERIC_MATRIX),), + ) + register_block_predictor_stub( + "blockPred", + required_cell_line_block_specs=(BlockSpec("gene_expression", FeatureFormat.NUMERIC_MATRIX),), + required_drug_block_specs=(BlockSpec("fingerprints", FeatureFormat.NUMERIC_MATRIX),), + ) + + with pytest.raises((ValueError, ValidationError), match="blockPred.*gene_expression.*numeric_matrix.*wrong_name"): + ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="cellBlocks"), + drug_featurizer=DrugFeaturizerConfig(name="drugBlocks"), + predictor=PredictorConfig(name="blockPred"), + ) + + +def test_builtin_featurizer_declares_output_block_specs() -> None: + from drevalpy.components.featurizers.drug.fingerprints import FingerprintsFeaturizer + + assert FingerprintsFeaturizer.output_block_specs == (BlockSpec("fingerprints", FeatureFormat.NUMERIC_MATRIX),) + + +def test_feature_free_predictor_without_featurizers_passes() -> None: + register_feature_free_predictor_stub("naiveMean") + + config = ModelConfig( + cell_line_featurizer=None, + drug_featurizer=None, + predictor=PredictorConfig(name="naiveMean"), + ) + validate(config) + + +def test_feature_using_predictor_without_featurizers_fails() -> None: + register_dense_trio() + with pytest.raises((ValueError, ValidationError), match="requires featurizers"): + _dense_model_config(cell_line_featurizer=None, drug_featurizer=None) + + +def test_baseline_tag_does_not_allow_missing_featurizers() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + with pytest.raises((ValueError, ValidationError), match="requires featurizers"): + ModelConfig( + cell_line_featurizer=None, + drug_featurizer=None, + predictor=PredictorConfig(name="naiveMeanEffects"), + ) + + +def test_single_drug_scope_requires_identity_drug_featurizer() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + with pytest.raises((ValueError, ValidationError), match="requires drug_featurizer='identity'"): + ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=DrugFeaturizerConfig(name="fingerprints"), + predictor=PredictorConfig(name="singleDrugElasticNet"), + ) + + +def test_single_drug_scope_accepts_identity_routing_featurizer() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig(name="scaledGeneExpression"), + drug_featurizer=DrugFeaturizerConfig(name="identity"), + predictor=PredictorConfig(name="singleDrugElasticNet"), + ) + validate(config) diff --git a/tests/models/config/test_view_resolution.py b/tests/models/config/test_view_resolution.py new file mode 100644 index 000000000..db90c61e5 --- /dev/null +++ b/tests/models/config/test_view_resolution.py @@ -0,0 +1,133 @@ +"""Tests for featurizer-to-view mapping and identity-only loading.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from drevalpy.models.config import CellLineFeaturizerConfig, DrugFeaturizerConfig, ModelConfig, PredictorConfig +from drevalpy.registry._builtins import register_builtin_components + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def _model_config(**kwargs: object) -> ModelConfig: + defaults: dict[str, object] = { + "predictor": PredictorConfig(name="randomForest"), + "cell_line_featurizer": CellLineFeaturizerConfig(name="scaledGeneExpression"), + "drug_featurizer": DrugFeaturizerConfig(name="fingerprints"), + } + defaults.update(kwargs) + return ModelConfig.model_validate(defaults) + + +def test_identity_featurizers_resolve_to_empty_views() -> None: + config = _model_config( + cell_line_featurizer=CellLineFeaturizerConfig.model_validate("identity"), + drug_featurizer=DrugFeaturizerConfig.model_validate("identity"), + ) + assert config.cell_line_entity_id_only() + assert config.drug_entity_id_only() + assert config.cell_line_views() == [] + assert config.drug_views() == [] + + +def test_constant_featurizers_resolve_to_empty_views() -> None: + config = _model_config( + cell_line_featurizer=CellLineFeaturizerConfig.model_validate("constant"), + drug_featurizer=DrugFeaturizerConfig.model_validate("constant"), + ) + assert config.cell_line_entity_id_only() + assert config.drug_entity_id_only() + assert config.cell_line_views() == [] + assert config.drug_views() == [] + + +def test_bracket_featurizers_resolve_canonical_views() -> None: + config = _model_config( + cell_line_featurizer=CellLineFeaturizerConfig.model_validate("raw[mutations]+pca[methylation]"), + ) + assert config.cell_line_views() == [ + "mutations", + "methylation", + ] + + +@pytest.mark.parametrize("name", ["landmarkGenes", "landmarkGenesReduced"]) +def test_landmark_featurizers_resolve_gene_expression(name: str) -> None: + config = _model_config( + cell_line_featurizer=CellLineFeaturizerConfig.model_validate(name), + ) + assert config.cell_line_views() == ["gene_expression"] + + +@pytest.mark.parametrize("name", ["molirOmics", "superfeltrOmics"]) +def test_multi_omics_featurizers_resolve_all_three_views(name: str) -> None: + config = _model_config( + cell_line_featurizer=CellLineFeaturizerConfig.model_validate(name), + ) + assert config.cell_line_views() == [ + "gene_expression", + "mutations", + "copy_number_variation_gistic", + ] + + +@pytest.mark.parametrize( + ("input_type", "expected"), + [("expression", "gene_expression"), ("mutations", "mutations")], +) +def test_sparsego_resolves_view_from_input_type(input_type: str, expected: str) -> None: + config = _model_config( + cell_line_featurizer=CellLineFeaturizerConfig( + name="sparsegoOntology", + options={"input_type": input_type}, + ), + ) + assert config.cell_line_views() == [expected] + + +def test_tissue_featurizer_resolves_no_omics_views() -> None: + config = _model_config( + cell_line_featurizer=CellLineFeaturizerConfig.model_validate("tissue"), + ) + assert config.cell_line_views() == [] + + +def test_view_override_is_honoured_over_declared_input_views() -> None: + config = _model_config( + cell_line_featurizer=CellLineFeaturizerConfig( + name="scaledGeneExpression", + options={"view": "proteomics"}, + ), + ) + assert config.cell_line_views() == ["proteomics"] + + +def test_fingerprint_featurizer_still_resolves_fingerprints_view() -> None: + config = _model_config( + drug_featurizer=DrugFeaturizerConfig.model_validate("fingerprints"), + ) + assert not config.drug_entity_id_only() + assert config.drug_views() == ["morgan_fingerprint"] + + +def test_view_featurizer_resolves_options_view() -> None: + config = _model_config( + drug_featurizer=DrugFeaturizerConfig( + name="view", + options={"view": "drug_chemberta_embeddings"}, + ), + ) + assert config.drug_views() == ["drug_chemberta_embeddings"] + + +def test_feature_based_predictor_requires_a_drug_featurizer() -> None: + with pytest.raises(ValidationError, match="requires a drug_featurizer"): + _model_config( + predictor=PredictorConfig(name="naiveCellLineMean"), + drug_featurizer=None, + ) diff --git a/tests/models/mixins/_helpers.py b/tests/models/mixins/_helpers.py new file mode 100644 index 000000000..13f8bb536 --- /dev/null +++ b/tests/models/mixins/_helpers.py @@ -0,0 +1,60 @@ +"""Shared checkpoint fixtures for the persistence mixin tests.""" + +from __future__ import annotations + +import io +import zipfile +from typing import Any + +import joblib +from upath import UPath + +from drevalpy.models import construct_model +from drevalpy.models.config import from_spec +from drevalpy.models.mixins._persistence_io import FORMAT_NAME, FORMAT_VERSION, PAYLOAD_MEMBER +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_gene_expression_fingerprints, +) + + +def fitted_elastic_net(): + """Train the cheapest real model in the zoo on the 2x2 synthetic dataset. + + :returns: A fitted ``ElasticNet`` model whose stack reports ``is_fitted()``. + """ + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + model.train(synthetic_mudataset_gene_expression_fingerprints(), lco_split_masks()) + return model + + +def write_archive(archive_path: UPath, payload: object) -> UPath: + """Write *payload* into a checkpoint-shaped zip archive. + + :param archive_path: Destination archive path. + :param payload: Object to serialize as the archive's payload member. + :returns: *archive_path*. + """ + archive_path.parent.mkdir(parents=True, exist_ok=True) + buffer = io.BytesIO() + joblib.dump(payload, buffer) + with zipfile.ZipFile(archive_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr(PAYLOAD_MEMBER, buffer.getvalue()) + return archive_path + + +def elastic_net_payload(state: Any, *, model_name: str = "ElasticNet") -> dict[str, Any]: + """Build a well-formed checkpoint payload carrying an arbitrary *state*. + + :param state: Value stored under the payload's ``state`` key. + :param model_name: Model identity recorded in the payload. + :returns: Checkpoint payload mapping. + """ + config = from_spec("ElasticNet", hyperparameters={"alpha": 0.1, "l1_ratio": 0.5}) + return { + "format": FORMAT_NAME, + "version": FORMAT_VERSION, + "model_name": model_name, + "config": config.model_dump(mode="json"), + "state": state, + } diff --git a/tests/models/mixins/test_feature_matrix.py b/tests/models/mixins/test_feature_matrix.py new file mode 100644 index 000000000..953b2b066 --- /dev/null +++ b/tests/models/mixins/test_feature_matrix.py @@ -0,0 +1,143 @@ +"""Tests for raw feature-matrix assembly on ``DRPModel``. + +Mirrors :mod:`drevalpy.models.mixins._feature_matrix`. Nothing built by +``construct_model`` calls either method - the component stack featurizes - so +these exist for callers driving featurization by hand, and are asserted against +a stub source rather than through a real model. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.models.mixins._feature_matrix import DRPFeatureMatrixMixin + + +class _StubSource: + """Feature source stand-in serving one matrix per view name.""" + + def __init__(self, matrices: dict[str, np.ndarray]) -> None: + self._matrices = matrices + + @property + def view_names(self) -> list[str]: + return list(self._matrices) + + def get_feature_matrix(self, view: str, identifiers: np.ndarray) -> np.ndarray: + return np.repeat(self._matrices[view], len(identifiers), axis=0) + + +class _Model(DRPFeatureMatrixMixin): + """Model-shaped object declaring the views its config would require.""" + + def __init__(self, cell_line_views: list[str], drug_views: list[str]) -> None: + self._cell_line_views = cell_line_views + self._drug_views = drug_views + + @property + def cell_line_views(self) -> list[str]: + return self._cell_line_views + + @property + def drug_views(self) -> list[str]: + return self._drug_views + + +_IDS = np.array(["a", "b"]) + + +def _cell_line_source() -> _StubSource: + return _StubSource({"gene_expression": np.array([[1.0, 2.0]]), "mutations": np.array([[9.0]])}) + + +def _drug_source() -> _StubSource: + return _StubSource({"fingerprints": np.array([[3.0]])}) + + +class TestGetFeatureMatrices: + """One matrix per required view, from whichever sides were supplied.""" + + def test_it_merges_both_sides(self) -> None: + model = _Model(["gene_expression"], ["fingerprints"]) + + matrices = model.get_feature_matrices(_IDS, _IDS, _cell_line_source(), _drug_source()) + + assert sorted(matrices) == ["fingerprints", "gene_expression"] + assert matrices["gene_expression"].shape == (2, 2) + assert matrices["fingerprints"].shape == (2, 1) + + def test_a_missing_source_contributes_nothing(self) -> None: + model = _Model(["gene_expression"], ["fingerprints"]) + + matrices = model.get_feature_matrices(_IDS, _IDS, _cell_line_source(), None) + + assert sorted(matrices) == ["gene_expression"] + + def test_two_absent_sources_yield_nothing(self) -> None: + model = _Model(["gene_expression"], ["fingerprints"]) + + assert model.get_feature_matrices(_IDS, _IDS, None, None) == {} + + def test_a_missing_cell_line_view_is_reported_by_side(self) -> None: + model = _Model(["proteomics"], []) + + with pytest.raises(ValueError, match="Cell line input does not contain view proteomics"): + model.get_feature_matrices(_IDS, _IDS, _cell_line_source(), None) + + def test_a_missing_drug_view_is_reported_by_side(self) -> None: + model = _Model([], ["chemberta"]) + + with pytest.raises(ValueError, match="Drug input does not contain view chemberta"): + model.get_feature_matrices(_IDS, _IDS, None, _drug_source()) + + +class TestGetConcatenatedFeatures: + """The requested pair of views, side by side in one matrix.""" + + def test_both_sides_are_concatenated_column_wise(self) -> None: + model = _Model(["gene_expression"], ["fingerprints"]) + + matrix = model.get_concatenated_features( + "gene_expression", "fingerprints", _IDS, _IDS, _cell_line_source(), _drug_source() + ) + + assert matrix.shape == (2, 3) + np.testing.assert_allclose(matrix[0], [1.0, 2.0, 3.0]) + + def test_a_none_drug_view_yields_the_cell_line_side_alone(self) -> None: + model = _Model(["gene_expression"], []) + + matrix = model.get_concatenated_features("gene_expression", None, _IDS, _IDS, _cell_line_source(), None) + + assert matrix.shape == (2, 2) + + def test_a_none_cell_line_view_yields_the_drug_side_alone(self) -> None: + model = _Model([], ["fingerprints"]) + + matrix = model.get_concatenated_features(None, "fingerprints", _IDS, _IDS, None, _drug_source()) + + assert matrix.shape == (2, 1) + + def test_requesting_neither_side_is_rejected(self) -> None: + model = _Model([], []) + + with pytest.raises(ValueError, match="No features provided"): + model.get_concatenated_features(None, None, _IDS, _IDS, None, None) + + def test_an_unassembled_cell_line_view_is_rejected(self) -> None: + """The view was not in ``cell_line_views``, so nothing assembled it.""" + model = _Model([], ["fingerprints"]) + + with pytest.raises(ValueError, match="Expected cell_line_view 'gene_expression'"): + model.get_concatenated_features( + "gene_expression", "fingerprints", _IDS, _IDS, _cell_line_source(), _drug_source() + ) + + def test_an_unassembled_drug_view_is_rejected_first(self) -> None: + model = _Model(["gene_expression"], []) + + with pytest.raises(ValueError, match="Expected drug_view 'fingerprints'"): + model.get_concatenated_features( + "gene_expression", "fingerprints", _IDS, _IDS, _cell_line_source(), _drug_source() + ) diff --git a/tests/models/mixins/test_hyperparameters.py b/tests/models/mixins/test_hyperparameters.py new file mode 100644 index 000000000..6050a8445 --- /dev/null +++ b/tests/models/mixins/test_hyperparameters.py @@ -0,0 +1,29 @@ +"""Coverage tests for structured hyperparameter spaces on public models.""" + +from __future__ import annotations + +from drevalpy.models import construct_model +from drevalpy.models._model_lookup import known_model_names +from drevalpy.registry._builtins import register_builtin_components as _register_builtins +from drevalpy.registry.predictor import list as list_predictors + + +def test_model_factory_models_expose_defaults() -> None: + _register_builtins() + for model_name in known_model_names(include_external=False): + model_cls = construct_model(model_name) + defaults = model_cls.get_default_hyperparameters() + assert isinstance(defaults, dict), model_name + assert model_cls.get_hyperparameter_set() == [defaults] + + +def test_registered_predictors_expose_space_helpers() -> None: + _register_builtins() + for name in list_predictors(): + from drevalpy.registry.predictor import get as get_predictor + + cls = get_predictor(name) + space = cls.get_hyperparameter_space() + defaults = cls.get_default_hyperparameters() + assert isinstance(space, dict) + assert isinstance(defaults, dict) diff --git a/tests/models/mixins/test_logging.py b/tests/models/mixins/test_logging.py new file mode 100644 index 000000000..1c0ea6f1a --- /dev/null +++ b/tests/models/mixins/test_logging.py @@ -0,0 +1,67 @@ +"""Tests for DRP wandb logging mixin.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from drevalpy.models.mixins._logging import _DRPLoggingMixin + +_EXPECTED_DEFINE_METRICS: list[tuple[str, str]] = [ + ("epoch", "max"), + ("train_loss", "min"), + ("val_loss", "min"), + ("train_R^2", "max"), + ("val_R^2", "max"), + ("train_Pearson", "max"), + ("val_Pearson", "max"), +] + + +class _Stub(_DRPLoggingMixin): + def __init__(self) -> None: + self.wandb_project = None + self.wandb_run = None + self.wandb_config = None + self._in_hyperparameter_tuning = False + self._hp: dict = {} + + @classmethod + def get_model_name(cls) -> str: + return "Stub" + + @property + def hyperparameters(self) -> dict: + return self._hp + + +def test_init_wandb_define_metrics_on_success() -> None: + stub = _Stub() + mock_run = MagicMock() + mock_wandb = MagicMock() + + with patch("drevalpy.models.mixins._logging._wandb", return_value=mock_wandb): + mock_wandb.run = mock_run + stub.init_wandb("proj") + + mock_wandb.finish.assert_called_once() + mock_wandb.init.assert_called_once_with( + project="proj", + config={}, + name="Stub", + tags=None, + ) + assert stub.wandb_run is mock_run + assert mock_wandb.define_metric.call_count == len(_EXPECTED_DEFINE_METRICS) + for metric, summary in _EXPECTED_DEFINE_METRICS: + mock_wandb.define_metric.assert_any_call(metric, summary=summary) + + +def test_init_wandb_swallows_define_metric_exceptions() -> None: + stub = _Stub() + mock_run = MagicMock() + mock_wandb = MagicMock() + + with patch("drevalpy.models.mixins._logging._wandb", return_value=mock_wandb): + mock_wandb.run = mock_run + mock_wandb.define_metric.side_effect = RuntimeError("define_metric failed") + stub.init_wandb("proj") diff --git a/tests/models/mixins/test_persistence.py b/tests/models/mixins/test_persistence.py new file mode 100644 index 000000000..cbb529ed0 --- /dev/null +++ b/tests/models/mixins/test_persistence.py @@ -0,0 +1,93 @@ +"""Tests for the ``DRPPersistenceMixin`` save/load surface on ``DRPModel``. + +The archive format itself is covered in ``test_persistence_io.py``; what is +asserted here is the mixin's own behaviour: delegation from ``save``, identity +checking in ``load``, and the state-restoration guards. +""" + +from __future__ import annotations + +import pytest +from upath import UPath + +from drevalpy.models import construct_model +from drevalpy.models.mixins._persistence_io import ( + CorruptedCheckpointError, + IncompatibleModelCheckpointError, + save_model, +) +from tests.models.mixins._helpers import elastic_net_payload, fitted_elastic_net, write_archive + + +class TestSave: + """``DRPModel.save`` delegates to ``save_model``.""" + + def test_writes_an_archive(self, tmp_path) -> None: + model = fitted_elastic_net() + + model.save(str(UPath(tmp_path) / "elastic_net")) + + assert (UPath(tmp_path) / "elastic_net.zip").is_file() + + def test_rejects_a_directory_path(self, tmp_path) -> None: + model = fitted_elastic_net() + + with pytest.raises(ValueError, match="not a directory"): + model.save(str(tmp_path)) + + +class TestLoad: + """``DRPModel.load`` restores fitted state onto a fresh instance.""" + + def test_round_trips_a_fitted_model(self, tmp_path) -> None: + model = fitted_elastic_net() + checkpoint = str(UPath(tmp_path) / "elastic_net") + model.save(checkpoint) + + loaded = construct_model("ElasticNet").load(checkpoint) + + assert loaded._stack is not None + assert loaded._stack.is_fitted() + + def test_restores_the_resolved_config(self, tmp_path) -> None: + model = fitted_elastic_net() + checkpoint = str(UPath(tmp_path) / "elastic_net") + model.save(checkpoint) + + loaded = construct_model("ElasticNet").load(checkpoint) + + assert loaded._resolved_model_config is not None + assert loaded._resolved_model_config.predictor_values()["alpha"] == 0.1 + + def test_clears_the_empty_training_flag(self, tmp_path) -> None: + model = fitted_elastic_net() + checkpoint = str(UPath(tmp_path) / "elastic_net") + model.save(checkpoint) + + loaded = construct_model("ElasticNet").load(checkpoint) + + assert loaded._empty_training is False + + def test_rejects_a_checkpoint_from_another_model(self, tmp_path) -> None: + model = fitted_elastic_net() + checkpoint = str(UPath(tmp_path) / "elastic_net") + save_model(model, checkpoint) + + with pytest.raises(IncompatibleModelCheckpointError, match="does not match"): + construct_model("Ridge").load(checkpoint) + + def test_rejects_an_unfitted_checkpoint_state(self, tmp_path) -> None: + payload = elastic_net_payload({"predictor": {}, "cell_line_featurizer": {}, "drug_featurizer": {}}) + archive = write_archive(UPath(tmp_path) / "elastic_net.zip", payload) + + with pytest.raises( + CorruptedCheckpointError, match="missing a fitted estimator|did not restore a fitted predictor" + ): + construct_model("ElasticNet").load(str(archive)) + + def test_rejects_a_non_mapping_predictor_state(self, tmp_path) -> None: + payload = elastic_net_payload({"predictor": "bad", "cell_line_featurizer": {}, "drug_featurizer": {}}) + archive = write_archive(UPath(tmp_path) / "elastic_net.zip", payload) + + with pytest.raises(CorruptedCheckpointError, match="component state is invalid"): + construct_model("ElasticNet").load(str(archive)) diff --git a/tests/models/mixins/test_persistence_io.py b/tests/models/mixins/test_persistence_io.py new file mode 100644 index 000000000..e4c17b95a --- /dev/null +++ b/tests/models/mixins/test_persistence_io.py @@ -0,0 +1,272 @@ +"""Tests for low-level checkpoint archive I/O. + +Covers ``drevalpy.models.mixins._persistence_io``: archive writing, payload +validation and path resolution. The ``DRPPersistenceMixin`` wrapper that calls +into it is tested in ``test_persistence.py``. +""" + +from __future__ import annotations + +import zipfile + +import pytest +from upath import UPath + +from drevalpy.models import construct_model, load_model +from drevalpy.models.mixins._persistence_io import ( + FORMAT_NAME, + FORMAT_VERSION, + PAYLOAD_MEMBER, + CorruptedCheckpointError, + IncompatibleModelCheckpointError, + ModelCheckpointError, + UnsupportedCheckpointFormatError, + load_model_payload, + resolve_checkpoint_path, + save_model, +) +from tests.models.mixins._helpers import elastic_net_payload, fitted_elastic_net, write_archive +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_gene_expression_fingerprints, +) + + +class TestResolveCheckpointPath: + """Archive path normalization.""" + + @pytest.mark.parametrize( + ("given", "expected"), + [ + pytest.param("checkpoints/foo", "checkpoints/foo.zip", id="suffix-appended"), + pytest.param("checkpoints/foo.zip", "checkpoints/foo.zip", id="suffix-kept"), + pytest.param("checkpoints/foo.dreval.zip", "checkpoints/foo.dreval.zip", id="compound-suffix-kept"), + pytest.param("checkpoints/foo.ZIP", "checkpoints/foo.ZIP", id="suffix-match-is-case-insensitive"), + ], + ) + def test_resolves_to_an_archive_path(self, given: str, expected: str) -> None: + assert resolve_checkpoint_path(given) == UPath(expected) + + def test_rejects_a_directory_style_path(self) -> None: + with pytest.raises(ValueError, match="not a directory"): + resolve_checkpoint_path("checkpoints/") + + +class TestSaveModel: + """Writing a fitted model to an archive.""" + + def test_appends_the_zip_suffix(self, tmp_path) -> None: + model = fitted_elastic_net() + + save_model(model, str(UPath(tmp_path) / "elastic_net")) + + assert (UPath(tmp_path) / "elastic_net.zip").is_file() + + def test_writes_the_payload_member(self, tmp_path) -> None: + model = fitted_elastic_net() + archive = UPath(tmp_path) / "elastic_net.zip" + + save_model(model, str(archive)) + + with zipfile.ZipFile(archive) as handle: + assert handle.namelist() == [PAYLOAD_MEMBER] + + def test_leaves_no_temporary_files(self, tmp_path) -> None: + model = fitted_elastic_net() + + save_model(model, str(UPath(tmp_path) / "elastic_net")) + + assert [path.name for path in UPath(tmp_path).iterdir()] == ["elastic_net.zip"] + + def test_rejects_an_existing_directory(self, tmp_path) -> None: + model = fitted_elastic_net() + + with pytest.raises(ValueError, match="not a directory"): + save_model(model, str(tmp_path)) + + def test_rejects_an_untrained_model(self, tmp_path) -> None: + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + + with pytest.raises(RuntimeError, match="not trained"): + save_model(model, str(UPath(tmp_path) / "elastic_net")) + + +class TestLoadModelPayload: + """Reading and validating a checkpoint payload.""" + + def test_round_trips_model_identity_and_config(self, tmp_path) -> None: + model = fitted_elastic_net() + archive = UPath(tmp_path) / "elastic_net.zip" + save_model(model, str(archive)) + + model_name, config, state = load_model_payload(str(archive)) + + assert model_name == "ElasticNet" + assert config.predictor_values()["alpha"] == 0.1 + assert set(state) == {"predictor", "cell_line_featurizer", "drug_featurizer"} + + def test_accepts_a_path_without_the_zip_suffix(self, tmp_path) -> None: + model = fitted_elastic_net() + save_model(model, str(UPath(tmp_path) / "elastic_net")) + + model_name, _, _ = load_model_payload(str(UPath(tmp_path) / "elastic_net")) + + assert model_name == "ElasticNet" + + def test_missing_archive_raises(self, tmp_path) -> None: + with pytest.raises(FileNotFoundError, match="Missing model checkpoint"): + load_model_payload(str(UPath(tmp_path) / "missing")) + + def test_rejects_a_non_zip_file(self, tmp_path) -> None: + archive = UPath(tmp_path) / "elastic_net.zip" + archive.write_text("not a zip", encoding="utf-8") + + with pytest.raises(CorruptedCheckpointError, match="not a valid zip file"): + load_model_payload(str(archive)) + + def test_rejects_an_archive_without_the_payload_member(self, tmp_path) -> None: + archive = UPath(tmp_path) / "elastic_net.zip" + with zipfile.ZipFile(archive, mode="w") as handle: + handle.writestr("something-else", b"") + + with pytest.raises(CorruptedCheckpointError, match=f"missing {PAYLOAD_MEMBER!r}"): + load_model_payload(str(archive)) + + def test_rejects_a_non_mapping_state(self, tmp_path) -> None: + archive = write_archive(UPath(tmp_path) / "elastic_net.zip", elastic_net_payload("bad")) + + with pytest.raises(CorruptedCheckpointError, match="checkpoint state is not a mapping"): + load_model_payload(str(archive)) + + @pytest.mark.parametrize( + ("model_name", "match"), + [ + pytest.param(None, "model_name is missing or invalid", id="missing-name"), + pytest.param("", "model_name is missing or invalid", id="empty-name"), + ], + ) + def test_rejects_a_missing_model_name(self, tmp_path, model_name: str | None, match: str) -> None: + payload = elastic_net_payload({}, model_name=model_name) # type: ignore[arg-type] + archive = write_archive(UPath(tmp_path) / "elastic_net.zip", payload) + + with pytest.raises(CorruptedCheckpointError, match=match): + load_model_payload(str(archive)) + + @pytest.mark.parametrize( + ("payload", "error_type", "match"), + [ + pytest.param("not-a-mapping", CorruptedCheckpointError, "not a mapping", id="payload-not-a-mapping"), + pytest.param( + {"format": "unknown-format", "version": 0, "model_name": "ElasticNet", "config": {}, "state": {}}, + UnsupportedCheckpointFormatError, + "unsupported checkpoint format/version", + id="unknown-format", + ), + pytest.param( + {"format": FORMAT_NAME, "version": 1, "model_name": "ElasticNet", "config": {}, "state": {}}, + UnsupportedCheckpointFormatError, + "unsupported checkpoint format/version", + id="older-version", + ), + pytest.param( + { + "format": FORMAT_NAME, + "version": FORMAT_VERSION + 1, + "model_name": "ElasticNet", + "config": {}, + "state": {}, + }, + UnsupportedCheckpointFormatError, + "unsupported checkpoint format/version", + id="newer-version", + ), + pytest.param( + { + "format": FORMAT_NAME, + "version": FORMAT_VERSION, + "model_name": "ElasticNet", + "config": "bad", + "state": {}, + }, + CorruptedCheckpointError, + "checkpoint config is invalid", + id="unparsable-config", + ), + ], + ) + def test_rejects_malformed_or_unsupported_payloads( + self, + tmp_path, + payload: object, + error_type: type[Exception], + match: str, + ) -> None: + archive = write_archive(UPath(tmp_path) / "elastic_net.zip", payload) + + with pytest.raises(error_type, match=match): + load_model_payload(str(archive)) + + +class TestLoadModel: + """Reconstructing a model without a class handle.""" + + def test_reconstructs_from_the_stored_model_name(self, tmp_path) -> None: + model = fitted_elastic_net() + checkpoint = str(UPath(tmp_path) / "elastic_net") + save_model(model, checkpoint) + + loaded = load_model(checkpoint) + + assert loaded.get_model_name() == "ElasticNet" + assert loaded._stack is not None + assert loaded._stack.is_fitted() + + def test_restores_the_hyperparameters(self, tmp_path) -> None: + model = fitted_elastic_net() + checkpoint = str(UPath(tmp_path) / "elastic_net") + save_model(model, checkpoint) + + loaded = load_model(checkpoint) + + assert loaded._resolved_model_config is not None + assert loaded._resolved_model_config.predictor_values()["alpha"] == 0.1 + + def test_accepts_an_explicit_zip_path(self, tmp_path) -> None: + model = fitted_elastic_net() + archive = str(UPath(tmp_path) / "elastic_net.zip") + save_model(model, archive) + + loaded = load_model(archive) + + assert loaded.get_model_name() == "ElasticNet" + + def test_supports_custom_model_names(self, tmp_path) -> None: + model = construct_model("MyRF", "scaledGeneExpression:fingerprints:randomForest")({"n_estimators": 5}) + model.train(synthetic_mudataset_gene_expression_fingerprints(), lco_split_masks()) + checkpoint = str(UPath(tmp_path) / "my_rf") + save_model(model, checkpoint) + + loaded = load_model(checkpoint) + + assert loaded.get_model_name() == "MyRF" + assert loaded._stack is not None + assert loaded._stack.is_fitted() + + +class TestErrorHierarchy: + """All checkpoint errors are catchable as one family and as ``ValueError``.""" + + @pytest.mark.parametrize( + "error_type", + [ + pytest.param(UnsupportedCheckpointFormatError, id="unsupported-format"), + pytest.param(CorruptedCheckpointError, id="corrupted"), + pytest.param(IncompatibleModelCheckpointError, id="incompatible"), + ], + ) + def test_subclasses_model_checkpoint_error_and_value_error(self, error_type: type[Exception]) -> None: + assert issubclass(error_type, ModelCheckpointError) + assert issubclass(error_type, ValueError) + + def test_base_error_is_not_a_value_error(self) -> None: + assert not issubclass(ModelCheckpointError, ValueError) diff --git a/tests/models/mixins/test_train_args.py b/tests/models/mixins/test_train_args.py new file mode 100644 index 000000000..8179d99a0 --- /dev/null +++ b/tests/models/mixins/test_train_args.py @@ -0,0 +1,163 @@ +"""Tests for the resolution of ``DRPModel.train``'s two accepted call shapes. + +Mirrors :mod:`drevalpy.models.mixins._train_args`, whose job is to turn one +``train`` call - positional or keyword, Dataset form or ResponseBatch form - into +a :class:`TrainCallArgs` the caller can dispatch on. Asserted directly rather +than through ``train`` because the interesting cases are precisely the ones no +call site in the library makes: the compat spellings that only exist for +hand-rolled models. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.models.mixins._train_args import TrainCallArgs, resolve_train_args +from drevalpy.types import SplitMask, SplitMasks +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_identity, +) + + +@pytest.fixture +def dataset(): + """The cheap 2x2 synthetic dataset.""" + return synthetic_mudataset_identity() + + +@pytest.fixture +def split() -> SplitMasks: + """Leave-cell-line-out masks over that dataset, with an empty ``val``.""" + return lco_split_masks() + + +def _masks_with_val() -> SplitMasks: + """Split masks whose ``val`` mask selects something.""" + return SplitMasks( + train=SplitMask(np.array([[True, False], [False, False]])), + test=SplitMask(np.array([[False, False], [True, False]])), + val=SplitMask(np.array([[False, True], [False, False]])), + ) + + +class TestTheDatasetForm: + """``(mudataset, scope)`` - what every call site inside the library uses.""" + + def test_keywords_are_taken_as_given(self, dataset, split) -> None: + args = resolve_train_args(mudataset=dataset, scope=split.train) + + assert args.is_dataset_form + assert not args.is_feature_source_form + assert args.mudataset is dataset + assert args.scope is split.train + + def test_a_dataset_in_the_first_positional_slot_is_recognised(self, dataset, split) -> None: + args = resolve_train_args(dataset, split.train) + + assert args.is_dataset_form + assert args.mudataset is dataset + + def test_a_positional_split_masks_is_narrowed_to_its_train_mask(self, dataset, split) -> None: + args = resolve_train_args(dataset, split) + + assert args.scope is split.train + + def test_a_keyword_split_masks_is_narrowed_too(self, dataset, split) -> None: + args = resolve_train_args(mudataset=dataset, split=split) + + assert args.scope is split.train + + def test_a_non_empty_val_mask_becomes_the_early_stopping_scope(self, dataset) -> None: + masks = _masks_with_val() + + args = resolve_train_args(dataset, masks) + + assert args.early_stopping_scope is masks.val + + def test_an_empty_val_mask_leaves_early_stopping_off(self, dataset, split) -> None: + args = resolve_train_args(dataset, split) + + assert args.early_stopping_scope is None + + def test_an_explicit_early_stopping_scope_survives_narrowing(self, dataset, split) -> None: + explicit = split.test + + args = resolve_train_args(dataset, split, early_stopping_scope=explicit) + + assert args.early_stopping_scope is explicit + + def test_an_explicit_scope_wins_over_a_positional_split(self, dataset, split) -> None: + args = resolve_train_args(dataset, split, scope=split.test) + + assert args.scope is split.test + assert args.early_stopping_scope is None + + +class TestTheFeatureSourceForm: + """``(output, cell_line_input, drug_input)`` - for hand-rolled models.""" + + def test_keywords_are_taken_as_given(self) -> None: + args = resolve_train_args(output="batch", cell_line_input="cl", drug_input="dr") + + assert args.is_feature_source_form + assert not args.is_dataset_form + assert (args.output, args.cell_line_input, args.drug_input) == ("batch", "cl", "dr") + + def test_a_non_dataset_first_positional_becomes_output(self) -> None: + args = resolve_train_args("batch", "cl", "dr") + + assert args.is_feature_source_form + assert args.output == "batch" + assert args.cell_line_input == "cl" + assert args.drug_input == "dr" + + def test_a_non_mask_second_positional_becomes_the_cell_line_input(self) -> None: + args = resolve_train_args(output="batch", cell_line_input=None) + + assert not args.is_feature_source_form + + args = resolve_train_args("batch", "cl") + + assert args.is_feature_source_form + + +class TestNeitherForm: + """An incomplete call resolves to neither form, which is what ``train`` reports.""" + + def test_an_empty_call_is_neither(self) -> None: + args = resolve_train_args() + + assert not args.is_dataset_form + assert not args.is_feature_source_form + + def test_a_dataset_without_a_scope_is_neither(self, dataset) -> None: + args = resolve_train_args(dataset) + + assert not args.is_dataset_form + assert not args.is_feature_source_form + + def test_an_output_without_a_cell_line_input_is_neither(self) -> None: + args = resolve_train_args("batch") + + assert not args.is_feature_source_form + + +def test_the_resolved_arguments_are_immutable(dataset, split) -> None: + """A frozen dataclass keeps ``train``'s branches from editing the call.""" + args = resolve_train_args(dataset, split.train) + + with pytest.raises(AttributeError): + args.scope = split.test # type: ignore[misc] + + +def test_an_empty_result_defaults_to_no_inputs() -> None: + assert TrainCallArgs() == TrainCallArgs( + mudataset=None, + scope=None, + early_stopping_scope=None, + output=None, + cell_line_input=None, + drug_input=None, + ) diff --git a/tests/models/mixins/test_training.py b/tests/models/mixins/test_training.py new file mode 100644 index 000000000..7a3623575 --- /dev/null +++ b/tests/models/mixins/test_training.py @@ -0,0 +1,205 @@ +"""Tests for the ``DRPModel`` train / predict surface. + +Mirrors :mod:`drevalpy.models.mixins._training`. The happy paths for a real model +are covered end to end in ``tests/models/test_drp_model.py``; what is asserted +here is the surface's own contract - the two guard clauses, the scope resolution +``predict`` accepts three spellings of, and the ResponseBatch form no call site in +the library exercises. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.mixins._training import DRPTrainingMixin, _resolve_predict_scope +from drevalpy.types import SplitMask, SplitMasks +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_gene_expression_fingerprints, + synthetic_mudataset_identity, +) + + +class _Stackless(DRPTrainingMixin): + """Model-shaped object that was never given a component stack.""" + + def __init__(self) -> None: + self._stack = None + self._empty_training = False + + @classmethod + def get_model_name(cls) -> str: + return "Stackless" + + +class _RecordingStack: + """Stack that records which fit entry point the mixin chose.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def _fit_featurizers_and_predictor(self, output, cell_line_input, drug_input, **kwargs) -> None: + self.calls.append(("features", {"output": output, "cl": cell_line_input, "dr": drug_input, **kwargs})) + + def is_fitted(self) -> bool: + return True + + +class _RecordingModel(DRPTrainingMixin): + """Model-shaped object over a recording stack.""" + + def __init__(self) -> None: + self._stack = _RecordingStack() + self._empty_training = False + + @classmethod + def get_model_name(cls) -> str: + return "Recording" + + +class _Batch(list): + """Response batch stand-in whose only relevant property is its length.""" + + +class TestTheStackGuard: + """Neither entry point may silently no-op on an unmaterialized model.""" + + def test_train_refuses_a_model_without_a_stack(self) -> None: + with pytest.raises(RuntimeError, match="has not been constructed with a component stack"): + _Stackless().train(synthetic_mudataset_identity(), lco_split_masks()) + + def test_predict_refuses_a_model_without_a_stack(self) -> None: + with pytest.raises(RuntimeError, match="has not been constructed with a component stack"): + _Stackless().predict(synthetic_mudataset_identity(), lco_split_masks()) + + +class TestTrainRejectsAnIncompleteCall: + """A call matching neither accepted form is a ``TypeError``, not a no-op fit.""" + + def test_no_arguments_at_all(self) -> None: + with pytest.raises(TypeError, match=r"train\(\) requires either"): + _RecordingModel().train() + + def test_a_dataset_without_a_scope(self) -> None: + with pytest.raises(TypeError, match=r"train\(\) requires either"): + _RecordingModel().train(synthetic_mudataset_identity()) + + +class TestTheFeatureSourceForm: + """``(output, cell_line_input, drug_input)`` is only reachable by hand.""" + + def test_it_reaches_the_featurizer_and_predictor_fit(self) -> None: + model = _RecordingModel() + + model.train(_Batch([1, 2, 3]), "cl_source", "dr_source") + + assert [name for name, _ in model._stack.calls] == ["features"] + assert model._empty_training is False + + def test_it_forwards_the_early_stopping_batch_and_a_context(self) -> None: + model = _RecordingModel() + + model.train(_Batch([1]), "cl_source", output_earlystopping="es_batch") + + _, kwargs = model._stack.calls[0] + assert kwargs["output_earlystopping"] == "es_batch" + assert kwargs["training_context"].logging_metadata == {"model_name": "Recording"} + + def test_the_checkpoint_directory_reaches_the_context(self) -> None: + model = _RecordingModel() + + model.train(_Batch([1]), "cl_source", model_checkpoint_dir="/tmp/ckpt") # noqa: S108 + + _, kwargs = model._stack.calls[0] + assert str(kwargs["training_context"].checkpoint_dir) == "/tmp/ckpt" # noqa: S108 + + def test_an_empty_batch_records_empty_training_without_fitting(self) -> None: + model = _RecordingModel() + + model.train(_Batch([]), "cl_source") + + assert model._empty_training is True + assert model._stack.calls == [] + + +class TestResolvePredictScope: + """``predict`` accepts the mask as a keyword, a positional, or inside a split.""" + + def test_an_explicit_scope_wins(self) -> None: + split = lco_split_masks() + + assert _resolve_predict_scope(split, scope=split.train, split=None) is split.train + + def test_a_positional_mask_is_used_as_is(self) -> None: + split = lco_split_masks() + + assert _resolve_predict_scope(split.val, scope=None, split=None) is split.val + + def test_positional_split_masks_resolve_to_the_test_mask(self) -> None: + split = lco_split_masks() + + assert _resolve_predict_scope(split, scope=None, split=None) is split.test + + def test_keyword_split_masks_resolve_to_the_test_mask(self) -> None: + split = lco_split_masks() + + assert _resolve_predict_scope(None, scope=None, split=split) is split.test + + def test_nothing_at_all_resolves_to_none(self) -> None: + assert _resolve_predict_scope(None, scope=None, split=None) is None + + +class TestPredictAgainstARealModel: + """The remaining branches need a stack that can actually answer.""" + + def test_it_refuses_a_missing_scope(self) -> None: + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + + with pytest.raises(TypeError, match=r"predict\(\) requires"): + model.predict(synthetic_mudataset_gene_expression_fingerprints()) + + def test_it_refuses_a_missing_dataset(self) -> None: + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + + with pytest.raises(TypeError, match=r"predict\(\) requires"): + model.predict(scope=lco_split_masks().test) + + def test_it_refuses_an_untrained_model(self) -> None: + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + + with pytest.raises(RuntimeError, match="has not been trained"): + model.predict(synthetic_mudataset_gene_expression_fingerprints(), lco_split_masks()) + + def test_an_empty_training_scope_answers_nan_instead_of_raising(self) -> None: + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + mudataset = synthetic_mudataset_gene_expression_fingerprints() + empty = SplitMasks( + train=SplitMask(np.zeros((2, 2), dtype=bool)), + test=lco_split_masks().test, + val=SplitMask(np.zeros((2, 2), dtype=bool)), + ) + + model.train(mudataset, empty) + predictions = model.predict(mudataset, empty) + + assert model._empty_training is True + assert np.isnan(predictions).all() + + def test_early_stopping_takes_the_other_dataset_branch(self) -> None: + """A non-empty ``val`` mask routes through ``train_with_early_stopping``.""" + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + mudataset = synthetic_mudataset_gene_expression_fingerprints() + split = SplitMasks( + train=SplitMask(np.array([[True, False], [False, False]])), + test=SplitMask(np.array([[False, False], [True, False]])), + val=SplitMask(np.array([[False, True], [False, False]])), + ) + + model.train(mudataset, split) + + assert model._stack is not None + assert model._stack.is_fitted() diff --git a/tests/models/synthetic_fixtures.py b/tests/models/synthetic_fixtures.py new file mode 100644 index 000000000..afc567850 --- /dev/null +++ b/tests/models/synthetic_fixtures.py @@ -0,0 +1,178 @@ +"""Tiny in-memory fixtures for model execution gates.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.data.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER, TISSUE_IDENTIFIER +from drevalpy.types import SplitMask, SplitMasks +from drevalpy.types.data.batch.response_batch import ResponseBatch +from tests.conftest import MockFeatureSource + + +def multi_drug_response() -> ResponseBatch: + return ResponseBatch( + response=np.array([1.0, 2.0, 3.0, 4.0]), + cell_line_ids=np.array(["cl1", "cl1", "cl2", "cl2"]), + drug_ids=np.array(["d1", "d2", "d1", "d2"]), + ) + + +def cell_line_gene_expression() -> MockFeatureSource: + return MockFeatureSource( + features={ + "cl1": {"gene_expression": np.array([0.1, 0.2, 0.3])}, + "cl2": {"gene_expression": np.array([0.4, 0.5, 0.6])}, + } + ) + + +def drug_fingerprints() -> MockFeatureSource: + return MockFeatureSource( + features={ + "d1": {"fingerprints": np.array([1.0, 0.0])}, + "d2": {"fingerprints": np.array([0.0, 1.0])}, + } + ) + + +def identity_cell_line_features(*, with_tissue: bool = False) -> MockFeatureSource: + features = { + "cl1": {CELL_LINE_IDENTIFIER: np.array(["cl1"])}, + "cl2": {CELL_LINE_IDENTIFIER: np.array(["cl2"])}, + } + if with_tissue: + features["cl1"][TISSUE_IDENTIFIER] = np.array(["Lung"]) + features["cl2"][TISSUE_IDENTIFIER] = np.array(["Blood"]) + return MockFeatureSource(features=features) + + +def identity_drug_features() -> MockFeatureSource: + return MockFeatureSource( + features={ + "d1": {DRUG_IDENTIFIER: np.array(["d1"])}, + "d2": {DRUG_IDENTIFIER: np.array(["d2"])}, + } + ) + + +#: Column-name prefix and value offset (in tenths) for each cell-line view. The +#: offset only has to make the views distinguishable; ``gene_expression``'s value +#: of 1 is what reproduces the matrix this module has always built. +_VIEW_SPECS = { + "gene_expression": ("gene", 1), + "methylation": ("cpg", 2), + "mutations": ("mut", 3), + "copy_number_variation_gistic": ("cnv", 4), + "proteomics": ("prot", 5), +} + + +def _view_matrix(width: int, offset: int) -> np.ndarray: + """Build a deterministic 2-row matrix, shifted by *offset* tenths. + + :param width: Number of columns. + :param offset: Added to every element before scaling, in tenths. + :returns: ``(2, width)`` float32 matrix. + """ + return ((np.arange(2 * width, dtype=np.float32) + offset) / 10.0).reshape(2, width) + + +def synthetic_mudataset( + *, + n_features_per_view: int = 3, + fingerprint_width: int = 2, + extra_views: tuple[str, ...] = (), +): + """Build a two-cell-line, two-drug ``Dataset`` with the requested views. + + The one builder behind every synthetic ``Dataset`` in the suite. Callers that + needed a wider gene-expression matrix or additional omics modalities used to + write out their own AnnData/MuData assembly, which is what made those test + files read as clones of each other. + + :param n_features_per_view: Columns in each cell-line modality. + :param fingerprint_width: Columns of the ``morgan_fingerprint`` in ``response.varm``. + :param extra_views: Further cell-line modalities to attach, named after the + views a featurizer reads; see :data:`_VIEW_SPECS`. + :returns: The assembled ``Dataset``. + """ + import anndata as ad + import mudata as md + import pandas as pd + + from drevalpy.types.data.dataset import Dataset + + cl_ids = np.array(["cl1", "cl2"]) + drug_ids = np.array(["d1", "d2"]) + response_ad = ad.AnnData( + X=np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32), + obs=pd.DataFrame({"cell_line_name": cl_ids, "tissue": ["Lung", "Blood"]}, index=cl_ids), + var=pd.DataFrame(index=drug_ids), + ) + response_ad.varm["morgan_fingerprint"] = np.eye(2, fingerprint_width, dtype=np.float32) + + modalities = {"response": response_ad} + for view in ("gene_expression", *extra_views): + prefix, offset = _VIEW_SPECS[view] + modalities[view] = ad.AnnData( + X=_view_matrix(n_features_per_view, offset), + obs=pd.DataFrame(index=cl_ids), + var=pd.DataFrame(index=[f"{prefix}{i}" for i in range(n_features_per_view)]), + ) + return Dataset(md.MuData(modalities), name="test") + + +def synthetic_mudataset_gene_expression_fingerprints(): + """Build a minimal Dataset with gene_expression + fingerprints for 2 cell lines and 2 drugs. + + :returns: A ``Dataset`` with a 3-gene expression modality and 2-wide fingerprints. + """ + return synthetic_mudataset() + + +def synthetic_mudataset_identity(): + """Build a minimal Dataset for identity (cell_line_id + drug_id) models.""" + import anndata as ad + import mudata as md + import pandas as pd + + from drevalpy.types.data.dataset import Dataset + + response_matrix = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + cl_ids = np.array(["cl1", "cl2"]) + drug_ids = np.array(["d1", "d2"]) + + response_ad = ad.AnnData( + X=response_matrix, + obs=pd.DataFrame({"cell_line_name": cl_ids, "tissue": ["Lung", "Blood"]}, index=cl_ids), + var=pd.DataFrame(index=drug_ids), + ) + mdata = md.MuData({"response": response_ad}) + return Dataset(mdata, name="test") + + +def _mask_2x2(*positions: tuple[int, int]) -> SplitMask: + """Build a 2x2 SplitMask with True at the given (row, col) positions.""" + mask = np.zeros((2, 2), dtype=bool) + for r, c in positions: + mask[r, c] = True + return SplitMask(mask) + + +def lpo_split_masks_all_train() -> SplitMasks: + """LPO-style masks: all 4 pairs as train, (0,0) as test, none as val.""" + return SplitMasks( + train=_mask_2x2((0, 0), (0, 1), (1, 0), (1, 1)), + test=_mask_2x2((0, 0)), + val=SplitMask(np.zeros((2, 2), dtype=bool)), + ) + + +def lco_split_masks() -> SplitMasks: + """LCO-style masks: cl0 pairs in train, cl1 pairs in test, no val.""" + return SplitMasks( + train=_mask_2x2((0, 0), (0, 1)), + test=_mask_2x2((1, 0), (1, 1)), + val=SplitMask(np.zeros((2, 2), dtype=bool)), + ) diff --git a/tests/models/test_baselines.py b/tests/models/test_baselines.py deleted file mode 100644 index 84d0bdb08..000000000 --- a/tests/models/test_baselines.py +++ /dev/null @@ -1,652 +0,0 @@ -"""Tests for the baselines in the models module that are not single drug models.""" - -import tempfile -from typing import cast - -import numpy as np -import pytest -from sklearn.linear_model import ElasticNet, Ridge - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER, TISSUE_IDENTIFIER -from drevalpy.evaluation import evaluate -from drevalpy.experiment import cross_study_prediction -from drevalpy.models import ( - MODEL_FACTORY, - NaiveCellLineMeanPredictor, - NaiveDrugMeanPredictor, - NaiveMeanEffectsPredictor, - NaivePredictor, - NaiveTissueDrugMeanPredictor, - NaiveTissueMeanPredictor, -) -from drevalpy.models.baselines.sklearn_models import RandomForest, SklearnModel -from drevalpy.models.drp_model import DRPModel - - -def test_naive_mean_effects_predictor_tissue_decomposition() -> None: - """Test tissue-aware decomposition in NaiveMeanEffectsPredictor.""" - cell_lines = np.array(["CL1", "CL1", "CL2", "CL2", "CL3", "CL3"]) - drugs = np.array(["D1", "D2", "D1", "D2", "D1", "D2"]) - response = np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]) - - output = DrugResponseDataset( - response=response, - cell_line_ids=cell_lines, - drug_ids=drugs, - ) - cell_line_input = FeatureDataset( - features={ - "CL1": { - CELL_LINE_IDENTIFIER: np.array(["CL1"]), - TISSUE_IDENTIFIER: np.array(["Lung"]), - }, - "CL2": { - CELL_LINE_IDENTIFIER: np.array(["CL2"]), - TISSUE_IDENTIFIER: np.array(["Lung"]), - }, - "CL3": { - CELL_LINE_IDENTIFIER: np.array(["CL3"]), - TISSUE_IDENTIFIER: np.array(["Blood"]), - }, - } - ) - drug_input = FeatureDataset( - features={ - "D1": {DRUG_IDENTIFIER: np.array(["D1"])}, - "D2": {DRUG_IDENTIFIER: np.array(["D2"])}, - } - ) - - model = NaiveMeanEffectsPredictor() - model.train(output=output, cell_line_input=cell_line_input, drug_input=drug_input) - - dataset_mean = np.mean(response) - assert model.dataset_mean == dataset_mean - - lung_mean = np.mean([1.0, 2.0, 3.0, 4.0]) - blood_mean = np.mean([5.0, 6.0]) - assert model.tissue_effects["Lung"] == pytest.approx(lung_mean - dataset_mean) - assert model.tissue_effects["Blood"] == pytest.approx(blood_mean - dataset_mean) - - cl1_mean = np.mean([1.0, 2.0]) - cl2_mean = np.mean([3.0, 4.0]) - cl3_mean = np.mean([5.0, 6.0]) - assert model.cell_line_effects["CL1"] == pytest.approx(cl1_mean - lung_mean) - assert model.cell_line_effects["CL2"] == pytest.approx(cl2_mean - lung_mean) - assert model.cell_line_effects["CL3"] == pytest.approx(cl3_mean - blood_mean) - - preds = model.predict( - cell_line_ids=np.array(["CL1", "CL2", "CL3"]), - drug_ids=np.array(["D1", "D2", "D1"]), - cell_line_input=cell_line_input, - ) - for i, (cl, drug) in enumerate(zip(["CL1", "CL2", "CL3"], ["D1", "D2", "D1"], strict=True)): - tissue_arr = cell_line_input.get_feature_matrix(view=TISSUE_IDENTIFIER, identifiers=np.array([cl])) - tissue_key = str(tissue_arr[0].item() if isinstance(tissue_arr[0], np.ndarray) else tissue_arr[0]) - expected = ( - dataset_mean + model.tissue_effects[tissue_key] + model.cell_line_effects[cl] + model.drug_effects[drug] - ) - assert preds[i] == pytest.approx(expected) - - with tempfile.TemporaryDirectory() as model_dir: - model.save(model_dir) - loaded = cast(NaiveMeanEffectsPredictor, NaiveMeanEffectsPredictor.load(model_dir)) - assert loaded.tissue_effects == model.tissue_effects - loaded_preds = loaded.predict( - cell_line_ids=np.array(["CL1"]), - drug_ids=np.array(["D1"]), - cell_line_input=cell_line_input, - ) - assert loaded_preds[0] == pytest.approx(preds[0]) - - -def test_naive_mean_effects_predictor_without_tissue_matches_previous_decomposition() -> None: - """Test NaiveMeanEffectsPredictor falls back to cell-line and drug effects without tissue.""" - cell_lines = np.array(["CL1", "CL1", "CL2", "CL2"]) - drugs = np.array(["D1", "D2", "D1", "D2"]) - response = np.array([1.0, 2.0, 5.0, 8.0]) - output = DrugResponseDataset(response=response, cell_line_ids=cell_lines, drug_ids=drugs) - cell_line_input = FeatureDataset( - features={ - "CL1": {CELL_LINE_IDENTIFIER: np.array(["CL1"])}, - "CL2": {CELL_LINE_IDENTIFIER: np.array(["CL2"])}, - } - ) - drug_input = FeatureDataset( - features={ - "D1": {DRUG_IDENTIFIER: np.array(["D1"])}, - "D2": {DRUG_IDENTIFIER: np.array(["D2"])}, - } - ) - - model = NaiveMeanEffectsPredictor() - model.train(output=output, cell_line_input=cell_line_input, drug_input=drug_input) - - dataset_mean = np.mean(response) - assert model.tissue_effects == {} - assert model.cell_line_effects["CL1"] == pytest.approx(np.mean([1.0, 2.0]) - dataset_mean) - assert model.cell_line_effects["CL2"] == pytest.approx(np.mean([5.0, 8.0]) - dataset_mean) - - preds = model.predict( - cell_line_ids=np.array(["CL1", "CL2"]), - drug_ids=np.array(["D1", "D2"]), - cell_line_input=cell_line_input, - ) - expected = np.array( - [ - dataset_mean + model.cell_line_effects["CL1"] + model.drug_effects["D1"], - dataset_mean + model.cell_line_effects["CL2"] + model.drug_effects["D2"], - ] - ) - np.testing.assert_allclose(preds, expected) - - -@pytest.mark.parametrize("max_depth_input, expected", [(5, 5), (10, 10), (30, 30), ("None", None)]) -def test_random_forest_respects_max_depth(max_depth_input, expected) -> None: - """Ensure RandomForest forwards max_depth to the underlying RandomForestRegressor. - - Regression test: max_depth was read from the hyperparameters but never passed to the - RandomForestRegressor constructor, so every forest was built with the default max_depth=None - regardless of the configured value. - - :param max_depth_input: max_depth value as provided via the hyperparameters - :param expected: max_depth expected on the built sklearn model - """ - model = RandomForest() - model.build_model( - { - "n_estimators": 10, - "criterion": "squared_error", - "max_samples": 0.5, - "n_jobs": 1, - "max_depth": max_depth_input, - } - ) - assert model.model.max_depth == expected - - -@pytest.mark.parametrize( - "model_name", - [ - "NaivePredictor", - "NaiveDrugMeanPredictor", - "NaiveCellLineMeanPredictor", - "NaiveMeanEffectsPredictor", - "NaiveTissueDrugMeanPredictor", - "ElasticNet", - "RandomForest", - "SVR", - "MultiViewRandomForest", - "GradientBoosting", - "AdaBoostDecisionTree", - "KNNRegressor", - "Lasso", - "MultiViewXGBoost", - ], -) -@pytest.mark.parametrize("test_mode", ["LTO", "LPO", "LCO", "LDO"]) -def test_baselines( - sample_dataset: DrugResponseDataset, - model_name: str, - test_mode: str, - cross_study_dataset: DrugResponseDataset, - data_dir, -) -> None: - """ - Test the baselines. - - :param sample_dataset: from conftest.py - :param model_name: name of the model - :param test_mode: either LPO, LCO, LDO, or LTO - :param cross_study_dataset: dataset - :param data_dir: path to the data directory - """ - drug_response = sample_dataset - drug_response.split_dataset( - n_cv_splits=2, - mode=test_mode, - validation_ratio=0.4, - ) - assert drug_response.cv_splits is not None - split = drug_response.cv_splits[0] - train_dataset = split["train"] - val_dataset = split["validation"] - - if model_name == "NaivePredictor": - model, preds_before = _call_naive_predictor( - train_dataset=train_dataset, - val_dataset=val_dataset, - test_mode=test_mode, - data_dir=data_dir, - ) - elif model_name == "NaiveDrugMeanPredictor": - model, preds_before = _call_naive_group_predictor( - "drug", - train_dataset, - val_dataset, - test_mode, - data_dir=data_dir, - ) - elif model_name == "NaiveCellLineMeanPredictor": - model, preds_before = _call_naive_group_predictor( - "cell_line", - train_dataset, - val_dataset, - test_mode, - data_dir=data_dir, - ) - elif model_name == "NaiveMeanEffectsPredictor": - model, preds_before = _call_naive_mean_effects_predictor( - train_dataset, - val_dataset, - test_mode, - data_dir=data_dir, - ) - elif model_name == "NaiveTissueMeanPredictor": - model, preds_before = _call_naive_group_predictor( - "tissue", - train_dataset, - val_dataset, - test_mode, - data_dir=data_dir, - ) - elif model_name == "NaiveTissueDrugMeanPredictor": - model, preds_before = _call_naive_tissue_drug_predictor( - train_dataset, - val_dataset, - test_mode, - data_dir=data_dir, - ) - else: - model, preds_before = _call_other_baselines( - model_name, - train_dataset, - val_dataset, - data_dir=data_dir, - ) - # Save and load test - with tempfile.TemporaryDirectory() as model_dir: - model.save(model_dir) - loaded_model = MODEL_FACTORY[model_name].load(model_dir) - train_dataset, val_dataset, cell_line_input, drug_input = _subset_dataset( - model=loaded_model, train_dataset=train_dataset, val_dataset=val_dataset, data_dir=data_dir - ) - - preds_after = loaded_model.predict( - drug_ids=val_dataset.drug_ids, - cell_line_ids=val_dataset.cell_line_ids, - drug_input=drug_input, - cell_line_input=cell_line_input, - ) - assert isinstance(preds_after, np.ndarray) - assert preds_after.shape == preds_before.shape - - # make temporary directory - with tempfile.TemporaryDirectory() as temp_dir: - print(f"Running cross-study prediction for {model_name}") - cross_study_prediction( - dataset=cross_study_dataset, - model=model, - test_mode=test_mode, - train_dataset=train_dataset, - path_data=str(data_dir), - early_stopping_dataset=None, - response_transformation=None, - path_out=temp_dir, - split_index=0, - single_drug_id=None, - ) - - -def _call_naive_predictor( - train_dataset: DrugResponseDataset, val_dataset: DrugResponseDataset, test_mode: str, data_dir -) -> tuple[DRPModel, np.ndarray]: - """ - Call the NaivePredictor model. - - :param train_dataset: training dataset - :param val_dataset: validation dataset - :param test_mode: either LPO, LCO, or LDO - :param data_dir: path to the data directory - :returns: NaivePredictor model - """ - naive = NaivePredictor() - train_dataset, val_dataset, cell_line_input, drug_input = _subset_dataset( - model=naive, train_dataset=train_dataset, val_dataset=val_dataset, data_dir=data_dir - ) - naive.train(output=train_dataset, cell_line_input=cell_line_input, drug_input=None) - val_dataset._predictions = naive.predict( - cell_line_ids=val_dataset.cell_line_ids, drug_ids=val_dataset.drug_ids, cell_line_input=cell_line_input - ) - assert val_dataset.predictions is not None - train_mean = train_dataset.response.mean() - assert train_mean == naive.dataset_mean - assert np.all(val_dataset.predictions == train_mean) - metrics = evaluate(val_dataset, metric=["Pearson"]) - assert metrics["Pearson"] == 0.0 - print(f"{test_mode}: Performance of NaivePredictor: PCC = {metrics['Pearson']}") - return naive, val_dataset._predictions - - -def _assert_group_mean( - train_dataset: DrugResponseDataset, - val_dataset: DrugResponseDataset, - group_ids: dict[str, np.ndarray], - naive_means: dict[int, float], -) -> None: - """ - Assert the group mean. - - :param train_dataset: training dataset - :param val_dataset: validation dataset - :param group_ids: group ids - :param naive_means: means - """ - common_ids = np.intersect1d(group_ids["train"], group_ids["val"]) - assert len(common_ids) > 0, ( - f"No common group identifiers found between training and validation. " - f"Train IDs: {np.unique(group_ids['train'])}, " - f"Val IDs: {np.unique(group_ids['val'])}" - ) - random_id = np.random.choice(common_ids) - group_mean = train_dataset.response[group_ids["train"] == random_id].mean() - assert group_mean == naive_means[random_id] - assert val_dataset.predictions is not None - assert np.all(val_dataset.predictions[group_ids["val"] == random_id] == group_mean) - - -def _call_naive_group_predictor( - group: str, train_dataset: DrugResponseDataset, val_dataset: DrugResponseDataset, test_mode: str, data_dir -) -> tuple[DRPModel, np.ndarray]: - naive: NaiveDrugMeanPredictor | NaiveCellLineMeanPredictor | NaiveTissueMeanPredictor - if group == "drug": - naive = NaiveDrugMeanPredictor() - elif group == "cell_line": - naive = NaiveCellLineMeanPredictor() - elif group == "tissue": - naive = NaiveTissueMeanPredictor() - else: - raise ValueError(f"Unknown group: {group}") - train_dataset, val_dataset, cell_line_input, drug_input = _subset_dataset( - model=naive, train_dataset=train_dataset, val_dataset=val_dataset, data_dir=data_dir - ) - naive.train( - output=train_dataset, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - val_dataset._predictions = naive.predict( - cell_line_ids=val_dataset.cell_line_ids, drug_ids=val_dataset.drug_ids, cell_line_input=cell_line_input - ) - assert val_dataset.predictions is not None - train_mean = train_dataset.response.mean() - assert train_mean == naive.dataset_mean - if ( - (group == "drug" and test_mode == "LDO") - or (group == "cell_line" and test_mode in ["LCO", "LTO"]) - or (group == "tissue" and test_mode == "LTO") - ): - assert np.all(val_dataset.predictions == train_mean) - elif group == "drug": - assert isinstance(naive, NaiveDrugMeanPredictor) - _assert_group_mean( - train_dataset, - val_dataset, - group_ids={ - "train": train_dataset.drug_ids, - "val": val_dataset.drug_ids, - }, - naive_means=naive.drug_means, - ) - elif group == "cell_line": - assert isinstance(naive, NaiveCellLineMeanPredictor) - _assert_group_mean( - train_dataset, - val_dataset, - group_ids={ - "train": train_dataset.cell_line_ids, - "val": val_dataset.cell_line_ids, - }, - naive_means=naive.cell_line_means, - ) - elif group == "tissue": - assert isinstance(naive, NaiveTissueMeanPredictor) - if train_dataset.tissue is None or val_dataset.tissue is None: - raise ValueError("Tissue information is missing in the dataset.") - _assert_group_mean( - train_dataset, - val_dataset, - group_ids={ - "train": train_dataset.tissue, - "val": val_dataset.tissue, - }, - naive_means=naive.tissue_means, - ) - else: - raise ValueError(f"Unknown group: {group}") - metrics = evaluate(val_dataset, metric=["Pearson"]) - print(f"{test_mode}: Performance of {naive.get_model_name()}: PCC = {metrics['Pearson']}") - if (group == "drug" and test_mode == "LDO") or (group == "cell_line" and test_mode == "LCO"): - assert metrics["Pearson"] == 0.0 - return naive, val_dataset._predictions - - -def _call_other_baselines(model: str, train_dataset: DrugResponseDataset, val_dataset: DrugResponseDataset, data_dir): - """ - Call the other baselines. - - :param model: model name - :param train_dataset: training - :param val_dataset: validation - :param data_dir: path to the data directory - :returns: model instance - """ - model_class = cast(type[DRPModel], MODEL_FACTORY[model]) - hpams = model_class.get_hyperparameter_set() - - if len(hpams) > 2: - if model in [ - "RandomForest", - "GradientBoosting", - "ElasticNet", - "AdaBoostDecisionTree", - "SVR", - "MultiViewXGBoost", - ]: - # test a hpam config with cell_line_views == "gene expression" and one with "proteomics - covered_gex = False - covered_prot = False - hpams_subset = [] - for hpam in hpams: - if hpam["cell_line_views"] == "gene_expression" and not covered_gex: - hpams_subset.append(hpam) - covered_gex = True - if hpam["cell_line_views"] == "proteomics" and not covered_prot: - hpams_subset.append(hpam) - covered_prot = True - if covered_prot and covered_gex: - break - assert len(hpams_subset) == 2, "Hpam subset is empty" - hpams = hpams_subset - else: - hpams = hpams[:2] - model_instance = model_class() - if model not in ("MultiViewXGBoost"): - assert isinstance(model_instance, SklearnModel) - for hpam_combi in hpams: - if model == "RandomForest" or model == "GradientBoosting": - hpam_combi["n_estimators"] = 2 - hpam_combi["max_depth"] = 2 - if model == "GradientBoosting": - hpam_combi["subsample"] = 0.1 - elif model == "MultiViewRandomForest": - hpam_combi["methylation_n_components"] = 10 - elif model == "AdaBoostDecisionTree": - hpam_combi["n_estimators"] = 2 - hpam_combi["max_depth"] = 2 - hpam_combi["min_samples_split"] = 2 - hpam_combi["min_samples_leaf"] = 1 - elif model == "KNNRegressor": - hpam_combi["n_neighbors"] = 3 - hpam_combi["weights"] = "distance" - hpam_combi["variance"] = 0.75 - model_instance.build_model(hpam_combi) - - train_dataset, val_dataset, cell_line_input, drug_input = _subset_dataset( - model=model_instance, train_dataset=train_dataset, val_dataset=val_dataset, data_dir=data_dir - ) - - if model == "ElasticNet": - assert isinstance(model_instance, SklearnModel) - if hpam_combi["l1_ratio"] == 0.0: - assert issubclass(type(model_instance.model), Ridge) - else: - assert issubclass(type(model_instance.model), ElasticNet) - - model_instance.train( - output=train_dataset, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - val_dataset._predictions = model_instance.predict( - drug_ids=val_dataset.drug_ids, - cell_line_ids=val_dataset.cell_line_ids, - drug_input=drug_input, - cell_line_input=cell_line_input, - ) - assert val_dataset.predictions is not None - metrics = evaluate(val_dataset, metric=["Pearson"]) - print(metrics) - assert metrics["Pearson"] >= -1 - return model_instance, val_dataset._predictions - - -def _call_naive_mean_effects_predictor( - train_dataset: DrugResponseDataset, val_dataset: DrugResponseDataset, test_mode: str, data_dir -) -> tuple[DRPModel, np.ndarray]: - """ - Test the NaiveMeanEffectsPredictor model. - - :param train_dataset: training dataset - :param val_dataset: validation dataset - :param test_mode: either LPO, LCO, or LDO - :param data_dir: path to the data directory - :returns: NaiveMeanEffectsPredictor model - """ - naive = NaiveMeanEffectsPredictor() - train_dataset, val_dataset, cell_line_input, drug_input = _subset_dataset( - model=naive, train_dataset=train_dataset, val_dataset=val_dataset, data_dir=data_dir - ) - - naive.train(output=train_dataset, cell_line_input=cell_line_input, drug_input=drug_input) - val_dataset._predictions = naive.predict( - cell_line_ids=val_dataset.cell_line_ids, - drug_ids=val_dataset.drug_ids, - cell_line_input=cell_line_input, - ) - - assert val_dataset.predictions is not None - train_mean = train_dataset.response.mean() - assert train_mean == naive.dataset_mean - - # Check that predictions are within a reasonable range - assert np.all(np.isfinite(val_dataset.predictions)) - assert np.all( - val_dataset.predictions >= 2 * np.min(train_dataset.response) - 1e-6 - ), f"Predictions below min response: {np.min(val_dataset.predictions)} < {np.min(train_dataset.response)}" - assert np.all(val_dataset.predictions <= 2 * np.max(train_dataset.response) + 1e-6), ( - f"Predictions above max response: {np.max(val_dataset.predictions)} > {np.max(train_dataset.response)}," - f"Problematic cell line: {val_dataset.cell_line_ids[np.argmax(val_dataset.predictions)]}, " - f"Problematic drug: {val_dataset.drug_ids[np.argmax(val_dataset.predictions)]}," - f"CL effect: {naive.cell_line_effects[val_dataset.cell_line_ids[np.argmax(val_dataset.predictions)]]}, " - f"Drug effect: {naive.drug_effects[val_dataset.drug_ids[np.argmax(val_dataset.predictions)]]}, " - f"Dataset mean: {naive.dataset_mean}" - ) - - metrics = evaluate(val_dataset, metric=["Pearson"]) - print(f"{test_mode}: Performance of NaiveMeanEffectsPredictor: PCC = {metrics['Pearson']}") - assert metrics["Pearson"] >= -1 # Should be within valid Pearson range - return naive, val_dataset._predictions - - -def _call_naive_tissue_drug_predictor( - train_dataset: DrugResponseDataset, val_dataset: DrugResponseDataset, test_mode: str, data_dir -) -> tuple[DRPModel, np.ndarray]: - """ - Test the NaiveTissueDrugMeanPredictor model. - - :param train_dataset: training dataset - :param val_dataset: validation dataset - :param test_mode: either LPO, LCO, LDO, or LTO - :param data_dir: path to the data directory - :returns: NaiveTissueDrugMeanPredictor model - """ - naive = NaiveTissueDrugMeanPredictor() - train_dataset, val_dataset, cell_line_input, drug_input = _subset_dataset( - model=naive, train_dataset=train_dataset, val_dataset=val_dataset, data_dir=data_dir - ) - - naive.train(output=train_dataset, cell_line_input=cell_line_input, drug_input=drug_input) - val_dataset._predictions = naive.predict( - cell_line_ids=val_dataset.cell_line_ids, - drug_ids=val_dataset.drug_ids, - cell_line_input=cell_line_input, - drug_input=drug_input, - ) - - assert val_dataset.predictions is not None - train_mean = train_dataset.response.mean() - assert train_mean == naive.dataset_mean - - # Check that predictions are within a reasonable range - assert np.all(np.isfinite(val_dataset.predictions)) - assert np.all(val_dataset.predictions >= np.min(train_dataset.response) - 1e-6) - assert np.all(val_dataset.predictions <= np.max(train_dataset.response) + 1e-6) - - # If all (tissue, drug) combinations in validation are unseen, predictions should be dataset mean - if val_dataset.tissue is not None: - tissues_val = cell_line_input.get_feature_matrix(view=TISSUE_IDENTIFIER, identifiers=val_dataset.cell_line_ids) - tissues_val_flat = np.array([t.item() if isinstance(t, np.ndarray) else t for t in tissues_val]).flatten() - drugs_val_flat = val_dataset.drug_ids - - # Check if any (tissue, drug) combination from validation was seen in training - seen_combos = set(naive.tissue_drug_means.keys()) - val_combos = {(str(tissue), str(drug)) for tissue, drug in zip(tissues_val_flat, drugs_val_flat, strict=True)} - common_combos = seen_combos & val_combos - - if len(common_combos) == 0: - # All combinations are unseen, should predict dataset mean - assert np.allclose(val_dataset.predictions, train_mean, atol=1e-6) - else: - # At least some combinations were seen, verify they use the correct mean - for combo_key in common_combos: - tissue, drug = combo_key - mask = (tissues_val_flat == tissue) & (drugs_val_flat == drug) - if np.any(mask): - expected_mean = naive.tissue_drug_means[combo_key] - assert np.allclose(val_dataset.predictions[mask], expected_mean, atol=1e-6) - - metrics = evaluate(val_dataset, metric=["Pearson"]) - print(f"{test_mode}: Performance of NaiveTissueDrugMeanPredictor: PCC = {metrics['Pearson']}") - assert metrics["Pearson"] >= -1 # Should be within valid Pearson range - return naive, val_dataset._predictions - - -def _subset_dataset(model: DRPModel, train_dataset: DrugResponseDataset, val_dataset: DrugResponseDataset, data_dir): - cell_line_input = model.load_cell_line_features(data_path=str(data_dir), dataset_name="TOYv1") - drug_input = model.load_drug_features(data_path=str(data_dir), dataset_name="TOYv1") - - if drug_input is None: - raise ValueError("Drug input is None") - - cell_lines_to_keep = cell_line_input.identifiers - drugs_to_keep = drug_input.identifiers - - len_train_before = len(train_dataset) - len_pred_before = len(val_dataset) - train_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - val_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - print(f"Reduced training dataset from {len_train_before} to {len(train_dataset)}") - print(f"Reduced val dataset from {len_pred_before} to {len(val_dataset)}") - return train_dataset, val_dataset, cell_line_input, drug_input diff --git a/tests/models/test_component_stack.py b/tests/models/test_component_stack.py new file mode 100644 index 000000000..0c090fbd3 --- /dev/null +++ b/tests/models/test_component_stack.py @@ -0,0 +1,55 @@ +"""Component stack execution through construct_model and build_component_stack.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.component_stack import build_component_stack +from drevalpy.models.config import from_spec +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_gene_expression_fingerprints, + synthetic_mudataset_identity, +) + + +def test_sklearn_model_config_builds_runnable_model() -> None: + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + mudataset = synthetic_mudataset_gene_expression_fingerprints() + split = lco_split_masks() + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert preds.shape == (2,) + assert np.isfinite(preds).all() + + +def test_naive_model_train_predict_on_synthetic_data() -> None: + model = construct_model("NaivePredictor")() + mudataset = synthetic_mudataset_identity() + split = lco_split_masks() + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert np.isfinite(preds).all() + + +def test_untrained_model_predict_raises() -> None: + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + mudataset = synthetic_mudataset_gene_expression_fingerprints() + split = lco_split_masks() + with pytest.raises(RuntimeError, match="not been trained"): + model.predict(mudataset, split) + + +def test_model_has_no_predictor_hyperparameter_mutator() -> None: + model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + assert not hasattr(model, "update_predictor_hyperparameters") + assert model._resolved_model_config is not None + assert model._resolved_model_config.predictor_values()["alpha"] == 0.1 + + +def test_druggnn_stack_configures_both_featurizers() -> None: + stack = build_component_stack(from_spec("DrugGNN")) + assert stack._cell_line_featurizer is not None + assert stack._drug_featurizer is not None diff --git a/tests/models/test_construct.py b/tests/models/test_construct.py new file mode 100644 index 000000000..2fa321f5f --- /dev/null +++ b/tests/models/test_construct.py @@ -0,0 +1,120 @@ +"""Tests for the public construct_model API.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.models import DRPModel, construct_model +from drevalpy.models.config import from_spec + + +def test_construct_model_returns_drp_model_subclass() -> None: + model_cls = construct_model("PcaIdentityRF", "pca[expression]:identity:randomForest") + assert issubclass(model_cls, DRPModel) + assert model_cls.get_model_name() == "PcaIdentityRF" + + +def test_construct_model_one_arg_zoo_name() -> None: + model_cls = construct_model("ElasticNet") + assert issubclass(model_cls, DRPModel) + assert model_cls.get_model_name() == "ElasticNet" + assert construct_model("ElasticNet") is model_cls + + +def test_construct_model_derives_early_stopping_from_predictor() -> None: + model_cls = construct_model("DipkFacade", "DIPK") + assert model_cls.supports_early_stopping() is True + + +def test_construct_model_accepts_model_config() -> None: + config = from_spec("ElasticNet") + model_cls = construct_model("ConfiguredElasticNet", config) + assert model_cls.get_model_name() == "ConfiguredElasticNet" + assert construct_model("ConfiguredElasticNet", config) is model_cls + + +def test_construct_model_invalid_spec_raises() -> None: + with pytest.raises(ValueError, match="Unknown model spec"): + construct_model("BadModel", "not-a-valid-spec") + + +def test_construct_model_one_arg_unknown_raises() -> None: + with pytest.raises(ValueError, match="Unknown model spec"): + construct_model("not-a-valid-spec") + + +def test_default_hyperparameters_for_constructed_pca_model() -> None: + from drevalpy.models.tuning.config_resolution import ( + assert_component_local_hyperparameters, + default_config_for_drp_model, + ) + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + + model_cls = construct_model("PcaOneHotRF", "pca[expression]:identity:randomForest") + hp = model_cls.get_default_hyperparameters() + + assert not any("." in key for key in hp) + assert "cell_line_featurizer.pca[expression].n_components" not in hp + assert "cell_line_featurizer.pca.0.n_components" not in hp + assert hp["n_components"] == 128 + + config = default_config_for_drp_model(model_cls) + assert config is not None + assert config.featurizer_values("cell_line", "pca[expression]")["n_components"] == 128 + assert_component_local_hyperparameters(config) + + model = model_cls(hp) + assert model._resolved_model_config is not None + assert_component_local_hyperparameters(model._resolved_model_config) + + +def test_construct_model_train_predict_smoke() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + + model_cls = construct_model("ComboRF", "raw[gene_expression]+raw[mutations]:fingerprints+identity:randomForest") + model = model_cls() + + import anndata as ad + import mudata as md + import pandas as pd + + from drevalpy.types import SplitMask, SplitMasks + from drevalpy.types.data.dataset import Dataset + + cl_ids_unique = np.array(["cl1", "cl2"]) + drug_ids_all = np.array(["d1", "d2"]) + response_matrix = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + response_ad = ad.AnnData( + X=response_matrix, + obs=pd.DataFrame({"cell_line_name": cl_ids_unique, "tissue": ["Lung", "Blood"]}, index=cl_ids_unique), + var=pd.DataFrame(index=drug_ids_all), + ) + ge_matrix = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], dtype=np.float32) + gene_expression_ad = ad.AnnData( + X=ge_matrix, + obs=pd.DataFrame(index=cl_ids_unique), + var=pd.DataFrame(index=[f"gene{i}" for i in range(3)]), + ) + mut_matrix = np.array([[0.0, 1.0], [1.0, 0.0]], dtype=np.float32) + mutations_ad = ad.AnnData( + X=mut_matrix, + obs=pd.DataFrame(index=cl_ids_unique), + var=pd.DataFrame(index=["mut0", "mut1"]), + ) + response_ad.varm["morgan_fingerprint"] = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32) + mdata = md.MuData({"response": response_ad, "gene_expression": gene_expression_ad, "mutations": mutations_ad}) + mudataset = Dataset(mdata, name="test") + split = SplitMasks( + train=SplitMask(np.array([[True, True], [False, False]])), + test=SplitMask(np.array([[False, False], [True, True]])), + val=SplitMask(np.zeros((2, 2), dtype=bool)), + ) + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert preds.shape[0] > 0 + assert np.isfinite(preds).all() diff --git a/tests/models/test_direct_component_harness.py b/tests/models/test_direct_component_harness.py new file mode 100644 index 000000000..165ddf890 --- /dev/null +++ b/tests/models/test_direct_component_harness.py @@ -0,0 +1,187 @@ +"""Direct construct_model execution for dependency-light models.""" + +from __future__ import annotations + +import tempfile +import textwrap + +import numpy as np +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import from_dict, validate +from tests._trusted_subprocess import run_trusted_python +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_gene_expression_fingerprints, + synthetic_mudataset_identity, +) + +_NAIVE_PRESETS = ( + "NaivePredictor", + "NaiveDrugMeanPredictor", + "NaiveCellLineMeanPredictor", +) +_SKLEARN_PRESETS = ( + "ElasticNet", + "RandomForest", + "Lasso", + "SVR", + "SingleDrugElasticNet", + "SingleDrugRandomForest", +) + + +@pytest.mark.parametrize("preset", _NAIVE_PRESETS) +def test_naive_direct_component_round_trip(preset: str) -> None: + mudataset = synthetic_mudataset_identity() + split = lco_split_masks() + model = construct_model(preset)() + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert preds.shape[0] > 0 + assert np.isfinite(preds).all() + with tempfile.TemporaryDirectory() as tmp: + checkpoint = f"{tmp}/model" + model.save(checkpoint) + loaded = type(model).load(checkpoint) + loaded_preds = loaded.predict(mudataset, split) + assert np.allclose(preds, loaded_preds) + + +@pytest.mark.parametrize("preset", _SKLEARN_PRESETS) +def test_sklearn_direct_component_round_trip(preset: str) -> None: + mudataset = synthetic_mudataset_gene_expression_fingerprints() + split = lco_split_masks() + model = construct_model(preset)() + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert preds.shape[0] > 0 + assert np.isfinite(preds).all() + with tempfile.TemporaryDirectory() as tmp: + checkpoint = f"{tmp}/model" + model.save(checkpoint) + loaded = type(model).load(checkpoint) + loaded_preds = loaded.predict(mudataset, split) + assert np.allclose(preds, loaded_preds) + + +def test_multi_drug_sklearn_rejects_missing_drug_featurizer() -> None: + from drevalpy.models.config import from_dict, validate + + with pytest.raises(ValueError, match="requires a drug_featurizer"): + validate( + from_dict( + { + "cell_line_featurizer": "scaledGeneExpression", + "predictor": "elasticNet", + } + ) + ) + + +def test_single_drug_sklearn_auto_injects_identity() -> None: + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + config = from_dict( + { + "cell_line_featurizer": "scaledGeneExpression", + "predictor": "singleDrugElasticNet", + } + ) + validate(config) + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "identity" + + +def test_feature_free_naive_accepts_no_featurizers() -> None: + config = from_dict({"predictor": "naiveMean"}) + validate(config) + + +@pytest.mark.slow +def test_subprocess_blocks_optional_deps_for_simple_models() -> None: + """Extended tier: proves the sklearn baselines train without the heavy engines. + + Spawns an interpreter (~2.2s) because the pristine import graph is the + assertion, so it cannot share this session's process. + """ + script = textwrap.dedent(""" + import importlib.abc + import importlib.machinery + import sys + + # Block optional heavy engines/extras not required for built-in sklearn baselines. + blocked = { + "xgboost": "blocked xgboost", + "lightgbm": "blocked lightgbm", + } + + class BlockLoader(importlib.abc.Loader): + def __init__(self, message: str) -> None: + self.message = message + + def create_module(self, spec): + raise ImportError(self.message) + + def exec_module(self, module): + raise ImportError(self.message) + + class BlockFinder(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path, target=None): + if fullname in blocked or fullname.split(".", 1)[0] in blocked: + key = fullname if fullname in blocked else fullname.split(".", 1)[0] + return importlib.machinery.ModuleSpec(fullname, BlockLoader(blocked[key])) + return None + + sys.meta_path.insert(0, BlockFinder()) + + import anndata as ad + import mudata as md + import numpy as np + import pandas as pd + + from drevalpy.types.data.dataset import Dataset + from drevalpy.types import SplitMask, SplitMasks + from drevalpy.models import construct_model + + cl_ids = np.array(["cl1", "cl2"]) + drug_ids = np.array(["d1", "d2"]) + response_matrix = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=np.float32) + response_ad = ad.AnnData( + X=response_matrix, + obs=pd.DataFrame({"cell_line_name": cl_ids, "tissue": ["L", "B"]}, index=cl_ids), + var=pd.DataFrame(index=drug_ids), + ) + ge_matrix = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], dtype=np.float32) + ge_ad = ad.AnnData( + X=ge_matrix, + obs=pd.DataFrame(index=cl_ids), + var=pd.DataFrame(index=["g0", "g1", "g2"]), + ) + response_ad.varm["morgan_fingerprint"] = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32) + mdata = md.MuData({"response": response_ad, "gene_expression": ge_ad}) + mudataset_ge = Dataset(mdata, name="test") + + response_ad2 = ad.AnnData( + X=response_matrix.copy(), + obs=pd.DataFrame({"cell_line_name": cl_ids, "tissue": ["L", "B"]}, index=cl_ids), + var=pd.DataFrame(index=drug_ids), + ) + mdata2 = md.MuData({"response": response_ad2}) + mudataset_id = Dataset(mdata2, name="test") + + split = SplitMasks( + train=SplitMask(np.array([[True, True], [False, False]])), + test=SplitMask(np.array([[False, False], [True, True]])), + val=SplitMask(np.zeros((2, 2), dtype=bool)), + ) + + naive = construct_model("NaivePredictor")({}) + naive.train(mudataset_id, split) + elastic = construct_model("ElasticNet")(construct_model("ElasticNet").get_hyperparameter_set()[0]) + elastic.train(mudataset_ge, split) + """) + completed = run_trusted_python(script) + assert completed.returncode == 0, completed.stdout + completed.stderr diff --git a/tests/models/test_drp_model.py b/tests/models/test_drp_model.py new file mode 100644 index 000000000..626b73cc7 --- /dev/null +++ b/tests/models/test_drp_model.py @@ -0,0 +1,176 @@ +"""Tests for the concrete DRPModel runtime.""" + +from __future__ import annotations + +import tempfile + +import numpy as np +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import from_spec +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_gene_expression_fingerprints, + synthetic_mudataset_identity, +) + + +def test_construct_model_supports_factory_lifecycle() -> None: + elastic_net_cls = construct_model("ElasticNet") + model = elastic_net_cls({"alpha": 0.1, "l1_ratio": 0.5}) + assert model.get_model_name() == "ElasticNet" + mudataset = synthetic_mudataset_gene_expression_fingerprints() + split = lco_split_masks() + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert preds.shape == (2,) + with tempfile.TemporaryDirectory() as tmp: + checkpoint = f"{tmp}/model" + model.save(checkpoint) + loaded = elastic_net_cls.load(checkpoint) + loaded_preds = loaded.predict(mudataset, split) + assert np.allclose(preds, loaded_preds) + + +def test_naive_model_round_trip() -> None: + naive_drug_mean_cls = construct_model("NaiveDrugMeanPredictor") + model = naive_drug_mean_cls({}) + mudataset = synthetic_mudataset_identity() + split = lco_split_masks() + model.train(mudataset, split) + with tempfile.TemporaryDirectory() as tmp: + checkpoint = f"{tmp}/model" + model.save(checkpoint) + loaded = naive_drug_mean_cls.load(checkpoint) + assert loaded._stack is not None + assert loaded._stack.is_fitted() + + +def test_empty_training_transitions() -> None: + import anndata as ad + import mudata as md + import pandas as pd + + from drevalpy.types import SplitMask, SplitMasks + from drevalpy.types.data.dataset import Dataset + + naive_drug_mean_cls = construct_model("NaiveDrugMeanPredictor") + model = naive_drug_mean_cls({}) + + # All-NaN response matrix → empty training + nan_response = np.full((2, 2), np.nan, dtype=np.float32) + cl_ids = np.array(["cl1", "cl2"]) + drug_ids = np.array(["d1", "d2"]) + empty_ad = ad.AnnData( + X=nan_response, + obs=pd.DataFrame({"cell_line_name": cl_ids, "tissue": ["L", "B"]}, index=cl_ids), + var=pd.DataFrame(index=drug_ids), + ) + empty_mudataset = Dataset(md.MuData({"response": empty_ad}), name="test") + empty_split = SplitMasks( + train=SplitMask(np.array([[True, True], [False, False]])), + test=SplitMask(np.array([[False, False], [True, True]])), + val=SplitMask(np.zeros((2, 2), dtype=bool)), + ) + + model.train(empty_mudataset, empty_split) + assert model._empty_training is True + empty_preds = model.predict(empty_mudataset, empty_split) + assert np.isnan(empty_preds).all() + + mudataset = synthetic_mudataset_identity() + split = lco_split_masks() + model.train(mudataset, split) + assert model._empty_training is False + assert model._stack is not None + assert model._stack.is_fitted() + real_preds = model.predict(mudataset, split) + assert np.isfinite(real_preds).all() + + model.train(empty_mudataset, empty_split) + assert model._empty_training is True + reempty_preds = model.predict(empty_mudataset, empty_split) + assert np.isnan(reempty_preds).all() + + model.train(mudataset, split) + assert model._empty_training is False + again_preds = model.predict(mudataset, split) + assert np.isfinite(again_preds).all() + + +def test_constructor_defaults_match_classmethod() -> None: + elastic_net_cls = construct_model("ElasticNet") + model = elastic_net_cls() + assert model.hyperparameters == elastic_net_cls.get_default_hyperparameters() + assert not hasattr(model, "configure") + assert not hasattr(elastic_net_cls, "configure") + + +def test_constructor_overrides_affect_views_before_feature_load() -> None: + random_forest_cls = construct_model("RandomForest") + defaults = random_forest_cls() + overridden = random_forest_cls({"n_estimators": 3, "max_depth": 2}) + assert overridden.hyperparameters["n_estimators"] == 3 + assert overridden.cell_line_views == defaults.cell_line_views + assert overridden.drug_views == defaults.drug_views + assert overridden._stack is not None + assert overridden._resolved_model_config is not None + assert overridden._resolved_model_config.predictor_values()["n_estimators"] == 3 + + +def test_separate_constructor_calls_have_isolated_fitted_state() -> None: + naive_drug_mean_cls = construct_model("NaiveDrugMeanPredictor") + mudataset = synthetic_mudataset_identity() + split = lco_split_masks() + first = naive_drug_mean_cls() + second = naive_drug_mean_cls() + first.train(mudataset, split) + assert first._stack is not None + assert first._stack.is_fitted() + assert second._stack is not None + assert not second._stack.is_fitted() + + +def test_from_resolved_config_and_load_skip_default_stack() -> None: + elastic_net_cls = construct_model("ElasticNet") + config = from_spec("ElasticNet", hyperparameters={"alpha": 0.2, "l1_ratio": 0.3}) + model = elastic_net_cls._from_resolved_config(config) + assert model.hyperparameters["alpha"] == 0.2 + mudataset = synthetic_mudataset_gene_expression_fingerprints() + split = lco_split_masks() + model.train(mudataset, split) + preds = model.predict(mudataset, split) + with tempfile.TemporaryDirectory() as tmp: + checkpoint = f"{tmp}/model" + model.save(checkpoint) + loaded = elastic_net_cls.load(checkpoint) + loaded_preds = loaded.predict(mudataset, split) + assert np.allclose(preds, loaded_preds) + assert loaded._stack is not None + assert loaded._stack.is_fitted() + + +def test_hyperparameters_and_views_are_immutable_after_construction() -> None: + elastic_net_cls = construct_model("ElasticNet") + model = elastic_net_cls({"alpha": 0.1, "l1_ratio": 0.5}) + assert model._stack is not None + + exposed = model.hyperparameters + exposed["alpha"] = 0.25 + assert model.hyperparameters["alpha"] == 0.1 + assert model._resolved_model_config is not None + assert model._resolved_model_config.predictor_values()["alpha"] == 0.1 + + with pytest.raises(AttributeError): + model.hyperparameters = {"alpha": 0.25} # type: ignore[misc] + + views = model.cell_line_views + views.append("mutated_view") + assert "mutated_view" not in model.cell_line_views + with pytest.raises(AttributeError): + model.cell_line_views = ["gene_expression"] # type: ignore[misc] + with pytest.raises(AttributeError): + model.drug_views = ["fingerprints"] # type: ignore[misc] + + assert not hasattr(model, "_sync_predictor_hyperparameters") diff --git a/tests/models/test_facade_parity.py b/tests/models/test_facade_parity.py new file mode 100644 index 000000000..200aa1dfe --- /dev/null +++ b/tests/models/test_facade_parity.py @@ -0,0 +1,104 @@ +"""construct_model vs ModelConfig._from_resolved_config parity.""" + +from __future__ import annotations + +import tempfile + +import numpy as np +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import from_spec +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_gene_expression_fingerprints, + synthetic_mudataset_identity, +) + +_PARITY_CASES = ( + ("NaivePredictor", "factory"), + ("NaiveDrugMeanPredictor", "factory"), + ("ElasticNet", "factory"), + ("RandomForest", "factory"), + ("PcaIdentityRF", "construct_model"), +) + + +def _model_class(model_name: str, entrypoint: str): + if entrypoint == "construct_model": + return construct_model(model_name, "pca[gene_expression]:identity:randomForest") + return construct_model(model_name) + + +def _minimal_hp(model_name: str) -> dict: + if model_name.startswith("Naive"): + return {} + if model_name == "ElasticNet": + return {"alpha": 0.1, "l1_ratio": 0.5} + return { + "n_estimators": 8, + "max_depth": 3, + "max_samples": 1.0, + "random_state": 0, + "n_jobs": 1, + } + + +@pytest.mark.parametrize(("model_name", "entrypoint"), _PARITY_CASES) +def test_construct_model_matches_from_resolved_config(model_name: str, entrypoint: str) -> None: + hp = _minimal_hp(model_name) + model_cls = _model_class(model_name, entrypoint) + if model_name.startswith("Naive"): + mudataset = synthetic_mudataset_identity() + else: + mudataset = synthetic_mudataset_gene_expression_fingerprints() + split = lco_split_masks() + + config = ( + from_spec("pca[gene_expression]:identity:randomForest", hyperparameters=hp) + if entrypoint == "construct_model" + else from_spec(model_name, hyperparameters=hp) + ) + flat_hp = model_cls.get_default_hyperparameters() if not hp else hp + facade = model_cls(flat_hp) + facade.train(mudataset, split) + facade_preds = facade.predict(mudataset, split) + + direct = model_cls._from_resolved_config(config) + direct.train(mudataset, split) + direct_preds = direct.predict(mudataset, split) + + assert facade._resolved_model_config is not None + assert facade._resolved_model_config.predictor_name == ( + config.predictor_name if hasattr(config, "predictor_name") else config.predictor.name + ) + assert np.allclose(facade_preds, direct_preds, equal_nan=True) + + +@pytest.mark.parametrize(("model_name", "entrypoint"), _PARITY_CASES) +def test_construct_model_save_load_preserves_predictions(model_name: str, entrypoint: str) -> None: + hp = _minimal_hp(model_name) + model_cls = _model_class(model_name, entrypoint) + flat_hp = model_cls.get_default_hyperparameters() if not hp else hp + if model_name.startswith("Naive"): + mudataset = synthetic_mudataset_identity() + else: + mudataset = synthetic_mudataset_gene_expression_fingerprints() + split = lco_split_masks() + + model = model_cls(flat_hp) + model.train(mudataset, split) + before_preds = model.predict(mudataset, split) + assert model._stack is not None + before_state = model._stack.component_state() + + with tempfile.TemporaryDirectory() as model_dir: + checkpoint = f"{model_dir}/model" + model.save(checkpoint) + loaded = model_cls.load(checkpoint) + after_preds = loaded.predict(mudataset, split) + assert loaded._stack is not None + after_state = loaded._stack.component_state() + + assert np.allclose(before_preds, after_preds, equal_nan=True) + assert before_state.keys() == after_state.keys() diff --git a/tests/models/test_factory.py b/tests/models/test_factory.py new file mode 100644 index 000000000..d920f312b --- /dev/null +++ b/tests/models/test_factory.py @@ -0,0 +1,35 @@ +"""Tests for factory/zoo name resolution parity with configure flat HP.""" + +from __future__ import annotations + +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import ResolvedModelConfig +from drevalpy.models.factory import model_config_for_name +from drevalpy.models.tuning.public_flat import config_from_public_hyperparameters + + +def test_model_config_for_name_matches_configure_path_for_predictor_hp() -> None: + model_cls = construct_model("MultiViewRandomForest") + flat = {"n_estimators": 8} + via_factory = model_config_for_name("MultiViewRandomForest", flat) + via_configure = config_from_public_hyperparameters(model_cls, flat) + assert via_configure is not None + assert isinstance(via_factory, ResolvedModelConfig) + assert isinstance(via_configure, ResolvedModelConfig) + assert via_factory.template.cell_line_featurizer is not None + assert via_configure.template.cell_line_featurizer is not None + assert via_factory.template.cell_line_featurizer.name == via_configure.template.cell_line_featurizer.name + assert via_factory.predictor_values()["n_estimators"] == 8 + assert via_configure.predictor_values()["n_estimators"] == 8 + + +def test_model_config_for_name_forwards_prediction_mode(monkeypatch: pytest.MonkeyPatch) -> None: + from drevalpy.registry.predictor import get as get_predictor + from drevalpy.types.enums.prediction_mode import PredictionMode + + monkeypatch.setattr(get_predictor("elasticNet"), "supported_modes", frozenset(PredictionMode)) + config = model_config_for_name("ElasticNet", prediction_mode=PredictionMode.CLASSIFICATION) + assert not isinstance(config, ResolvedModelConfig) + assert config.prediction_mode == PredictionMode.CLASSIFICATION diff --git a/tests/models/test_global_models.py b/tests/models/test_global_models.py index ae633acd1..edf4f3e01 100644 --- a/tests/models/test_global_models.py +++ b/tests/models/test_global_models.py @@ -1,281 +1,220 @@ -"""Test the neural networks that are not single drug models.""" +"""Train/predict/round-trip gate for the models that are not single-drug models.""" + +from __future__ import annotations -import os import tempfile -from typing import cast +from typing import Any, cast import numpy as np import pytest -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.evaluation import evaluate -from drevalpy.experiment import cross_study_prediction -from drevalpy.models import MODEL_FACTORY +from drevalpy.models import construct_model +from drevalpy.models.config import CellLineFeaturizerConfig, DrugFeaturizerConfig, ModelConfig from drevalpy.models.drp_model import DRPModel +from drevalpy.models.zoo import get_zoo_config +from drevalpy.types import SplitMasks +from drevalpy.types.data.dataset import Dataset +from tests.synthetic.variants import ( + SAVE_LOAD_DEFECTS, + SUPPORTED_GLOBAL_MODELS, + build_partial_coverage_dataset, + model_param, +) +#: Extended tier: every test here trains a real model. PharmaFormer alone is 2.1s +#: and the file is ~2.6s. The dependency-light models keep their fast-tier +#: coverage in ``test_direct_component_harness.py``. +pytestmark = pytest.mark.slow -@pytest.mark.parametrize("test_mode", ["LTO"]) -@pytest.mark.parametrize( - "model_name", - [ - "DrugGNN", - "SRMF", - "DIPK", - "SimpleNeuralNetwork[fingerprints]", - "SimpleNeuralNetwork[chemberta]", - "MultiViewNeuralNetwork", - "PharmaFormer", - "Precily", - "SparseGO", - ], -) -def test_global_models( - sample_dataset: DrugResponseDataset, - model_name: str, - test_mode: str, - cross_study_dataset: DrugResponseDataset, - data_dir, -) -> None: - """ - Test global drug response models. - - :param sample_dataset: from conftest.py - :param model_name: e.g., DIPK, SRMF, SimpleNeuralNetwork, or MultiViewNeuralNetwork - :param test_mode: LPO - :param cross_study_dataset: from conftest.py - :param data_dir: path to the data directory - :raises ValueError: if drug input is None + +def _zoo_config_variant(name: str, **updates: Any) -> ModelConfig: + """Build a variant of a zoo preset by re-validating an updated dump. + + :param name: Zoo preset name. + :param updates: ``ModelConfig`` field overrides. + :returns: Newly validated ``ModelConfig``. """ - drug_response = sample_dataset - drug_response.split_dataset(n_cv_splits=2, mode=test_mode, validation_ratio=0.4) - assert drug_response.cv_splits is not None - split = drug_response.cv_splits[0] - train_dataset = split["train"] - val_es_dataset = split["validation_es"] - es_dataset = split["early_stopping"] - val_dataset = split["validation"] + payload = get_zoo_config(name).model_dump(mode="python") + payload.update(updates) + return ModelConfig.model_validate(payload) + +def _resolve_global_model_name(model_name: str) -> tuple[str, str]: whole_name = model_name if model_name.startswith("SimpleNeuralNetwork"): model_name = "SimpleNeuralNetwork" + return whole_name, model_name + + +def _construct_global_model_class(whole_name: str, model_name: str) -> type[DRPModel]: + if whole_name == "SimpleNeuralNetwork[chemberta]": + config = _zoo_config_variant( + "SimpleNeuralNetwork", + drug_featurizer=DrugFeaturizerConfig( + name="view", + options={"view": "chemberta"}, + ), + ) + return cast(type[DRPModel], construct_model(model_name, config)) + return cast(type[DRPModel], construct_model(model_name)) - model_class = cast(type[DRPModel], MODEL_FACTORY[model_name]) - model = model_class() - hpams = model.get_hyperparameter_set() - hpam_combi = hpams[0] + +def _apply_global_model_hpam_tweaks(model_name: str, hpam_combi: dict) -> None: + """Shrink the model to the smallest configuration that still exercises it.""" if model_name == "DIPK": + hpam_combi["batch_size"] = 1 hpam_combi["epochs"] = 1 hpam_combi["epochs_autoencoder"] = 1 hpam_combi["heads"] = 1 elif model_name in ["SimpleNeuralNetwork", "MultiViewNeuralNetwork"]: hpam_combi["units_per_layer"] = [2, 2] hpam_combi["max_epochs"] = 1 - if whole_name == "SimpleNeuralNetwork[chemberta]": - hpam_combi["drug_views"] = "drug_chemberta_embeddings" - elif whole_name == "SimpleNeuralNetwork[fingerprints]": - hpam_combi["drug_views"] = "fingerprints" elif model_name == "PharmaFormer": hpam_combi["epochs"] = 1 hpam_combi["patience"] = 2 - elif model_name == "Precily": - hpam_combi["epochs"] = 1 - hpam_combi["batch_size"] = 32 - elif model_name == "SparseGO": + elif model_name in {"Precily", "SparseGO"}: hpam_combi["epochs"] = 1 hpam_combi["batch_size"] = 32 + elif model_name == "SRMF": + hpam_combi["max_iter"] = 2 elif model_name == "AdaBoostDecisionTree": hpam_combi["max_depth"] = 2 hpam_combi["min_samples_split"] = 2 hpam_combi["min_samples_leaf"] = 2 hpam_combi["n_estimators"] = 2 - model.build_model(hyperparameters=hpam_combi) - cell_line_input = model.load_cell_line_features(data_path=str(data_dir), dataset_name="TOYv1") - drug_input = model.load_drug_features(data_path=str(data_dir), dataset_name="TOYv1") - if drug_input is None: - raise ValueError("Drug input is None") - cell_lines_to_keep = cell_line_input.identifiers - drugs_to_keep = drug_input.identifiers - train_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - val_es_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - es_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - val_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) +def _first_lto_fold(mudataset: Dataset) -> SplitMasks: + """Return the first Leave-Tissue-Out fold of *mudataset*. - with tempfile.TemporaryDirectory() as tmpdirname: - if model_name == "SRMF": - # no early stopping - model.train( - output=train_dataset, - cell_line_input=cell_line_input, - drug_input=drug_input, - output_earlystopping=None, - model_checkpoint_dir=tmpdirname, - ) - else: - model.train( - output=train_dataset, - cell_line_input=cell_line_input, - drug_input=drug_input, - output_earlystopping=es_dataset, - model_checkpoint_dir=tmpdirname, - ) - if model_name == "DIPK": - # test batch size = 1 - model.batch_size = 1 # type: ignore - if model_name == "SRMF": - # no early stopping - prediction_dataset = val_dataset - else: - prediction_dataset = val_es_dataset - prediction_dataset._predictions = model.predict( - drug_ids=prediction_dataset.drug_ids, - cell_line_ids=prediction_dataset.cell_line_ids, - drug_input=drug_input, - cell_line_input=cell_line_input, - ) - # Save and load test (should either succeed or raise NotImplementedError) + :param mudataset: Dataset to split. + :returns: The first fold's split masks. + """ + from drevalpy.registry.splitter import get as get_splitter + + return get_splitter("LTO")(mudataset, n_splits=2, validation_ratio=0.4)[0] + + +def _assert_round_trips( + model: DRPModel, + model_class: type[DRPModel], + model_name: str, + mudataset: Dataset, + split: SplitMasks, + preds: np.ndarray, +) -> None: + """Assert a save/load cycle reproduces the prediction shape. + + Models with a known set-dependent-featurizer defect are asserted to still + raise it, so the defect cannot be fixed without this test noticing. + + :param model: Trained model instance. + :param model_class: Class used to reload the checkpoint. + :param model_name: Model name, used to look up known defects. + :param mudataset: Dataset the model was trained on. + :param split: Split the predictions were made over. + :param preds: Predictions from the in-memory model. + """ + defect = SAVE_LOAD_DEFECTS.get(model_name) with tempfile.TemporaryDirectory() as model_dir: - try: - model.save(model_dir) - loaded_model = model_class.load(model_dir) - if model_name == "SparseGO": - loaded_model.load_cell_line_features(data_path=str(data_dir), dataset_name="TOYv1") - assert isinstance(loaded_model, DRPModel) - - preds_after = loaded_model.predict( - drug_ids=prediction_dataset.drug_ids, - cell_line_ids=prediction_dataset.cell_line_ids, - drug_input=drug_input, - cell_line_input=cell_line_input, - ) - - assert prediction_dataset._predictions.shape == preds_after.shape - assert isinstance(preds_after, np.ndarray) - except NotImplementedError: - print(f"{model_name}: save/load not implemented") - - metrics = evaluate(prediction_dataset, metric=["Pearson"]) - print(f"Model: {model_name}, Pearson: {metrics['Pearson']}") - assert metrics["Pearson"] >= -1.0 - - with tempfile.TemporaryDirectory() as temp_dir: - print(f"Running cross-study prediction for {model_name}") - cross_study_prediction( - dataset=cross_study_dataset, - model=model, - test_mode=test_mode, - train_dataset=train_dataset, - path_data=str(data_dir), - early_stopping_dataset=None, - response_transformation=None, - path_out=temp_dir, - split_index=0, - single_drug_id=None, - ) + checkpoint = f"{model_dir}/model" + model.save(checkpoint) + loaded_model = model_class.load(checkpoint) + assert isinstance(loaded_model, DRPModel) + if defect is not None: + with pytest.raises(RuntimeError, match=defect): + loaded_model.predict(mudataset, split) + return + assert preds.shape == loaded_model.predict(mudataset, split).shape + + +@pytest.mark.parametrize("model_name", [model_param(name) for name in SUPPORTED_GLOBAL_MODELS]) +def test_global_models(synthetic_dataset: Dataset, model_name: str) -> None: + """Each global model trains, predicts and reloads on a Leave-Tissue-Out fold. + + :param synthetic_dataset: Session-scoped synthetic raw-omics dataset. + :param model_name: Model name, possibly with a ``[view]`` suffix. + """ + split = _first_lto_fold(synthetic_dataset) + + whole_name, model_name = _resolve_global_model_name(model_name) + model_class = _construct_global_model_class(whole_name, model_name) + hpam_combi = dict(model_class.get_hyperparameter_set()[0]) + _apply_global_model_hpam_tweaks(model_name, hpam_combi) + model = model_class(hpam_combi) + + with tempfile.TemporaryDirectory() as tmpdirname: + model.train(synthetic_dataset, split, model_checkpoint_dir=tmpdirname) + + preds = model.predict(synthetic_dataset, split) + assert isinstance(preds, np.ndarray) + assert preds.shape[0] > 0 + + _assert_round_trips(model, model_class, model_name, synthetic_dataset, split, preds) + + +def test_multi_view_neural_network_custom_views(synthetic_dataset: Dataset) -> None: + """MultiViewNeuralNetwork runs with a non-default cell-line view. + Uses an existing modality (methylation) through the raw featurizer to verify + the flexible input pipeline works end-to-end including save/load. Overriding + the preset's featurizer list also drops its copy-number view, so this variant + covers the single-view path rather than the preset's multi-omics one. -@pytest.mark.parametrize("test_mode", ["LTO"]) -def test_multi_view_neural_network_custom_views(sample_dataset: DrugResponseDataset, test_mode: str, data_dir) -> None: + :param synthetic_dataset: Session-scoped synthetic raw-omics dataset. """ - Test MultiViewNeuralNetwork with a fully custom cell line view (not a built-in omic). + split = _first_lto_fold(synthetic_dataset) + + model_class = cast( + type[DRPModel], + construct_model( + "MultiViewNeuralNetwork", + _zoo_config_variant( + "MultiViewNeuralNetwork", + cell_line_featurizer=CellLineFeaturizerConfig( + name="raw", + view="methylation", + ), + ), + ), + ) + + model = model_class({"units_per_layer": [2, 2], "dropout_prob": 0.3, "max_epochs": 1}) + + with tempfile.TemporaryDirectory() as tmpdirname: + model.train(synthetic_dataset, split, model_checkpoint_dir=tmpdirname) + + preds = model.predict(synthetic_dataset, split) + assert isinstance(preds, np.ndarray) + assert preds.shape[0] > 0 + + _assert_round_trips(model, model_class, "MultiViewNeuralNetwork", synthetic_dataset, split, preds) - Creates a fake CSV feature file and uses it via load_generic_csv to verify - the flexible input pipeline works end-to-end including save/load without methylation. - :param sample_dataset: from conftest.py - :param test_mode: LTO - :param data_dir: path to the data directory - :raises ValueError: if drug input is None +def test_partial_omics_coverage_reaches_the_nan_filtering_path() -> None: + """The partial-coverage variant really does leave cell lines without omics data. + + Guards the premise of the test below: if coverage silently became complete, + that test would start passing for the wrong reason. """ - import pandas as pd + mudataset = build_partial_coverage_dataset() + covered = mudataset.entities_with_modality("gene_expression") + assert len(covered) < len(mudataset.cell_line_ids) - path_data = data_dir - toy_dir = data_dir / "TOYv1" - # Read existing cell line names from gene_expression.csv (cell_line_name is the index used by the loader) - gex = pd.read_csv(toy_dir / "gene_expression.csv") - cell_line_names = gex["cell_line_name"].values +def test_partial_omics_coverage_trains() -> None: + """Training over partial omics coverage exercises ``PredictorBase``' NaN filtering. - # Create a fake custom feature CSV with random data, matching the real CSV format - rng = np.random.default_rng(42) - n_features = 10 - custom_df = pd.DataFrame( - rng.standard_normal((len(cell_line_names), n_features)), - columns=[f"feat_{i}" for i in range(n_features)], - ) - custom_df.insert(0, "cell_line_name", cell_line_names) - custom_csv_path = toy_dir / "custom_test_view.csv" - custom_df.to_csv(custom_csv_path, index=False) - - try: - drug_response = sample_dataset - drug_response.split_dataset(n_cv_splits=2, mode=test_mode, validation_ratio=0.4) - assert drug_response.cv_splits is not None - split = drug_response.cv_splits[0] - train_dataset = split["train"] - es_dataset = split["early_stopping"] - val_es_dataset = split["validation_es"] - - model_class = cast(type[DRPModel], MODEL_FACTORY["MultiViewNeuralNetwork"]) - model = model_class() - - hpam_combi = { - "cell_line_views": ["custom_test_view"], - "drug_views": "fingerprints", - "units_per_layer": [2, 2], - "dropout_prob": 0.3, - "max_epochs": 1, - } - model.build_model(hyperparameters=hpam_combi) - - cell_line_input = model.load_cell_line_features(data_path=str(path_data), dataset_name="TOYv1") - drug_input = model.load_drug_features(data_path=str(path_data), dataset_name="TOYv1") - if drug_input is None: - raise ValueError("Drug input is None") - - cell_lines_to_keep = cell_line_input.identifiers - drugs_to_keep = drug_input.identifiers - train_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - es_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - val_es_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - - with tempfile.TemporaryDirectory() as tmpdirname: - model.train( - output=train_dataset, - cell_line_input=cell_line_input, - drug_input=drug_input, - output_earlystopping=es_dataset, - model_checkpoint_dir=tmpdirname, - ) - - preds = model.predict( - drug_ids=val_es_dataset.drug_ids, - cell_line_ids=val_es_dataset.cell_line_ids, - drug_input=drug_input, - cell_line_input=cell_line_input, - ) - assert isinstance(preds, np.ndarray) - assert len(preds) == len(val_es_dataset) - - # Save and load roundtrip — no methylation files should be required - with tempfile.TemporaryDirectory() as model_dir: - model.save(model_dir) - # Verify no methylation files were saved - assert not os.path.exists(os.path.join(model_dir, "methylation_scaler.pkl")) - assert not os.path.exists(os.path.join(model_dir, "methylation_pca.pkl")) - - loaded_model = model_class.load(model_dir) - assert isinstance(loaded_model, DRPModel) - - preds_after = loaded_model.predict( - drug_ids=val_es_dataset.drug_ids, - cell_line_ids=val_es_dataset.cell_line_ids, - drug_input=drug_input, - cell_line_input=cell_line_input, - ) - assert preds.shape == preds_after.shape - finally: - # Clean up the fake CSV - if os.path.exists(custom_csv_path): - os.remove(custom_csv_path) + ``fit`` routes the batch through ``ModelInputBatch.subset_pairs``, which + narrows the early-stopping pairs to the drugs surviving the NaN mask. That + mask spans every drug, so this is the multi-drug path; ``train`` completing + is the assertion. Every early-stopping predictor takes the same route, so + one representative is enough. + """ + mudataset = build_partial_coverage_dataset() + split = _first_lto_fold(mudataset) + model_class = cast(type[DRPModel], construct_model("SimpleNeuralNetwork")) + model = model_class({"units_per_layer": [2, 2], "max_epochs": 1}) + + with tempfile.TemporaryDirectory() as tmpdirname: + model.train(mudataset, split, model_checkpoint_dir=tmpdirname) diff --git a/tests/models/test_hp_key_grammar.py b/tests/models/test_hp_key_grammar.py new file mode 100644 index 000000000..4336fa5cc --- /dev/null +++ b/tests/models/test_hp_key_grammar.py @@ -0,0 +1,179 @@ +"""Tests for the qualified hyperparameter key grammar. + +The grammar is the one place that decides what a qualified key looks like; both +``drevalpy.models.config`` and ``drevalpy.models.tuning`` build and parse keys +through it, so round-tripping ``*_prefix`` against ``split_*`` is asserted here +rather than in either consumer. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.models._hp_key_grammar import ( + CELL_LINE_SLOT, + DRUG_SLOT, + FEATURIZER_SLOTS, + PREDICTOR_SLOT, + REGISTRY_TO_SLOT, + SLOT_TO_REGISTRY, + featurizer_prefix, + is_featurizer_slot_key, + predictor_prefix, + reject_indexed_featurizer_key, + split_predictor_key, + split_prefixed_key, +) + + +class TestSlotConstants: + """The slot names are the wire format of every persisted config.""" + + def test_slot_names(self) -> None: + assert (CELL_LINE_SLOT, DRUG_SLOT, PREDICTOR_SLOT) == ( + "cell_line_featurizer", + "drug_featurizer", + "predictor", + ) + + def test_featurizer_slots_exclude_the_predictor(self) -> None: + assert FEATURIZER_SLOTS == (CELL_LINE_SLOT, DRUG_SLOT) + assert PREDICTOR_SLOT not in FEATURIZER_SLOTS + + def test_registry_and_slot_maps_are_inverses(self) -> None: + assert REGISTRY_TO_SLOT == {"cell_line": CELL_LINE_SLOT, "drug": DRUG_SLOT} + assert SLOT_TO_REGISTRY == {slot: registry for registry, slot in REGISTRY_TO_SLOT.items()} + + +class TestPrefixBuilders: + """Key construction is a pure string join over the slot names.""" + + @pytest.mark.parametrize( + ("registry", "selector", "expected"), + [ + pytest.param("cell_line", "pca", "cell_line_featurizer.pca.n_components", id="plain-selector"), + pytest.param( + "cell_line", + "pca[methylation]", + "cell_line_featurizer.pca[methylation].n_components", + id="view-qualified-selector", + ), + pytest.param("drug", "fingerprints", "drug_featurizer.fingerprints.n_components", id="drug-slot"), + ], + ) + def test_featurizer_prefix(self, registry: str, selector: str, expected: str) -> None: + assert featurizer_prefix(registry, selector, "n_components") == expected + + def test_featurizer_prefix_rejects_an_unknown_registry(self) -> None: + with pytest.raises(KeyError): + featurizer_prefix("proteome", "pca", "n_components") + + def test_predictor_prefix(self) -> None: + assert predictor_prefix("elasticNet", "alpha") == "predictor.elasticNet.alpha" + + +class TestIsFeaturizerSlotKey: + """Only the two featurizer slots count as already-addressed keys.""" + + @pytest.mark.parametrize( + ("key", "expected"), + [ + pytest.param("cell_line_featurizer.pca.n_components", True, id="cell-line-slot"), + pytest.param("drug_featurizer.fingerprints.radius", True, id="drug-slot"), + pytest.param("predictor.elasticNet.alpha", False, id="predictor-slot"), + pytest.param("n_components", False, id="short-key"), + pytest.param("cell_line_featurizer", False, id="bare-slot-without-dot"), + ], + ) + def test_classifies_a_key(self, key: str, expected: bool) -> None: + assert is_featurizer_slot_key(key) is expected + + +class TestRejectIndexedFeaturizerKey: + """The removed ``slot.name.<index>.param`` notation is refused outright.""" + + @pytest.mark.parametrize( + "key", + [ + pytest.param("cell_line_featurizer.pca.0.n_components", id="cell-line-slot"), + pytest.param("drug_featurizer.fingerprints.1.radius", id="drug-slot"), + pytest.param("cell_line_featurizer.pca.12.n_components", id="multi-digit-index"), + pytest.param("cell_line_featurizer.pca.0.nested.param", id="dotted-parameter"), + ], + ) + def test_rejects_an_indexed_key(self, key: str) -> None: + with pytest.raises(ValueError, match="no longer supported"): + reject_indexed_featurizer_key(key) + + def test_error_suggests_the_qualified_selector(self) -> None: + with pytest.raises(ValueError, match=r"cell_line_featurizer\.pca\[<view>\]\.n_components"): + reject_indexed_featurizer_key("cell_line_featurizer.pca.0.n_components") + + @pytest.mark.parametrize( + "key", + [ + pytest.param("cell_line_featurizer.pca.n_components", id="unindexed"), + pytest.param("cell_line_featurizer.pca[gene_expression].n_components", id="qualified-selector"), + pytest.param("cell_line_featurizer.pca.view.n_components", id="non-numeric-segment"), + pytest.param("predictor.elasticNet.0.alpha", id="predictor-slot-is-not-matched"), + pytest.param("cell_line_featurizer.pca.0", id="no-parameter-segment"), + ], + ) + def test_leaves_other_keys_alone(self, key: str) -> None: + assert reject_indexed_featurizer_key(key) is None + + +class TestSplitPrefixedKey: + """Parsing inverts :func:`featurizer_prefix` and refuses everything else.""" + + @pytest.mark.parametrize( + ("registry", "selector", "param"), + [ + pytest.param("cell_line", "pca", "n_components", id="plain-selector"), + pytest.param("cell_line", "pca[methylation]", "n_components", id="view-qualified-selector"), + pytest.param("drug", "fingerprints", "radius", id="drug-slot"), + pytest.param("drug", "fingerprints", "nested.param", id="dotted-parameter"), + ], + ) + def test_round_trips_a_built_key(self, registry: str, selector: str, param: str) -> None: + assert split_prefixed_key(featurizer_prefix(registry, selector, param)) == (registry, selector, param) + + @pytest.mark.parametrize( + "key", + [ + pytest.param("predictor.elasticNet.alpha", id="predictor-slot"), + pytest.param("n_components", id="short-key"), + pytest.param("cell_line_featurizer.pca", id="missing-parameter"), + ], + ) + def test_returns_none_for_a_non_featurizer_key(self, key: str) -> None: + assert split_prefixed_key(key) is None + + def test_propagates_the_indexed_key_rejection(self) -> None: + with pytest.raises(ValueError, match="no longer supported"): + split_prefixed_key("cell_line_featurizer.pca.0.n_components") + + +class TestSplitPredictorKey: + """Parsing inverts :func:`predictor_prefix` and refuses everything else.""" + + @pytest.mark.parametrize( + ("name", "param"), + [ + pytest.param("elasticNet", "alpha", id="plain-parameter"), + pytest.param("randomForest", "nested.param", id="dotted-parameter"), + ], + ) + def test_round_trips_a_built_key(self, name: str, param: str) -> None: + assert split_predictor_key(predictor_prefix(name, param)) == (name, param) + + @pytest.mark.parametrize( + "key", + [ + pytest.param("cell_line_featurizer.pca.n_components", id="featurizer-slot"), + pytest.param("predictor.elasticNet", id="missing-parameter"), + pytest.param("alpha", id="short-key"), + ], + ) + def test_returns_none_for_a_non_predictor_key(self, key: str) -> None: + assert split_predictor_key(key) is None diff --git a/tests/models/test_init.py b/tests/models/test_init.py new file mode 100644 index 000000000..d7dda5850 --- /dev/null +++ b/tests/models/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the public :mod:`drevalpy.models` package surface. + +Forty-odd modules import ``DRPModel``, ``construct_model`` and ``load_model`` +from this barrel rather than from the modules that define them, which is the +whole point of the barrel: the construction and persistence internals behind +these three names are free to move. This file therefore records the names and +their kinds and deliberately leaves the origin table empty, saying nothing about +which module supplies them. +""" + +from __future__ import annotations + +import inspect + +from drevalpy import models +from tests._barrel_surface import DeclaredSurface + +#: The names the module docstring and ``__all__`` promise to callers. +PROMISED_EXPORTS = ("DRPModel", "construct_model", "load_model") + + +class TestModelsSurface(DeclaredSurface): + barrel = models + unpinned_names = PROMISED_EXPORTS + callable_names = ("construct_model", "load_model") + + +def test_drp_model_is_exported_as_a_class() -> None: + assert inspect.isclass(models.DRPModel) diff --git a/tests/models/test_literature_lifecycle.py b/tests/models/test_literature_lifecycle.py new file mode 100644 index 000000000..505d86fba --- /dev/null +++ b/tests/models/test_literature_lifecycle.py @@ -0,0 +1,109 @@ +"""Smoke tests for literature models routed through the native facade.""" + +from __future__ import annotations + +import tempfile + +import numpy as np +import pytest + +from drevalpy.models import construct_model +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.types import SplitMasks +from drevalpy.types.data.dataset import Dataset +from tests.models.synthetic_fixtures import lco_split_masks, synthetic_mudataset + +#: The multi-omics views ``MultiViewNeuralNetwork`` reads beyond gene expression. +MULTIVIEW_EXTRA_VIEWS = ("methylation", "mutations", "copy_number_variation_gistic") + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def _dataset(*, extra_views: tuple[str, ...] = ()) -> tuple[Dataset, SplitMasks]: + """Build a four-feature synthetic dataset and an LCO split over it. + + :param extra_views: Cell-line modalities beyond ``gene_expression``. + :returns: ``(dataset, split)``. + """ + dataset = synthetic_mudataset(n_features_per_view=4, fingerprint_width=4, extra_views=extra_views) + return dataset, lco_split_masks() + + +LITERATURE_MODEL_NAMES = ( + "DIPK", + "DrugGNN", + "MOLIR", + "PharmaFormer", + "Precily", + "SRMF", + "SimpleNeuralNetwork", + "MultiViewNeuralNetwork", + "SuperFELTR", + "SparseGO", +) + + +@pytest.mark.parametrize("model_name", LITERATURE_MODEL_NAMES) +def test_literature_models_build_with_defaults(model_name: str) -> None: + model_cls = construct_model(model_name) + hyperparameters = dict(model_cls.get_hyperparameter_set()[0]) + if model_name == "DIPK": + hyperparameters.update({"epochs": 1, "epochs_autoencoder": 1, "heads": 1}) + elif model_name in {"SimpleNeuralNetwork", "MultiViewNeuralNetwork"}: + hyperparameters.update({"units_per_layer": [2, 2], "max_epochs": 1}) + elif model_name == "PharmaFormer": + hyperparameters.update({"epochs": 1, "patience": 2}) + elif model_name == "Precily": + hyperparameters.update({"epochs": 1, "batch_size": 32}) + elif model_name == "SparseGO": + hyperparameters.update({"epochs": 1, "batch_size": 32}) + model_cls(hyperparameters) + + +@pytest.mark.parametrize( + ("model_name", "hyperparameters", "extra_views"), + [ + ("SimpleNeuralNetwork", {"units_per_layer": [2, 2], "max_epochs": 1}, ()), + ("SRMF", {"K": 2, "max_iter": 2, "n_features": 4}, ()), + ( + "MultiViewNeuralNetwork", + { + "units_per_layer": [2, 2], + "max_epochs": 1, + "methylation_pca_components": 2, + }, + MULTIVIEW_EXTRA_VIEWS, + ), + ("NaiveDrugMeanPredictor", {}, ()), + ], +) +def test_literature_model_lifecycle( + model_name: str, + hyperparameters: dict, + extra_views: tuple[str, ...], +) -> None: + mudataset, split = _dataset(extra_views=extra_views) + model = construct_model(model_name)(hyperparameters) + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert preds.shape[0] > 0 + assert np.isfinite(preds).all() + + with tempfile.TemporaryDirectory() as directory: + checkpoint = f"{directory}/model" + model.save(checkpoint) + loaded = type(model).load(checkpoint) + loaded_preds = loaded.predict(mudataset, split) + assert np.allclose(preds, loaded_preds, rtol=1e-5, atol=1e-5) + + +def test_untrained_component_model_raises() -> None: + model_cls = construct_model("elasticNet", "raw[gene_expression]:fingerprints:elasticNet") + model = model_cls({}) + mudataset, split = _dataset() + + with pytest.raises(RuntimeError, match="not been trained"): + model.predict(mudataset, split) diff --git a/tests/models/test_model_config_native.py b/tests/models/test_model_config_native.py new file mode 100644 index 000000000..f48c18657 --- /dev/null +++ b/tests/models/test_model_config_native.py @@ -0,0 +1,76 @@ +"""Invariants for ModelConfig-native built-in model execution.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import numpy as np +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import ModelConfig, from_spec, validate +from drevalpy.models.drp_model import DRPModel +from drevalpy.models.factory import model_config_for_name +from drevalpy.models.zoo import list_zoo_names +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_gene_expression_fingerprints, + synthetic_mudataset_identity, +) + + +@pytest.mark.parametrize("name", list_zoo_names(include_external=False)) +def test_model_factory_names_resolve_to_model_config(name: str) -> None: + config = model_config_for_name(name) + assert isinstance(config, ModelConfig) + validate(config) + assert config.predictor.name + + +@pytest.mark.parametrize("name", list_zoo_names(include_external=False)) +def test_zoo_entries_create_runnable_models(name: str) -> None: + config = from_spec(name) + assert isinstance(config, ModelConfig) + validate(config) + model_cls = construct_model(name) + assert issubclass(model_cls, DRPModel) + assert model_cls() is not None + + +def test_no_pair_context_in_production_code() -> None: + repo_root = Path(__file__).resolve().parents[2] / "drevalpy" + hits = [ + path.relative_to(repo_root.parent) + for path in repo_root.rglob("*.py") + if "pair_context" in path.read_text(encoding="utf-8") + ] + assert not hits, f"pair_context found in production code: {hits}" + + +def test_multiview_baselines_are_construct_model_classes() -> None: + for name in ("MultiViewRandomForest", "MultiViewXGBoost", "MultiViewLightGBM"): + cls = construct_model(name) + assert issubclass(cls, DRPModel) + config = from_spec(name) + assert isinstance(config, ModelConfig) + assert config.cell_line_featurizer is not None + + +@pytest.mark.parametrize("name", ["ElasticNet", "NaiveDrugMeanPredictor"]) +def test_component_stack_save_load_round_trip(name: str) -> None: + if name == "ElasticNet": + model = construct_model(name)({"alpha": 0.1, "l1_ratio": 0.5, "max_iter": 1000}) + mudataset = synthetic_mudataset_gene_expression_fingerprints() + else: + model = construct_model(name)({}) + mudataset = synthetic_mudataset_identity() + split = lco_split_masks() + model.train(mudataset, split) + preds_before = model.predict(mudataset, split) + with tempfile.TemporaryDirectory() as directory: + checkpoint = f"{directory}/model" + model.save(checkpoint) + loaded = type(model).load(checkpoint) + preds_after = loaded.predict(mudataset, split) + assert np.allclose(preds_before, preds_after, rtol=1e-6, atol=1e-6) diff --git a/tests/models/test_model_lookup.py b/tests/models/test_model_lookup.py new file mode 100644 index 000000000..19ac0d275 --- /dev/null +++ b/tests/models/test_model_lookup.py @@ -0,0 +1,76 @@ +"""Tests for internal model lookup helpers and modern construct_model forms.""" + +from __future__ import annotations + +import warnings + +import pytest + +from drevalpy.models import construct_model +from drevalpy.models._model_lookup import ( + known_model_names, + single_drug_model_names, +) +from drevalpy.models.zoo import list_zoo_names +from drevalpy.types.enums.model_scope import ModelScope + + +def test_construct_model_one_arg_resolves_zoo_preset() -> None: + model_cls = construct_model("ElasticNet") + assert model_cls.get_model_name() == "ElasticNet" + assert construct_model("ElasticNet") is model_cls + + +def test_construct_model_one_arg_unknown_raises() -> None: + with pytest.raises(ValueError, match="Unknown model spec"): + construct_model("NotARealModel") + + +def test_list_zoo_names_scope_filter() -> None: + single = list_zoo_names(include_external=False, scope=ModelScope.SINGLE_DRUG) + multi = list_zoo_names(include_external=False, scope=ModelScope.MULTI_DRUG) + assert "MOLIR" in single + assert "SingleDrugElasticNet" in single + assert "ElasticNet" in multi + assert "ElasticNet" not in single + assert set(single).isdisjoint(multi) + assert set(single) | set(multi) == set(list_zoo_names(include_external=False)) + + +def test_model_lookup_helpers() -> None: + assert "MOLIR" in single_drug_model_names(include_external=False) + assert "ElasticNet" in known_model_names(include_external=False) + + +def test_construct_model_does_not_warn() -> None: + import drevalpy.models as models + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + _ = models.construct_model + _ = construct_model("ElasticNet") + + assert not any(issubclass(w.category, FutureWarning) for w in caught) + + +def test_factory() -> None: + """Test that known model names include the built-in zoo presets.""" + names = set(known_model_names(include_external=False)) + assert "NaivePredictor" in names + assert "NaiveDrugMeanPredictor" in names + assert "NaiveCellLineMeanPredictor" in names + assert "NaiveMeanEffectsPredictor" in names + assert "NaiveTissueDrugMeanPredictor" in names + assert "ElasticNet" in names + assert "RandomForest" in names + assert "SVR" in names + assert "SimpleNeuralNetwork" in names + assert "MultiViewNeuralNetwork" in names + assert "MultiViewRandomForest" in names + assert "SingleDrugRandomForest" in names + assert "SRMF" in names + assert "GradientBoosting" in names + assert "MOLIR" in names + assert "SuperFELTR" in names + assert "DIPK" in names + assert "SparseGO" in names diff --git a/tests/models/test_response_transformation_call_sites.py b/tests/models/test_response_transformation_call_sites.py new file mode 100644 index 000000000..e823f3bbe --- /dev/null +++ b/tests/models/test_response_transformation_call_sites.py @@ -0,0 +1,190 @@ +"""Which ``_extract_response_pairs`` call sites transform, and which stay raw. + +``_extract_response_pairs`` is reached from six places. Four are training-time +supervision and must see transformed targets; two feed prediction and evaluation +and must keep reading the raw response matrix. Getting that split wrong is +silent - the numbers stay plausible, they are just in the wrong space - so the +distinction is pinned here rather than left to review. + +The six, as they are exercised below: + +=================================================== ========= +call site transform +=================================================== ========= +``_ComponentStack.train`` output yes +``train_with_early_stopping`` output yes +``train_with_early_stopping`` early-stopping target yes +``DRPModel.train`` train_response yes +``_ComponentStack.predict`` test_response no +``DRPModel.predict`` empty-training test_response no +=================================================== ========= + +The tests spy on the static method itself instead of asserting on returned +values, because the point is *which* extraction was asked to transform. The spy +forwards the transformer positionally and omits it entirely when it is ``None``, +so the raw-path assertions do not depend on the new parameter existing. + +This file is not a module mirror: the contract spans ``component_stack.py`` and +``drp_model.py`` and only means anything when both agree. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from sklearn.base import TransformerMixin +from sklearn.preprocessing import StandardScaler + +from drevalpy.models import construct_model +from drevalpy.models.component_stack import _ComponentStack, build_component_stack +from drevalpy.models.config import from_spec +from drevalpy.types import SplitMask, SplitMasks +from drevalpy.utils import fit_response_transformation +from tests.models.synthetic_fixtures import ( + lco_split_masks, + synthetic_mudataset_gene_expression_fingerprints, +) + +ELASTIC_NET_HPAMS = {"alpha": 0.1, "l1_ratio": 0.5} + + +@pytest.fixture +def mudataset(): + """A two-cell-line, two-drug dataset with gene expression and fingerprints.""" + return synthetic_mudataset_gene_expression_fingerprints() + + +@pytest.fixture +def masks() -> SplitMasks: + """LCO-style masks: the first cell line trains, the second is held out.""" + return lco_split_masks() + + +@pytest.fixture +def fitted(mudataset, masks: SplitMasks) -> TransformerMixin: + """A ``StandardScaler`` fitted on the training scope, as the pipeline does.""" + return fit_response_transformation(StandardScaler(), mudataset, masks.train) + + +@pytest.fixture +def extractions(monkeypatch: pytest.MonkeyPatch) -> list[TransformerMixin | None]: + """Record the transformer handed to every ``_extract_response_pairs`` call.""" + original = _ComponentStack._extract_response_pairs + recorded: list[TransformerMixin | None] = [] + + def recorder(mudataset, scope, response_transformation=None): + recorded.append(response_transformation) + if response_transformation is None: + return original(mudataset, scope) + return original(mudataset, scope, response_transformation) + + monkeypatch.setattr(_ComponentStack, "_extract_response_pairs", staticmethod(recorder)) + return recorded + + +class TestTrainingExtractionsAreTransformed: + def test_the_stack_transforms_its_training_targets( + self, mudataset, masks: SplitMasks, fitted: TransformerMixin, extractions: list + ) -> None: + stack = build_component_stack(from_spec("ElasticNet")) + + stack.train(mudataset, masks.train, response_transformation=fitted) + + assert extractions == [fitted] + + def test_early_stopping_supervision_shares_the_training_space( + self, mudataset, masks: SplitMasks, fitted: TransformerMixin, extractions: list + ) -> None: + """Both extractions here are training-time supervision, so both transform.""" + stack = build_component_stack(from_spec("ElasticNet")) + + stack.train_with_early_stopping(mudataset, masks.train, masks.test, response_transformation=fitted) + + assert extractions == [fitted, fitted] + + def test_the_model_transforms_its_training_targets( + self, mudataset, masks: SplitMasks, fitted: TransformerMixin, extractions: list + ) -> None: + model = construct_model("ElasticNet")(ELASTIC_NET_HPAMS) + + model.train(mudataset=mudataset, scope=masks.train, response_transformation=fitted) + + assert extractions + assert all(transformer is fitted for transformer in extractions) + + +class TestEvaluationExtractionsStayRaw: + def test_the_stack_reads_raw_responses_when_predicting( + self, mudataset, masks: SplitMasks, fitted: TransformerMixin, extractions: list + ) -> None: + stack = build_component_stack(from_spec("ElasticNet")) + stack.train(mudataset, masks.train, response_transformation=fitted) + extractions.clear() + + stack.predict(mudataset, masks.test) + + assert extractions == [None] + + def test_the_model_reads_raw_responses_when_predicting( + self, mudataset, masks: SplitMasks, fitted: TransformerMixin, extractions: list + ) -> None: + model = construct_model("ElasticNet")(ELASTIC_NET_HPAMS) + model.train(mudataset=mudataset, scope=masks.train, response_transformation=fitted) + extractions.clear() + + model.predict(mudataset=mudataset, scope=masks.test) + + assert extractions == [None] + + def test_the_model_reads_raw_responses_after_empty_training( + self, mudataset, masks: SplitMasks, fitted: TransformerMixin, extractions: list + ) -> None: + """The empty-training shortcut in ``predict`` is the sixth call site.""" + empty = SplitMask(np.zeros(mudataset.response_matrix.shape, dtype=bool)) + model = construct_model("ElasticNet")(ELASTIC_NET_HPAMS) + model.train(mudataset=mudataset, scope=empty, response_transformation=fitted) + extractions.clear() + + model.predict(mudataset=mudataset, scope=masks.test) + + assert extractions == [None] + + +class TestExtractionSemantics: + """What the transformed extraction actually returns, with the spy out of the way.""" + + def test_the_fitted_scaler_is_applied_to_the_responses( + self, mudataset, masks: SplitMasks, fitted: TransformerMixin + ) -> None: + raw = _ComponentStack._extract_response_pairs(mudataset, masks.train) + + transformed = _ComponentStack._extract_response_pairs(mudataset, masks.train, fitted) + + expected = fitted.transform(raw.response.reshape(-1, 1)).ravel() + np.testing.assert_allclose(transformed.response, expected) + + def test_the_pair_identifiers_are_untouched(self, mudataset, masks: SplitMasks, fitted: TransformerMixin) -> None: + raw = _ComponentStack._extract_response_pairs(mudataset, masks.train) + + transformed = _ComponentStack._extract_response_pairs(mudataset, masks.train, fitted) + + np.testing.assert_array_equal(transformed.cell_line_ids, raw.cell_line_ids) + np.testing.assert_array_equal(transformed.drug_ids, raw.drug_ids) + + def test_omitting_the_transform_leaves_the_responses_raw(self, mudataset, masks: SplitMasks) -> None: + pairs = masks.train.pairs + expected = mudataset.response_matrix[pairs[:, 0], pairs[:, 1]] + + batch = _ComponentStack._extract_response_pairs(mudataset, masks.train) + + np.testing.assert_allclose(batch.response, expected) + + def test_unmeasured_pairs_are_dropped_before_transforming(self, synthetic_dataset) -> None: + """The transform runs after the NaN filter, so no NaN reaches the scaler.""" + everything = SplitMask(np.ones(synthetic_dataset.response_matrix.shape, dtype=bool)) + scaler = fit_response_transformation(StandardScaler(), synthetic_dataset, everything) + + batch = _ComponentStack._extract_response_pairs(synthetic_dataset, everything, scaler) + + assert len(batch) == int(np.sum(~np.isnan(synthetic_dataset.response_matrix))) + assert np.isfinite(batch.response).all() diff --git a/tests/models/test_single_drug_models.py b/tests/models/test_single_drug_models.py index 0d684239a..d8106e87b 100644 --- a/tests/models/test_single_drug_models.py +++ b/tests/models/test_single_drug_models.py @@ -1,203 +1,78 @@ -"""Tests for all single drug models.""" +"""Train/predict/round-trip gate for all single-drug models.""" + +from __future__ import annotations -import pathlib import tempfile +from typing import cast import numpy as np -import pandas as pd import pytest -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER -from drevalpy.experiment import ( - consolidate_single_drug_model_predictions, - cross_study_prediction, - generate_data_saving_path, - get_datasets_from_cv_split, - train_and_predict, -) -from drevalpy.models import MODEL_FACTORY -from drevalpy.visualization.utils import evaluate_file - - -@pytest.mark.parametrize( - "model_name", - [ - "SingleDrugRandomForest[gex]", - "SingleDrugRandomForest[proteomics]", - "SingleDrugElasticNet[gex]", - "SingleDrugElasticNet[proteomics]", - "MOLIR", - "SuperFELTR", - ], -) -@pytest.mark.parametrize("test_mode", ["LTO"]) -def test_single_drug_models( - sample_dataset: DrugResponseDataset, - model_name: str, - test_mode: str, - cross_study_dataset: DrugResponseDataset, - data_dir, -) -> None: - """ - Test the SingleDrugRandomForest model, can also test other baseline single drug models. +from drevalpy.models import construct_model +from drevalpy.models.drp_model import DRPModel +from drevalpy.types.data.dataset import Dataset +from tests.synthetic.variants import SUPPORTED_SINGLE_DRUG_MODELS, model_param - :param sample_dataset: from conftest.py - :param model_name: model name - :param test_mode: either LPO or LCO - :param cross_study_dataset: dataset - :param data_dir: path to the data directory - """ - from drevalpy.experiment import seed_everything +#: Extended tier: trains a separate model per drug, ~2.6s for six parametrizations. +pytestmark = pytest.mark.slow - seed_everything(42) - whole_name = model_name - if model_name.startswith("SingleDrugRandomForest"): - model_name = "SingleDrugRandomForest" - elif model_name.startswith("SingleDrugElasticNet"): - model_name = "SingleDrugElasticNet" - - sample_dataset.split_dataset(n_cv_splits=2, mode=test_mode, random_state=42, validation_ratio=0.4) - assert sample_dataset.cv_splits is not None - split = sample_dataset.cv_splits[0] - model = MODEL_FACTORY[model_name]() - - # test what happens if a drug is only in the original dataset, not in the cross-study dataset - exclusive_drugs = list(set(sample_dataset.drug_ids).difference(set(cross_study_dataset.drug_ids))) - all_unique_drugs = list(set(sample_dataset.drug_ids).intersection(set(cross_study_dataset.drug_ids))) - all_unique_drugs.sort() - exclusive_drugs.sort() - all_unique_drugs_arr = np.array(all_unique_drugs) - exclusive_drugs_arr = np.array(exclusive_drugs) - # randomly sample a drug to speed up testing - rng = np.random.default_rng(123) - rng.shuffle(all_unique_drugs_arr) - rng.shuffle(exclusive_drugs_arr) - random_drugs = all_unique_drugs_arr[:1] - random_drugs = np.concatenate([random_drugs, exclusive_drugs_arr[:1]]) - # test what happens if the training and validation dataset is empty for a drug but the test set is not - drug_to_remove = all_unique_drugs_arr[2] - random_drugs = np.concatenate([random_drugs, [drug_to_remove]]) - - hpam_combi = model.get_hyperparameter_set()[0] - result_path = tempfile.TemporaryDirectory() +def _resolve_single_drug_model_name(whole_name: str) -> str: + if whole_name.startswith("SingleDrugRandomForest"): + return "SingleDrugRandomForest" + if whole_name.startswith("SingleDrugElasticNet"): + return "SingleDrugElasticNet" + return whole_name + +def _construct_single_drug_model(whole_name: str, model_name: str) -> type[DRPModel]: + if whole_name.endswith("[proteomics]"): + predictor_token = model_name.replace("SingleDrug", "singleDrug") + return cast(type[DRPModel], construct_model(model_name, f"normalizedProteomics:{predictor_token}")) + return cast(type[DRPModel], construct_model(model_name)) + + +def _configure_single_drug_hpam(model_name: str, hpam_combi: dict) -> None: + """Shrink the model to the smallest configuration that still exercises it.""" if model_name == "SingleDrugRandomForest": - hpam_combi["n_estimators"] = 2 # reduce test time - hpam_combi["max_depth"] = 2 # reduce test time - if whole_name == "SingleDrugRandomForest[gex]": - hpam_combi["cell_line_views"] = "gene_expression" - elif whole_name == "SingleDrugRandomForest[proteomics]": - hpam_combi["cell_line_views"] = "proteomics" - elif whole_name == "SingleDrugElasticNet[gex]": - hpam_combi["cell_line_views"] = "gene_expression" - elif whole_name == "SingleDrugElasticNet[proteomics]": - hpam_combi["cell_line_views"] = "proteomics" + hpam_combi["n_estimators"] = 2 + hpam_combi["max_depth"] = 2 elif model_name in ["MOLIR", "SuperFELTR"]: hpam_combi["epochs"] = 1 - for random_drug in random_drugs: - model = MODEL_FACTORY[model_name]() - predictions_path = generate_data_saving_path( - model_name=model_name, - drug_id=str(random_drug), - result_path=result_path.name, - suffix="predictions", - ) - prediction_file = pathlib.Path(predictions_path, "predictions_split_0.csv") - ( - train_dataset, - validation_dataset, - early_stopping_dataset, - test_dataset, - ) = get_datasets_from_cv_split(split, MODEL_FACTORY[model_name], model_name, random_drug) - train_dataset.add_rows(validation_dataset) - if random_drug == drug_to_remove: - reduce_to_drugs = np.array(list(set(train_dataset.drug_ids) - {random_drug})) - train_dataset.reduce_to(cell_line_ids=None, drug_ids=reduce_to_drugs) - train_dataset.shuffle(random_state=42) - - test_dataset = train_and_predict( - model=model, - hpams=hpam_combi, - path_data=str(data_dir), - train_dataset=train_dataset, - prediction_dataset=test_dataset, - early_stopping_dataset=None, - response_transformation=None, - model_checkpoint_dir="TEMPORARY", - ) - - # Save and load test (should either succeed or raise NotImplementedError) - if len(train_dataset) == 0: - print(f"Training dataset empty for drug {random_drug}, continuing with train_and_predict anyway") - else: - with tempfile.TemporaryDirectory() as model_dir: - try: - - model.save(model_dir) - loaded_model = MODEL_FACTORY[model_name].load(model_dir) - - # Re-run prediction with loaded model - preds_original = model.predict( - drug_ids=test_dataset.drug_ids, - cell_line_ids=test_dataset.cell_line_ids, - drug_input=model.load_drug_features(str(data_dir), "TOYv1"), - cell_line_input=model.load_cell_line_features(str(data_dir), "TOYv1"), - ) - preds_loaded = loaded_model.predict( - drug_ids=test_dataset.drug_ids, - cell_line_ids=test_dataset.cell_line_ids, - drug_input=model.load_drug_features(str(data_dir), "TOYv1"), - cell_line_input=model.load_cell_line_features(str(data_dir), "TOYv1"), - ) - assert isinstance(preds_loaded, np.ndarray) - assert preds_loaded.shape == preds_original.shape - except NotImplementedError: - print(f"{model_name} does not implement save/load") - - cross_study_dataset.remove_nan_responses() - parent_dir = str(pathlib.Path(predictions_path).parent) - cross_study_prediction( - dataset=cross_study_dataset, - model=model, - test_mode=test_mode, - train_dataset=train_dataset, - path_data=str(data_dir), - early_stopping_dataset=None, - response_transformation=None, - path_out=parent_dir, - split_index=0, - single_drug_id=str(random_drug), - ) - test_dataset.to_csv(prediction_file) - consolidate_single_drug_model_predictions( - models=[MODEL_FACTORY[model_name]], - n_cv_splits=1, - results_path=result_path.name, - cross_study_datasets=[cross_study_dataset.dataset_name], - randomization_mode=None, - n_trials_robustness=0, - out_path=result_path.name, - ) - # get cross-study predictions and assert that each drug-cell line combination only occurs once - cross_study_predictions = pd.read_csv( - pathlib.Path(result_path.name, model_name, "cross_study", "cross_study_TOYv2_split_0.csv") - ) - assert len(cross_study_predictions) == len( - cross_study_predictions.drop_duplicates([DRUG_IDENTIFIER, CELL_LINE_IDENTIFIER]) - ) - predictions_file = pathlib.Path(result_path.name, model_name, "predictions", "predictions_split_0.csv") - cross_study_file = pathlib.Path(result_path.name, model_name, "cross_study", "cross_study_TOYv2_split_0.csv") - for file in [predictions_file, cross_study_file]: - ( - overall_eval, - eval_results_per_drug, - eval_results_per_cl, - t_vs_p, - model_name, - ) = evaluate_file(pred_file=file, test_mode=test_mode, model_name=model_name) - assert len(overall_eval) == 1 - print(f"Performance of {model_name}: PCC = {overall_eval['Pearson'][0]}") - assert overall_eval["Pearson"].iloc[0] >= -1.0 + +@pytest.mark.parametrize("model_name", [model_param(name) for name in SUPPORTED_SINGLE_DRUG_MODELS]) +def test_single_drug_models(synthetic_dataset: Dataset, model_name: str) -> None: + """Each single-drug model trains, predicts and reloads on a Leave-Tissue-Out fold. + + :param synthetic_dataset: Session-scoped synthetic raw-omics dataset. + :param model_name: Model name, possibly with a ``[view]`` suffix. + """ + from drevalpy.registry.splitter import get as get_splitter + from drevalpy.utils.seed import seed_everything + + seed_everything(42) + + whole_name = model_name + model_name = _resolve_single_drug_model_name(whole_name) + + split = get_splitter("LTO")(synthetic_dataset, n_splits=2, validation_ratio=0.4)[0] + + model_class = _construct_single_drug_model(whole_name, model_name) + hpam_combi = dict(model_class.get_hyperparameter_set()[0]) + _configure_single_drug_hpam(model_name, hpam_combi) + model = model_class(hpam_combi) + + with tempfile.TemporaryDirectory() as tmpdirname: + model.train(synthetic_dataset, split, model_checkpoint_dir=tmpdirname) + + preds = model.predict(synthetic_dataset, split) + assert isinstance(preds, np.ndarray) + assert preds.shape[0] > 0 + + with tempfile.TemporaryDirectory() as model_dir: + checkpoint = f"{model_dir}/model" + model.save(checkpoint) + loaded_model = model_class.load(checkpoint) + assert isinstance(loaded_model, DRPModel) + assert preds.shape == loaded_model.predict(synthetic_dataset, split).shape diff --git a/tests/models/tuning/test_compatibility_keys.py b/tests/models/tuning/test_compatibility_keys.py new file mode 100644 index 000000000..c7070e6a3 --- /dev/null +++ b/tests/models/tuning/test_compatibility_keys.py @@ -0,0 +1,36 @@ +"""Tests for flat-key compatibility helpers.""" + +from __future__ import annotations + +from drevalpy.models.config import CellLineFeaturizerConfig, DrugFeaturizerConfig, ModelConfig, PredictorConfig +from drevalpy.models.tuning.compatibility_keys import append_featurizer_flat_keys + + +def test_append_featurizer_flat_keys_exports_methylation_alias() -> None: + config = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig.model_validate({"pca[methylation]": {"n_components": 42}}), + drug_featurizer=DrugFeaturizerConfig.model_validate("fingerprints"), + predictor=PredictorConfig(name="randomForest"), + ) + flat: dict = {} + append_featurizer_flat_keys(flat, config.cell_line_featurizer, "cell_line") + assert flat["methylation_n_components"] == 42 + assert flat["methylation_pca_components"] == 42 + + +def test_append_featurizer_flat_keys_skips_architecture_only_kwargs() -> None: + config = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig.model_validate( + [{"name": "identity"}, {"name": "tissue", "options": {"allow_missing": True}}], + ), + drug_featurizer=DrugFeaturizerConfig.model_validate("identity"), + predictor=PredictorConfig(name="naiveMeanEffects"), + ) + flat: dict = {} + append_featurizer_flat_keys(flat, config.cell_line_featurizer, "cell_line") + assert "allow_missing" not in flat + assert config.cell_line_featurizer is not None + children = config.cell_line_featurizer.featurizers + assert children is not None + assert children[1].options is not None + assert children[1].options["allow_missing"] is True diff --git a/tests/models/tuning/test_config.py b/tests/models/tuning/test_config.py new file mode 100644 index 000000000..d4be10f9a --- /dev/null +++ b/tests/models/tuning/test_config.py @@ -0,0 +1,130 @@ +"""Tests for the Optuna search configuration in ``drevalpy.models.tuning.config``.""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from drevalpy.evaluation import AVAILABLE_METRICS +from drevalpy.models.tuning.config import ( + HPOConfig, + build_experiment_hpo_config, + validate_hpo_metric, +) + + +class TestValidateHpoMetric: + """Metric names are checked against the evaluation registry.""" + + @pytest.mark.parametrize("metric", sorted(AVAILABLE_METRICS), ids=lambda metric: metric.replace("^", "")) + def test_accepts_every_available_metric(self, metric: str) -> None: + assert validate_hpo_metric(metric) is None + + def test_rejects_an_unknown_metric(self) -> None: + with pytest.raises(ValueError, match="Invalid HPO metric 'NotAMetric'"): + validate_hpo_metric("NotAMetric") + + def test_error_lists_the_valid_choices(self) -> None: + with pytest.raises(ValueError, match="RMSE"): + validate_hpo_metric("NotAMetric") + + def test_metric_names_are_case_sensitive(self) -> None: + with pytest.raises(ValueError, match="Invalid HPO metric 'rmse'"): + validate_hpo_metric("rmse") + + +class TestHPOConfigDefaults: + """The dataclass defaults are the documented experiment defaults.""" + + def test_all_fields_default(self) -> None: + config = HPOConfig() + + assert (config.n_trials, config.metric, config.mode, config.random_state) == (16, "RMSE", "min", 42) + + def test_fields_are_overridable(self) -> None: + config = HPOConfig(n_trials=3, metric="Pearson", mode="max", random_state=7) + + assert (config.n_trials, config.metric, config.mode, config.random_state) == (3, "Pearson", "max", 7) + + def test_is_a_mutable_dataclass(self) -> None: + assert dataclasses.is_dataclass(HPOConfig) + assert [field.name for field in dataclasses.fields(HPOConfig)] == [ + "n_trials", + "metric", + "mode", + "random_state", + ] + + +class TestHPOConfigFromMetric: + """``from_metric`` infers the optimization direction.""" + + @pytest.mark.parametrize( + ("metric", "expected_mode"), + [ + pytest.param("RMSE", "min", id="rmse-minimized"), + pytest.param("MSE", "min", id="mse-minimized"), + pytest.param("MAE", "min", id="mae-minimized"), + pytest.param("Pearson", "max", id="pearson-maximized"), + pytest.param("Spearman", "max", id="spearman-maximized"), + pytest.param("R^2", "max", id="r2-maximized"), + ], + ) + def test_infers_the_mode(self, metric: str, expected_mode: str) -> None: + assert HPOConfig.from_metric(metric).mode == expected_mode + + def test_records_the_metric(self) -> None: + assert HPOConfig.from_metric("Kendall").metric == "Kendall" + + def test_defaults_n_trials(self) -> None: + assert HPOConfig.from_metric("RMSE").n_trials == 16 + + def test_forwards_n_trials(self) -> None: + assert HPOConfig.from_metric("RMSE", n_trials=5).n_trials == 5 + + def test_accepts_zero_trials_for_default_only_tuning(self) -> None: + assert HPOConfig.from_metric("RMSE", n_trials=0).n_trials == 0 + + def test_rejects_negative_n_trials(self) -> None: + with pytest.raises(ValueError, match=r"n_trials must be >= 0 \(got -1\)"): + HPOConfig.from_metric("RMSE", n_trials=-1) + + def test_rejects_an_unknown_metric(self) -> None: + with pytest.raises(ValueError, match="Invalid HPO metric"): + HPOConfig.from_metric("NotAMetric") + + def test_validates_the_metric_before_n_trials(self) -> None: + """A bad metric is reported even when ``n_trials`` is also invalid.""" + with pytest.raises(ValueError, match="Invalid HPO metric"): + HPOConfig.from_metric("NotAMetric", n_trials=-1) + + def test_forwards_extra_field_overrides(self) -> None: + assert HPOConfig.from_metric("RMSE", random_state=7).random_state == 7 + + def test_rejects_an_unknown_field_override(self) -> None: + with pytest.raises(TypeError): + HPOConfig.from_metric("RMSE", not_a_field=1) + + +class TestBuildExperimentHpoConfig: + """The shared entry point used for CV and final-model tuning.""" + + def test_infers_the_mode_from_the_metric(self) -> None: + assert build_experiment_hpo_config("Pearson").mode == "max" + + def test_carries_metric_trials_and_seed(self) -> None: + config = build_experiment_hpo_config("MAE", n_trials=4, random_state=11) + + assert (config.metric, config.n_trials, config.random_state) == ("MAE", 4, 11) + + def test_defaults_match_the_dataclass_defaults(self) -> None: + assert build_experiment_hpo_config("RMSE") == HPOConfig() + + def test_rejects_an_unknown_metric(self) -> None: + with pytest.raises(ValueError, match="Invalid HPO metric"): + build_experiment_hpo_config("NotAMetric") + + def test_rejects_negative_n_trials(self) -> None: + with pytest.raises(ValueError, match="n_trials must be >= 0"): + build_experiment_hpo_config("RMSE", n_trials=-1) diff --git a/tests/models/tuning/test_config_resolution.py b/tests/models/tuning/test_config_resolution.py new file mode 100644 index 000000000..f72b783b8 --- /dev/null +++ b/tests/models/tuning/test_config_resolution.py @@ -0,0 +1,26 @@ +"""Tests for default and tuned ModelConfig resolution.""" + +from __future__ import annotations + +from drevalpy.models import construct_model +from drevalpy.models.tuning.config_resolution import ( + assert_component_local_hyperparameters, + default_config_for_drp_model, + has_tunable_hyperparameters, + structured_space_for_drp_model, +) +from drevalpy.registry._builtins import register_builtin_components + + +def test_default_config_for_elastic_net_is_component_local() -> None: + register_builtin_components() + config = default_config_for_drp_model(construct_model("ElasticNet")) + assert config is not None + assert config.template.predictor.name == "elasticNet" + assert_component_local_hyperparameters(config) + + +def test_structured_space_for_naive_is_empty() -> None: + register_builtin_components() + assert structured_space_for_drp_model(construct_model("NaivePredictor")) == {} + assert has_tunable_hyperparameters(construct_model("NaivePredictor")) is False diff --git a/tests/models/tuning/test_hpo.py b/tests/models/tuning/test_hpo.py new file mode 100644 index 000000000..b69787351 --- /dev/null +++ b/tests/models/tuning/test_hpo.py @@ -0,0 +1,227 @@ +"""Deterministic mocked tests for Optuna HPO.""" + +from __future__ import annotations + +import logging +from unittest.mock import patch + +import numpy as np +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.tuning.config import HPOConfig +from drevalpy.models.tuning.hpo import HPOTrialsFailedError, hpam_tune +from drevalpy.types import SplitMask +from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + +def _tiny_mudataset_and_scopes(): + mudataset = synthetic_mudataset_gene_expression_fingerprints() + shape = mudataset.response_matrix.shape + train_scope = SplitMask.from_pairs(np.array([[0, 0], [0, 1], [1, 0], [1, 1]]), shape=shape) + val_scope = SplitMask.from_pairs(np.array([[0, 0], [0, 1], [1, 0], [1, 1]]), shape=shape) + return mudataset, train_scope, val_scope + + +def test_hpam_tune_no_space_returns_defaults(monkeypatch) -> None: + model_cls = construct_model("ElasticNet") + monkeypatch.setattr(model_cls, "get_structured_hyperparameter_space", classmethod(lambda cls: {})) + monkeypatch.setattr( + "drevalpy.models.tuning.hpo.has_tunable_hyperparameters", + lambda _cls: False, + ) + + mudataset, train_scope, val_scope = _tiny_mudataset_and_scopes() + best, _ = hpam_tune( + model_class=model_cls, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=None, + metric="RMSE", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=5), + ) + assert best == model_cls.get_default_hyperparameters() + + +def test_hpam_tune_zero_trials_returns_defaults() -> None: + model_cls = construct_model("ElasticNet") + mudataset, train_scope, val_scope = _tiny_mudataset_and_scopes() + best, _ = hpam_tune( + model_class=model_cls, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=None, + metric="RMSE", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=0), + ) + assert best == model_cls.get_default_hyperparameters() + + +@patch( + "drevalpy.models.tuning.hpo._mu_evaluate_trial_all_metrics", + return_value=({"RMSE": 0.1}, np.zeros(4)), +) +@pytest.mark.parametrize("n_trials", [1, 3]) +def test_hpam_tune_evaluates_exactly_n_trials(mock_evaluate, n_trials) -> None: + model_cls = construct_model("ElasticNet") + mudataset, train_scope, val_scope = _tiny_mudataset_and_scopes() + best, _ = hpam_tune( + model_class=model_cls, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=None, + metric="RMSE", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=n_trials), + ) + assert isinstance(best, dict) + assert "alpha" in best + assert mock_evaluate.call_count == n_trials + + +@patch( + "drevalpy.models.tuning.hpo._mu_evaluate_trial_all_metrics", + return_value=({"RMSE": float("nan")}, np.zeros(4)), +) +def test_hpam_tune_all_nan_returns_defaults(mock_evaluate) -> None: + model_cls = construct_model("ElasticNet") + mudataset, train_scope, val_scope = _tiny_mudataset_and_scopes() + best, _ = hpam_tune( + model_class=model_cls, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=None, + metric="RMSE", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=2), + ) + assert best == model_cls.get_default_hyperparameters() + + +@patch("drevalpy.models.tuning.hpo._mu_evaluate_trial_all_metrics", side_effect=RuntimeError("boom")) +def test_hpam_tune_raises_when_every_trial_fails(mock_evaluate) -> None: + """Every trial raised, so the study produced no tuning information. + + Reporting defaults as a result would hide the real cause behind a later traceback. + """ + model_cls = construct_model("ElasticNet") + mudataset, train_scope, val_scope = _tiny_mudataset_and_scopes() + with pytest.raises(HPOTrialsFailedError, match="All 2 hyperparameter trials failed") as excinfo: + hpam_tune( + model_class=model_cls, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=None, + metric="RMSE", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=2), + ) + + cause = excinfo.value.__cause__ + assert isinstance(cause, RuntimeError) + assert str(cause) == "boom" + + +@patch("drevalpy.models.tuning.hpo._mu_evaluate_trial_all_metrics") +def test_hpam_tune_warns_when_some_trials_fail(mock_evaluate, caplog) -> None: + """A partly failing study still tuned, so it warns and returns the survivors' best.""" + calls = {"n": 0} + + def evaluate(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("boom") + return {"RMSE": 0.1}, np.zeros(4) + + mock_evaluate.side_effect = evaluate + + model_cls = construct_model("ElasticNet") + mudataset, train_scope, val_scope = _tiny_mudataset_and_scopes() + with caplog.at_level(logging.WARNING): + best, _ = hpam_tune( + model_class=model_cls, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=None, + metric="RMSE", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=2), + ) + + assert isinstance(best, dict) + assert "alpha" in best + assert "1 of 2 hyperparameter trials failed" in caplog.text + + +def test_hpam_tune_rejects_metric_mismatch() -> None: + model_cls = construct_model("ElasticNet") + mudataset, train_scope, val_scope = _tiny_mudataset_and_scopes() + with pytest.raises(ValueError, match="must match"): + hpam_tune( + model_class=model_cls, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=None, + metric="Pearson", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=1), + ) + + +@patch("drevalpy.models.tuning.hpo._mu_evaluate_trial_all_metrics") +def test_hpam_tune_multiple_trials_picks_best(mock_evaluate) -> None: + scores = iter([0.8, 0.3, 0.5]) + mock_evaluate.side_effect = lambda *args, **kwargs: ({"RMSE": next(scores)}, np.zeros(4)) + + model_cls = construct_model("ElasticNet") + mudataset, train_scope, val_scope = _tiny_mudataset_and_scopes() + best, _ = hpam_tune( + model_class=model_cls, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=None, + metric="RMSE", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=3), + ) + assert isinstance(best, dict) + assert "alpha" in best + + +class TestUnmockedTuning: + """The one case that runs Optuna and the estimator for real, end to end. + + Everything above mocks the trial evaluation, so nothing above would notice a + break between ``hpam_tune`` and a real split, a real fit and a real metric. + Kept in a class of its own so the ``slow`` marker covers only this test and + not the mocked ones, which are milliseconds each. + """ + + pytestmark = pytest.mark.slow + + def test_tuning_elastic_net_over_a_real_lpo_fold_returns_its_hyperparameters(self, synthetic_dataset) -> None: + from drevalpy.registry.splitter import get as get_splitter + + model_cls = construct_model("ElasticNet") + splitter = get_splitter("LPO") + split = splitter(synthetic_dataset, n_splits=2, validation_ratio=0.4)[0] + + early_stopping_scope = None + val_scope = split.val + if model_cls.supports_early_stopping() and len(split.val) > 1: + early_stopping_scope, val_scope = split.early_stopping_mask() + + best, _ = hpam_tune( + model_class=model_cls, + mudataset=synthetic_dataset, + train_scope=split.train, + val_scope=val_scope, + early_stopping_scope=early_stopping_scope, + metric="RMSE", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=2), + ) + + assert isinstance(best, dict) + assert "alpha" in best diff --git a/tests/models/tuning/test_hpo_migration.py b/tests/models/tuning/test_hpo_migration.py new file mode 100644 index 000000000..890fe33fd --- /dev/null +++ b/tests/models/tuning/test_hpo_migration.py @@ -0,0 +1,46 @@ +"""Guard tests for the structured HPO migration.""" + +from __future__ import annotations + +import inspect +from pathlib import Path + + +def test_experiment_tuning_does_not_use_parameter_grid() -> None: + from drevalpy.models.tuning.hpo import hpam_tune + + source = inspect.getsource(hpam_tune) + assert "ParameterGrid" not in source + assert "grid_search" not in source + + +def test_drp_model_does_not_load_yaml_hyperparameters() -> None: + from drevalpy.models import drp_model + + source = inspect.getsource(drp_model.DRPModel.get_hyperparameter_set) + assert "yaml" not in source.lower() + assert "ParameterGrid" not in source + + +def test_predictor_hyperparameters_yaml_removed() -> None: + """v2 ParameterGrid YAML is unused; HPO uses Python get_*_hyperparameters.""" + predictors_root = Path(__file__).resolve().parents[3] / "drevalpy" / "components" / "predictors" + leftover = sorted(predictors_root.rglob("hyperparameters.yaml")) + assert leftover == [], f"remove unused v2 YAML: {leftover}" + + +def test_package_has_no_v2_hyperparameters_yaml() -> None: + package_root = Path(__file__).resolve().parents[3] / "drevalpy" + leftover = sorted(package_root.rglob("hyperparameters.yaml")) + assert leftover == [], f"remove unused v2 YAML under drevalpy/: {leftover}" + + +def test_literature_impl_tree_removed() -> None: + impl_root = Path(__file__).resolve().parents[3] / "drevalpy" / "components" / "predictors" / "literature" / "impl" + assert not impl_root.exists(), f"remove unused literature/impl tree: {impl_root}" + + +def test_literature_has_no_flat_predictor_wrappers() -> None: + literature_root = Path(__file__).resolve().parents[3] / "drevalpy" / "components" / "predictors" / "literature" + flat_wrappers = sorted(path.name for path in literature_root.glob("*_predictor.py") if path.is_file()) + assert flat_wrappers == [], f"remove flat literature wrappers: {flat_wrappers}" diff --git a/tests/models/tuning/test_hpo_runtime.py b/tests/models/tuning/test_hpo_runtime.py new file mode 100644 index 000000000..09f4b0741 --- /dev/null +++ b/tests/models/tuning/test_hpo_runtime.py @@ -0,0 +1,258 @@ +"""Tests for Optuna HPO runtime helpers.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import optuna + +from drevalpy.models import construct_model +from drevalpy.models.tuning.config import HPOConfig +from drevalpy.models.tuning.hpo_runtime import ( + _construct_trial_model, + _init_trial_wandb, + _optuna_objective, + _wandb_trial_run_config, + _wandb_trial_run_name, + run_optuna_study, +) + + +@patch("drevalpy.models.tuning.hpo_runtime.tuned_config_for_drp_model", return_value=None) +def test_construct_trial_model_without_tuned_config(mock_tuned_config) -> None: + model_class = construct_model("ElasticNet") + sampled = {"alpha": 0.5} + trial_model = _construct_trial_model(model_class, sampled) + mock_tuned_config.assert_called_once_with(model_class, sampled) + assert trial_model.hyperparameters["alpha"] == 0.5 + + +@patch("drevalpy.models.tuning.hpo_runtime.construct_drp_model_from_config") +@patch("drevalpy.models.tuning.hpo_runtime.tuned_config_for_drp_model") +def test_construct_trial_model_with_tuned_config(mock_tuned_config, mock_construct) -> None: + model_class = construct_model("ElasticNet") + sampled = {"alpha": 0.5} + tuned_config = MagicMock() + expected_model = MagicMock() + mock_tuned_config.return_value = tuned_config + mock_construct.return_value = expected_model + + trial_model = _construct_trial_model(model_class, sampled) + + mock_construct.assert_called_once_with(model_class, tuned_config) + assert trial_model is expected_model + + +def test_wandb_trial_run_config_merges_base_config() -> None: + trial_model = MagicMock() + trial_model.hyperparameters = {"alpha": 0.1} + cfg = HPOConfig.from_metric("RMSE", n_trials=3) + + config = _wandb_trial_run_config( + trial_model=trial_model, + cfg=cfg, + wandb_base_config={"dataset": "GDSC1"}, + trial_number=1, + ) + + assert config["dataset"] == "GDSC1" + assert config["phase"] == "hyperparameter_tuning" + assert config["hpo_backend"] == "optuna" + assert config["trial_number"] == 1 + assert config["hyperparameters"] == {"alpha": 0.1} + + +def test_wandb_trial_run_name_includes_split_and_trial() -> None: + assert _wandb_trial_run_name(model_name="ElasticNet", split_index=2, trial_number=5) == ( + "ElasticNet_split_2_trial_5" + ) + assert _wandb_trial_run_name(model_name="ElasticNet", split_index=None, trial_number=3) == ("ElasticNet_trial_3") + + +@patch("drevalpy.models.tuning.hpo_runtime._wandb_trial_run_name", return_value="run-name") +@patch("drevalpy.models.tuning.hpo_runtime._wandb_trial_run_config", return_value={"trial_number": 7}) +def test_init_trial_wandb_delegates_to_model(mock_run_config, mock_run_name) -> None: + trial_model = MagicMock() + cfg = HPOConfig.from_metric("RMSE") + + _init_trial_wandb( + trial_model, + wandb_project="dreval-hpo", + wandb_base_config={"fold": 1}, + cfg=cfg, + model_name="ElasticNet", + split_index=0, + trial_number=7, + ) + + mock_run_config.assert_called_once() + mock_run_name.assert_called_once_with(model_name="ElasticNet", split_index=0, trial_number=7) + trial_model.init_wandb.assert_called_once_with( + project="dreval-hpo", + config={"trial_number": 7}, + name="run-name", + tags=["ElasticNet", "hpam_tuning", "optuna"], + finish_previous=True, + ) + + +@patch("drevalpy.models.tuning.hpo_runtime._mu_evaluate_trial_model", return_value=0.33) +@patch("drevalpy.models.tuning.hpo_runtime._construct_trial_model") +@patch("drevalpy.models.tuning.hpo_runtime.sample_from_optuna_trial") +def test_optuna_objective_returns_score( + mock_sample, + mock_construct, + mock_evaluate, +) -> None: + trial_model = MagicMock() + mock_construct.return_value = trial_model + mock_sample.return_value = {"alpha": 0.1} + + study = optuna.create_study() + trial = study.ask() + + score = _optuna_objective( + trial, + model_class=construct_model("ElasticNet"), + mudataset=MagicMock(), + train_scope=MagicMock(), + val_scope=MagicMock(), + early_stopping_scope=None, + response_transformation=None, + metric="RMSE", + structured_space={"alpha": {"type": "float", "low": 0.01, "high": 1.0, "default": 0.5}}, + model_checkpoint_dir="checkpoints", + cfg=HPOConfig.from_metric("RMSE"), + wandb_project=None, + wandb_base_config=None, + split_index=None, + model_name="ElasticNet", + ) + + assert score == 0.33 + mock_construct.assert_called_once() + mock_evaluate.assert_called_once() + + +@patch("drevalpy.models.tuning.hpo_runtime._mu_evaluate_trial_model", side_effect=RuntimeError("boom")) +@patch("drevalpy.models.tuning.hpo_runtime._construct_trial_model") +@patch("drevalpy.models.tuning.hpo_runtime.sample_from_optuna_trial") +def test_optuna_objective_failure_returns_nan(mock_sample, mock_construct, mock_evaluate) -> None: + mock_construct.return_value = MagicMock() + mock_sample.return_value = {"alpha": 0.1} + + study = optuna.create_study() + trial = study.ask() + + score = _optuna_objective( + trial, + model_class=construct_model("ElasticNet"), + mudataset=MagicMock(), + train_scope=MagicMock(), + val_scope=MagicMock(), + early_stopping_scope=None, + response_transformation=None, + metric="RMSE", + structured_space={"alpha": {"type": "float", "low": 0.01, "high": 1.0, "default": 0.5}}, + model_checkpoint_dir="checkpoints", + cfg=HPOConfig.from_metric("RMSE"), + wandb_project=None, + wandb_base_config=None, + split_index=None, + model_name="ElasticNet", + ) + + assert score != score # NaN + + +@patch("drevalpy.models.tuning.hpo_runtime._mu_evaluate_trial_model", return_value=0.5) +@patch("drevalpy.models.tuning.hpo_runtime._init_trial_wandb") +@patch("drevalpy.models.tuning.hpo_runtime._construct_trial_model") +@patch("drevalpy.models.tuning.hpo_runtime.sample_from_optuna_trial") +def test_optuna_objective_wandb_finishes_in_finally( + mock_sample, + mock_construct, + mock_init_wandb, + mock_evaluate, +) -> None: + trial_model = MagicMock() + trial_model.is_wandb_enabled.return_value = True + mock_construct.return_value = trial_model + mock_sample.return_value = {"alpha": 0.1} + + study = optuna.create_study() + trial = study.ask() + + score = _optuna_objective( + trial, + model_class=construct_model("ElasticNet"), + mudataset=MagicMock(), + train_scope=MagicMock(), + val_scope=MagicMock(), + early_stopping_scope=None, + response_transformation=None, + metric="RMSE", + structured_space={"alpha": {"type": "float", "low": 0.01, "high": 1.0, "default": 0.5}}, + model_checkpoint_dir="checkpoints", + cfg=HPOConfig.from_metric("RMSE"), + wandb_project="dreval-hpo", + wandb_base_config=None, + split_index=1, + model_name="ElasticNet", + ) + + assert score == 0.5 + mock_init_wandb.assert_called_once() + trial_model.finish_wandb.assert_called_once() + + +@patch("drevalpy.models.tuning.hpo_runtime._mu_evaluate_trial_model", side_effect=RuntimeError("boom")) +@patch("drevalpy.models.tuning.hpo_runtime._init_trial_wandb") +@patch("drevalpy.models.tuning.hpo_runtime._construct_trial_model") +@patch("drevalpy.models.tuning.hpo_runtime.sample_from_optuna_trial") +def test_optuna_objective_wandb_failure_still_finishes( + mock_sample, + mock_construct, + mock_init_wandb, + mock_evaluate, +) -> None: + trial_model = MagicMock() + trial_model.is_wandb_enabled.return_value = True + mock_construct.return_value = trial_model + mock_sample.return_value = {"alpha": 0.1} + + study = optuna.create_study() + trial = study.ask() + + score = _optuna_objective( + trial, + model_class=construct_model("ElasticNet"), + mudataset=MagicMock(), + train_scope=MagicMock(), + val_scope=MagicMock(), + early_stopping_scope=None, + response_transformation=None, + metric="RMSE", + structured_space={"alpha": {"type": "float", "low": 0.01, "high": 1.0, "default": 0.5}}, + model_checkpoint_dir="checkpoints", + cfg=HPOConfig.from_metric("RMSE"), + wandb_project="dreval-hpo", + wandb_base_config=None, + split_index=None, + model_name="ElasticNet", + ) + + assert score != score # NaN + trial_model.finish_wandb.assert_called_once() + + +def test_run_optuna_study_uses_tpe_sampler() -> None: + cfg = HPOConfig.from_metric("RMSE", n_trials=3) + + def dummy_objective(trial: optuna.Trial) -> float: + trial.suggest_float("x", 0.0, 1.0) + return 0.5 + + study = run_optuna_study(objective=dummy_objective, cfg=cfg) + assert len(study.trials) == 3 + assert isinstance(study.sampler, optuna.samplers.TPESampler) diff --git a/tests/models/tuning/test_hyperparameter_export.py b/tests/models/tuning/test_hyperparameter_export.py new file mode 100644 index 000000000..8cc6489cd --- /dev/null +++ b/tests/models/tuning/test_hyperparameter_export.py @@ -0,0 +1,26 @@ +"""Tests for public hyperparameter mapping export.""" + +from __future__ import annotations + +import pytest + +from drevalpy.models.config import from_spec +from drevalpy.models.config.model import ModelConfig +from drevalpy.models.tuning.hyperparameter_export import ( + export_public_mapping, +) +from drevalpy.registry._builtins import register_builtin_components + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_export_uses_qualified_keys_for_collisions() -> None: + config = from_spec("pca[expression]+pca[proteomics]:fingerprints:randomForest") + assert isinstance(config, ModelConfig) + exported = export_public_mapping(config) + assert "n_components" not in exported + assert "cell_line_featurizer.pca[expression].n_components" in exported + assert "cell_line_featurizer.pca[proteomics].n_components" in exported diff --git a/tests/models/tuning/test_hyperparameter_keys.py b/tests/models/tuning/test_hyperparameter_keys.py new file mode 100644 index 000000000..21a303a3e --- /dev/null +++ b/tests/models/tuning/test_hyperparameter_keys.py @@ -0,0 +1,73 @@ +"""Tests for hyperparameter ownership indexing and resolution.""" + +from __future__ import annotations + +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import from_spec +from drevalpy.models.config.model import ModelConfig +from drevalpy.models.tuning.hyperparameter_keys import ( + build_ownership_index, + resolve_to_qualified_mapping, +) +from drevalpy.registry._builtins import register_builtin_components + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_elastic_net_alpha_has_single_owner() -> None: + model_cls = construct_model("ElasticNet") + from drevalpy.models.tuning.config_resolution import default_config_for_drp_model + + config = default_config_for_drp_model(model_cls) + assert config is not None + index = build_ownership_index(config.template) + assert len(index.short_to_targets["alpha"]) == 1 + assert index.short_to_targets["alpha"][0].qualified_key == "predictor.elasticNet.alpha" + + +def test_two_pca_views_make_n_components_ambiguous() -> None: + config = from_spec("pca[expression]+pca[proteomics]:fingerprints:randomForest") + assert isinstance(config, ModelConfig) + index = build_ownership_index(config) + owners = index.short_to_targets["n_components"] + assert len(owners) == 2 + qualified = resolve_to_qualified_mapping( + config, + { + "cell_line_featurizer.pca[expression].n_components": 32, + "cell_line_featurizer.pca[proteomics].n_components": 16, + }, + index, + reserved_keys=frozenset(), + ) + assert qualified["cell_line_featurizer.pca[expression].n_components"] == 32 + assert qualified["cell_line_featurizer.pca[proteomics].n_components"] == 16 + + +def test_ambiguous_short_key_lists_qualified_alternatives() -> None: + config = from_spec("pca[expression]+pca[proteomics]:fingerprints:randomForest") + assert isinstance(config, ModelConfig) + index = build_ownership_index(config) + with pytest.raises(ValueError, match="Ambiguous hyperparameter 'n_components'"): + resolve_to_qualified_mapping(config, {"n_components": 64}, index, reserved_keys=frozenset()) + + +def test_duplicate_short_and_qualified_assignments_rejected() -> None: + model_cls = construct_model("ElasticNet") + from drevalpy.models.tuning.config_resolution import default_config_for_drp_model + + config = default_config_for_drp_model(model_cls) + assert config is not None + index = build_ownership_index(config.template) + with pytest.raises(ValueError, match="Duplicate hyperparameter assignment"): + resolve_to_qualified_mapping( + config.template, + {"alpha": 0.2, "predictor.elasticNet.alpha": 0.3}, + index, + reserved_keys=frozenset(), + ) diff --git a/tests/models/tuning/test_public_flat.py b/tests/models/tuning/test_public_flat.py new file mode 100644 index 000000000..1f3f36bf6 --- /dev/null +++ b/tests/models/tuning/test_public_flat.py @@ -0,0 +1,73 @@ +"""Tests for public flat hyperparameter translation and application.""" + +from __future__ import annotations + +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import from_spec +from drevalpy.models.config.model import ModelConfig +from drevalpy.models.tuning.public_flat import ( + apply_public_hyperparameters_to_config, + config_from_public_hyperparameters, + public_hyperparameters_from_config, +) +from drevalpy.models.zoo import get_zoo_config +from drevalpy.registry._builtins import register_builtin_components + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_public_round_trip_for_factory_model() -> None: + register_builtin_components() + model_cls = construct_model("ElasticNet") + config = model_cls.model_config() + assert config is not None + public = public_hyperparameters_from_config(config) + rebuilt = config_from_public_hyperparameters(model_cls, public) + assert rebuilt is not None + assert rebuilt.template.predictor.name == "elasticNet" + + +def test_construct_model_spec_resolves_without_hyperparameters() -> None: + register_builtin_components() + model_cls = construct_model("PcaIdentityRF", "pca[expression]:identity:randomForest") + config = model_cls.model_config() + assert isinstance(config, ModelConfig) + assert config.predictor.name == "randomForest" + + +def test_methylation_pca_components_alias() -> None: + config = get_zoo_config("MultiViewRandomForest") + updated = apply_public_hyperparameters_to_config(config, {"methylation_pca_components": 7}) + assert updated.featurizer_values("cell_line", "pca[methylation]")["n_components"] == 7 + + +def test_unknown_flat_keys_rejected() -> None: + config = get_zoo_config("ElasticNet") + with pytest.raises(ValueError, match="Unknown hyperparameter"): + apply_public_hyperparameters_to_config(config, {"alpha": 0.1, "totally_unknown_key": 42}) + + +def test_ambiguous_n_components_rejected_for_multi_pca_stack() -> None: + config = from_spec("pca[expression]+pca[proteomics]:fingerprints:randomForest") + assert isinstance(config, ModelConfig) + with pytest.raises(ValueError, match="Ambiguous hyperparameter 'n_components'"): + apply_public_hyperparameters_to_config(config, {"n_components": 64}) + + +def test_qualified_n_components_update_single_leaf() -> None: + config = from_spec("pca[expression]+pca[proteomics]:fingerprints:randomForest") + assert isinstance(config, ModelConfig) + updated = apply_public_hyperparameters_to_config( + config, + { + "cell_line_featurizer.pca[expression].n_components": 32, + "cell_line_featurizer.pca[proteomics].n_components": 16, + }, + ) + assert updated.featurizer_values("cell_line", "pca[expression]")["n_components"] == 32 + assert updated.featurizer_values("cell_line", "pca[proteomics]")["n_components"] == 16 diff --git a/tests/models/tuning/test_public_hyperparameter_compat.py b/tests/models/tuning/test_public_hyperparameter_compat.py new file mode 100644 index 000000000..b5066d0ba --- /dev/null +++ b/tests/models/tuning/test_public_hyperparameter_compat.py @@ -0,0 +1,138 @@ +"""Public hyperparameter compatibility tests against development-style usage.""" + +from __future__ import annotations + +import pytest + +import drevalpy.registry._builtins as _builtins_mod +from drevalpy.models import construct_model +from drevalpy.models.tuning.config_resolution import ( + assert_component_local_hyperparameters, + default_config_for_drp_model, + tuned_config_for_drp_model, +) +from drevalpy.models.tuning.public_flat import ( + config_from_public_hyperparameters, + public_hyperparameters_from_config, +) +from drevalpy.models.tuning.search_space import ( + apply_merged_to_model_config, + defaults_from_merged_space, + merge_model_config_spaces, +) + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + _builtins_mod.register_builtin_components() + + +@pytest.mark.parametrize( + "model_name", + ["ElasticNet", "RandomForest", "NaiveMeanEffectsPredictor"], +) +def test_model_factory_defaults_build_without_error(model_name: str) -> None: + model_cls = construct_model(model_name) + defaults = model_cls.get_default_hyperparameters() + model = model_cls(defaults) + assert isinstance(defaults, dict) + assert model.hyperparameters == defaults + + +def test_construct_model_defaults_have_no_namespaced_keys() -> None: + model_cls = construct_model("PcaOneHotRF", "pca[expression]:identity:randomForest") + defaults = model_cls.get_default_hyperparameters() + assert not any("." in key for key in defaults) + assert "cell_line_featurizer.pca[expression].n_components" not in defaults + assert "cell_line_featurizer.pca.0.n_components" not in defaults + model_cls(defaults) + + +def test_default_config_has_component_local_hyperparameters_only() -> None: + model_cls = construct_model("PcaOneHotRF", "pca[expression]:identity:randomForest") + config = default_config_for_drp_model(model_cls) + assert config is not None + assert config.featurizer_values("cell_line", "pca[expression]")["n_components"] == 128 + assert_component_local_hyperparameters(config) + + +def test_public_round_trip_for_constructed_model() -> None: + model_cls = construct_model("PcaOneHotRF", "pca[expression]:identity:randomForest") + config = default_config_for_drp_model(model_cls) + assert config is not None + public = public_hyperparameters_from_config(config) + rebuilt = config_from_public_hyperparameters(model_cls, public) + assert rebuilt is not None + assert rebuilt.featurizer_values("cell_line", "pca[expression]")["n_components"] == 128 + assert_component_local_hyperparameters(rebuilt) + + +def test_tuned_config_strips_structured_keys() -> None: + model_cls = construct_model("PcaOneHotRF", "pca[expression]:identity:randomForest") + base = default_config_for_drp_model(model_cls) + assert base is not None + merged = defaults_from_merged_space(merge_model_config_spaces(base.template)) + tuned = tuned_config_for_drp_model(model_cls, merged) + assert tuned is not None + assert_component_local_hyperparameters(tuned) + public = public_hyperparameters_from_config(tuned) + assert "cell_line_featurizer.pca[expression].n_components" not in public + assert "cell_line_featurizer.pca.0.n_components" not in public + + +def test_apply_merged_never_leaks_namespaced_keys_into_components() -> None: + from drevalpy.models.config import from_spec + from drevalpy.models.config.model import ModelConfig + + config = from_spec("pca[expression]:identity:randomForest") + assert isinstance(config, ModelConfig) + merged = defaults_from_merged_space(merge_model_config_spaces(config)) + updated = apply_merged_to_model_config(config, merged) + assert_component_local_hyperparameters(updated) + + +def test_pca_methylation_pca_components_alias_round_trip() -> None: + rebuilt = config_from_public_hyperparameters( + construct_model("MultiViewRandomForest"), + {"methylation_pca_components": 9}, + ) + assert rebuilt is not None + assert rebuilt.featurizer_values("cell_line", "pca[methylation]")["n_components"] == 9 + + +def test_cell_line_views_override_on_configure_path_rejected() -> None: + with pytest.raises(ValueError, match=r"Unknown hyperparameter"): + config_from_public_hyperparameters( + construct_model("MultiViewRandomForest"), + {"cell_line_views": ["gene_expression"]}, + ) + + +def test_pca_methylation_flat_key_round_trip() -> None: + from drevalpy.models.config import CellLineFeaturizerConfig, DrugFeaturizerConfig, ModelConfig, PredictorConfig + from drevalpy.models.tuning.search_space import resolve_model_config + + template = ModelConfig( + cell_line_featurizer=CellLineFeaturizerConfig.model_validate( + [ + "scaledGeneExpression", + {"pca[methylation]": {"n_components": 100}}, + ], + ), + drug_featurizer=DrugFeaturizerConfig.model_validate("fingerprints"), + predictor=PredictorConfig(name="randomForest"), + ) + config = resolve_model_config( + template, + {"cell_line_featurizer.pca[methylation].n_components": 100}, + ) + public = public_hyperparameters_from_config(config) + assert public["n_components"] == 100 + rebuilt = config_from_public_hyperparameters(construct_model("MultiViewRandomForest"), public) + assert rebuilt is not None + assert rebuilt.featurizer_values("cell_line", "pca[methylation]")["n_components"] == 100 + + +def test_cli_resolves_models_through_construct_model() -> None: + model_class = construct_model("ElasticNet") + assert model_class.get_model_name() == "ElasticNet" diff --git a/tests/models/tuning/test_search_space.py b/tests/models/tuning/test_search_space.py new file mode 100644 index 000000000..748a01eb1 --- /dev/null +++ b/tests/models/tuning/test_search_space.py @@ -0,0 +1,152 @@ +"""Tests for internal hyperparameter search-space helpers.""" + +import optuna +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import from_spec +from drevalpy.models.config.model import ModelConfig +from drevalpy.models.tuning.search_space import ( + apply_merged_to_model_config, + defaults_from_merged_space, + extract_defaults, + merge_model_config_spaces, + merge_search_spaces, + sample_from_optuna_trial, + split_hyperparameters, +) +from drevalpy.registry._builtins import register_builtin_components + + +def test_merge_all_three_spaces() -> None: + merged = merge_search_spaces( + cell_line_featurizer_space={"n_components": {"type": "int", "low": 4, "high": 64, "default": 8}}, + drug_featurizer_space={"n_bits": {"type": "int", "low": 64, "high": 256, "default": 128}}, + predictor_space={"alpha": {"type": "float", "low": 0.1, "high": 1.0, "default": 0.5}}, + ) + assert set(merged) == { + "cell_line_featurizer.n_components", + "drug_featurizer.n_bits", + "predictor.alpha", + } + + +def test_split_hyperparameters_inverts_merge() -> None: + merged = { + "cell_line_featurizer.n_components": 8, + "drug_featurizer.n_bits": 128, + "predictor.alpha": 0.5, + } + cell_line_hp, drug_hp, predictor_hp = split_hyperparameters(merged) + assert cell_line_hp == {"n_components": 8} + assert drug_hp == {"n_bits": 128} + assert predictor_hp == {"alpha": 0.5} + + +def test_split_predictor_only_fallback() -> None: + merged = {"alpha": 1.0, "l1_ratio": 0.5} + _, _, predictor_hp = split_hyperparameters(merged) + assert predictor_hp == {"alpha": 1.0, "l1_ratio": 0.5} + + +def test_merge_concat_child_spaces_use_qualified_selectors() -> None: + register_builtin_components() + spec = "pca[expression]+landmarkGenes:fingerprints:randomForest" + config = from_spec(spec) + assert isinstance(config, ModelConfig) + merged = merge_model_config_spaces(config) + pca_keys = [key for key in merged if key.startswith("cell_line_featurizer.pca[expression].")] + assert pca_keys + assert any(key.startswith("cell_line_featurizer.landmarkGenes.") for key in merged) + assert any("predictor.randomForest." in key for key in merged) + # The class-level space is what HPO tunes; it must not drift from the config merge. + assert construct_model("ComboRF", spec).get_structured_hyperparameter_space() == merged + + +def test_merge_same_name_different_views_get_distinct_keys() -> None: + register_builtin_components() + config = from_spec("pca[expression]+pca[proteomics]:fingerprints:randomForest") + assert isinstance(config, ModelConfig) + merged = merge_model_config_spaces(config) + assert any(key.startswith("cell_line_featurizer.pca[expression].") for key in merged) + assert any(key.startswith("cell_line_featurizer.pca[proteomics].") for key in merged) + + +def test_apply_rejects_indexed_featurizer_keys() -> None: + register_builtin_components() + config = from_spec("pca[expression]:identity:randomForest") + assert isinstance(config, ModelConfig) + with pytest.raises( + ValueError, + match="Indexed featurizer hyperparameter keys are no longer supported", + ): + apply_merged_to_model_config( + config, + {"cell_line_featurizer.pca.0.n_components": 8}, + ) + + +def test_apply_merged_to_model_config_strips_featurizer_prefix() -> None: + register_builtin_components() + config = from_spec("pca[expression]:identity:randomForest") + assert isinstance(config, ModelConfig) + merged = defaults_from_merged_space(merge_model_config_spaces(config)) + updated = apply_merged_to_model_config(config, merged) + assert updated.featurizer_values("cell_line", "pca[expression]")["n_components"] == 128 + assert all("." in key for key in updated.values) + + +def test_extract_defaults() -> None: + defaults = extract_defaults( + cell_line_featurizer_space={"n_components": {"type": "int", "default": 16}}, + predictor_space={"alpha": {"type": "float", "default": 0.1}}, + ) + assert defaults == { + "cell_line_featurizer.n_components": 16, + "predictor.alpha": 0.1, + } + + +class TestSampleFromOptunaTrial: + """One spec kind per case; the four suggest_* branches shared one skeleton.""" + + @pytest.mark.parametrize( + ("spec", "is_in_range"), + [ + pytest.param({"type": "int", "low": 10, "high": 20, "default": 15}, lambda v: 10 <= v <= 20, id="int"), + pytest.param( + {"type": "float", "low": 0.1, "high": 0.9, "default": 0.2}, + lambda v: 0.1 <= v <= 0.9, + id="float", + ), + pytest.param( + {"type": "float", "low": 0.001, "high": 10.0, "log": True, "default": 1.0}, + lambda v: 0.001 <= v <= 10.0, + id="log-float", + ), + pytest.param( + {"type": "categorical", "choices": ["linear", "rbf", "poly"], "default": "rbf"}, + lambda v: v in {"linear", "rbf", "poly"}, + id="categorical", + ), + pytest.param(42, lambda v: v == 42, id="non-mapping-is-passed-through"), + ], + ) + def test_each_spec_kind_samples_inside_its_own_domain(self, spec, is_in_range) -> None: + trial = optuna.create_study().ask() + + sampled = sample_from_optuna_trial(trial, {"param": spec}) + + assert is_in_range(sampled["param"]) + + def test_the_qualified_key_survives_sampling(self) -> None: + """Dotted selectors are what ``split_hyperparameters`` later routes on.""" + space = { + "predictor.randomForest.n_estimators": {"type": "int", "low": 10, "high": 20, "default": 15}, + "predictor.randomForest.max_samples": {"type": "float", "low": 0.1, "high": 0.9, "default": 0.2}, + } + trial = optuna.create_study().ask() + + sampled = sample_from_optuna_trial(trial, space) + + assert set(sampled) == set(space) diff --git a/tests/models/tuning/test_wandb_hpo.py b/tests/models/tuning/test_wandb_hpo.py new file mode 100644 index 000000000..dff592862 --- /dev/null +++ b/tests/models/tuning/test_wandb_hpo.py @@ -0,0 +1,40 @@ +"""Wandb payload tests for Optuna HPO.""" + +from __future__ import annotations + +from unittest.mock import patch + +import numpy as np + +from drevalpy.models import construct_model +from drevalpy.models.tuning.config import HPOConfig +from drevalpy.types import SplitMask + + +@patch("drevalpy.models.tuning.hpo_runtime._mu_evaluate_trial_model", return_value=0.2) +@patch("drevalpy.models.tuning.hpo._log_trial_to_wandb") +def test_hpam_tune_logs_wandb_config(mock_wandb_log, mock_evaluate) -> None: + from drevalpy.models.tuning.hpo import hpam_tune + from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + model_cls = construct_model("ElasticNet") + mudataset = synthetic_mudataset_gene_expression_fingerprints() + shape = mudataset.response_matrix.shape + train_scope = SplitMask.from_pairs(np.array([[0, 0], [0, 1], [1, 0], [1, 1]]), shape=shape) + val_scope = SplitMask.from_pairs(np.array([[0, 0], [0, 1], [1, 0], [1, 1]]), shape=shape) + + hpam_tune( + model_class=model_cls, + mudataset=mudataset, + train_scope=train_scope, + val_scope=val_scope, + early_stopping_scope=None, + metric="RMSE", + hpo_config=HPOConfig.from_metric("RMSE", n_trials=2), + wandb_project="test-project", + wandb_base_config={"dataset": "synthetic"}, + ) + + assert mock_wandb_log.call_count > 0 + call_kwargs = mock_wandb_log.call_args_list[0].kwargs + assert call_kwargs["wandb_project"] == "test-project" diff --git a/tests/models/zoo/test_external_load.py b/tests/models/zoo/test_external_load.py new file mode 100644 index 000000000..814405b3b --- /dev/null +++ b/tests/models/zoo/test_external_load.py @@ -0,0 +1,35 @@ +"""Tests for external zoo YAML loading.""" + +from __future__ import annotations + +import pytest + +from drevalpy.models.zoo._external_load import ( + _collect_zoo_entries_from_yaml, + _load_zoo_yaml_mapping, +) + + +def test_load_zoo_yaml_mapping_requires_file(tmp_path) -> None: + missing = tmp_path / "missing.yaml" + with pytest.raises(FileNotFoundError, match="not found"): + _load_zoo_yaml_mapping(missing) + + +def test_collect_zoo_entries_rejects_non_mapping_entry(tmp_path) -> None: + data = {"bad": "not-a-dict"} + with pytest.raises(ValueError, match="must be a mapping"): + _collect_zoo_entries_from_yaml(data, source=tmp_path / "z.yaml", builtin_names=frozenset()) + + +def test_collect_zoo_entries_single_document_format(tmp_path) -> None: + payload = { + "predictor": "elasticNet", + "cell_line_featurizer": "scaledGeneExpression", + "drug_featurizer": "fingerprints", + "name": "customEntry", + } + parsed = _collect_zoo_entries_from_yaml(payload, source=tmp_path / "z.yaml", builtin_names=frozenset()) + assert len(parsed) == 1 + assert parsed[0][0] == "customEntry" + assert parsed[0][1].predictor.name == "elasticNet" diff --git a/tests/models/zoo/test_init.py b/tests/models/zoo/test_init.py new file mode 100644 index 000000000..571444f09 --- /dev/null +++ b/tests/models/zoo/test_init.py @@ -0,0 +1,258 @@ +"""Tests for the built-in model zoo under :mod:`drevalpy.models.zoo`.""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.models import construct_model +from drevalpy.models.config import ModelConfig, ModelScope, from_spec, validate +from drevalpy.models.factory import model_config_for_name +from drevalpy.models.zoo import get_zoo_config, list_zoo_names, zoo_model_config +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry.predictor import get as get_predictor + +LITERATURE_ZOO_NAMES = [ + "DrugGNN", + "PharmaFormer", + "DIPK", + "MOLIR", + "SuperFELTR", + "Precily", + "SRMF", + "SimpleNeuralNetwork", + "MultiViewNeuralNetwork", + "SparseGO", +] + +BLOCK_ZOO_NAMES = {"DrugGNN", "PharmaFormer", "DIPK", "MOLIR", "SuperFELTR", "Precily", "SRMF", "SparseGO"} + + +@pytest.fixture(autouse=True) +def _register_components() -> None: + register_builtin_components() + + +def test_builtin_zoo_lists_passing_models() -> None: + names = list_zoo_names(include_external=False) + assert "ElasticNet" in names + assert "NaivePredictor" in names + assert "DIPK" in names + assert "PharmaFormer" in names + assert "SingleDrugElasticNet" in names + + +def test_list_zoo_names_filters_by_scope() -> None: + from drevalpy.types.enums.model_scope import ModelScope + + single = list_zoo_names(include_external=False, scope=ModelScope.SINGLE_DRUG) + multi = list_zoo_names(include_external=False, scope="multi_drug") + assert "SingleDrugElasticNet" in single + assert "ElasticNet" in multi + assert set(single).isdisjoint(multi) + + +def test_zoo_elastic_net_defaults() -> None: + zoo_config = get_zoo_config("ElasticNet") + assert zoo_config.cell_line_featurizer is not None + assert zoo_config.cell_line_featurizer.name == "scaledGeneExpression" + assert zoo_config.drug_featurizer is not None + assert zoo_config.drug_featurizer.name == "fingerprints" + assert zoo_config.predictor.name == "elasticNet" + + +def test_zoo_naive_presets_use_information_accurate_featurizers() -> None: + naive = get_zoo_config("NaivePredictor") + assert naive.predictor.name == "naiveMean" + assert naive.cell_line_featurizer is None + assert naive.drug_featurizer is None + + cell_mean = get_zoo_config("NaiveCellLineMeanPredictor") + assert cell_mean.cell_line_featurizer is not None + assert cell_mean.cell_line_featurizer.name == "identity" + assert cell_mean.drug_featurizer is not None + assert cell_mean.drug_featurizer.name == "constant" + + drug_mean = get_zoo_config("NaiveDrugMeanPredictor") + assert drug_mean.cell_line_featurizer is not None + assert drug_mean.cell_line_featurizer.name == "constant" + assert drug_mean.drug_featurizer is not None + assert drug_mean.drug_featurizer.name == "identity" + + tissue_mean = get_zoo_config("NaiveTissueMeanPredictor") + assert tissue_mean.cell_line_featurizer is not None + assert tissue_mean.cell_line_featurizer.name == "tissue" + assert tissue_mean.drug_featurizer is not None + assert tissue_mean.drug_featurizer.name == "constant" + + tissue_drug = get_zoo_config("NaiveTissueDrugMeanPredictor") + assert tissue_drug.cell_line_featurizer is not None + assert tissue_drug.cell_line_featurizer.name == "tissue" + assert tissue_drug.drug_featurizer is not None + assert tissue_drug.drug_featurizer.name == "identity" + + mean_effects = get_zoo_config("NaiveMeanEffectsPredictor") + assert mean_effects.cell_line_featurizer is not None + assert mean_effects.cell_line_featurizer.name == "concatFeaturizers" + children = mean_effects.cell_line_featurizer.featurizers + assert children is not None + assert [child.name for child in children] == ["identity", "tissue"] + assert children[1].options is not None + assert children[1].options["allow_missing"] is True + assert mean_effects.drug_featurizer is not None + assert mean_effects.drug_featurizer.name == "identity" + + +def test_zoo_model_config_merges_hyperparameters() -> None: + from drevalpy.models.config import ResolvedModelConfig + + config = zoo_model_config("ElasticNet", {"alpha": 0.25}) + assert isinstance(config, ResolvedModelConfig) + assert config.predictor_values()["alpha"] == 0.25 + + +def test_zoo_model_config_rejects_view_keys() -> None: + with pytest.raises(ValueError, match=r"Unknown hyperparameter"): + zoo_model_config( + "ElasticNet", + {"cell_line_views": ["gene_expression"], "alpha": 0.1}, + ) + + +def test_zoo_model_config_routes_methylation_flat_key_to_pca_child() -> None: + from drevalpy.models.config import ResolvedModelConfig + + resolved = zoo_model_config("MultiViewRandomForest", {"methylation_n_components": 11}) + assert isinstance(resolved, ResolvedModelConfig) + assert resolved.predictor_values().get("methylation_n_components") is None + assert resolved.featurizer_values("cell_line", "pca[methylation]")["n_components"] == 11 + + +def test_get_zoo_config_applies_prediction_mode_override(monkeypatch: pytest.MonkeyPatch) -> None: + from drevalpy.types.enums.prediction_mode import PredictionMode + + monkeypatch.setattr(get_predictor("elasticNet"), "supported_modes", frozenset(PredictionMode)) + assert get_zoo_config("ElasticNet").prediction_mode == PredictionMode.REGRESSION + overridden = get_zoo_config("ElasticNet", prediction_mode=PredictionMode.CLASSIFICATION) + assert overridden.prediction_mode == PredictionMode.CLASSIFICATION + via_zoo = zoo_model_config("ElasticNet", prediction_mode=PredictionMode.CLASSIFICATION) + assert isinstance(via_zoo, ModelConfig) + assert via_zoo.prediction_mode == PredictionMode.CLASSIFICATION + + +def test_external_zoo_rejects_builtin_collision_and_is_atomic(tmp_path) -> None: + from drevalpy.models.zoo import clear_external_zoo, load_external_zoo_file + + clear_external_zoo() + bad = tmp_path / "zoo.yaml" + bad.write_text( + """ +goodEntry: + cell_line_featurizer: scaledGeneExpression + drug_featurizer: fingerprints + predictor: elasticNet +ElasticNet: + cell_line_featurizer: scaledGeneExpression + drug_featurizer: fingerprints + predictor: elasticNet +""", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="collides with a built-in"): + load_external_zoo_file(bad) + assert "goodEntry" not in list_zoo_names(include_external=True) + clear_external_zoo() + + +def test_model_config_for_name_uses_zoo_entry() -> None: + from drevalpy.models.config import ResolvedModelConfig + + config = model_config_for_name("ElasticNet", {"alpha": 0.5}) + assert isinstance(config, ResolvedModelConfig) + assert config.template.predictor.name == "elasticNet" + assert config.predictor_values()["alpha"] == 0.5 + + +def test_single_drug_sklearn_zoo_entries_use_identity_for_routing() -> None: + elastic_net = get_zoo_config("SingleDrugElasticNet") + random_forest = get_zoo_config("SingleDrugRandomForest") + + assert elastic_net.predictor.name == "singleDrugElasticNet" + assert random_forest.predictor.name == "singleDrugRandomForest" + assert elastic_net.drug_featurizer is not None + assert random_forest.drug_featurizer is not None + assert elastic_net.drug_featurizer.name == "identity" + assert random_forest.drug_featurizer.name == "identity" + assert elastic_net.model_id == "scaledGeneExpression:singleDrugElasticNet" + assert elastic_net.scope.value == "single_drug" + validate(elastic_net) + validate(random_forest) + + +def test_multi_drug_sklearn_predictor_without_drug_featurizer_fails() -> None: + from pydantic import ValidationError + + preset = get_zoo_config("ElasticNet") + with pytest.raises(ValidationError, match="requires a drug_featurizer"): + ModelConfig( + cell_line_featurizer=preset.cell_line_featurizer, + drug_featurizer=None, + predictor=preset.predictor, + prediction_mode=preset.prediction_mode, + ) + + +@pytest.mark.parametrize("name", LITERATURE_ZOO_NAMES) +def test_literature_zoo_entries_validate(name: str) -> None: + assert name in list_zoo_names(include_external=False) + config = get_zoo_config(name) + validate(config) + assert config.cell_line_featurizer is not None + if name in BLOCK_ZOO_NAMES: + assert config.drug_featurizer is not None + assert issubclass(get_predictor(config.predictor.name), BlockPredictor) + + +@pytest.mark.parametrize("name", LITERATURE_ZOO_NAMES) +def test_literature_zoo_entries_create_model(name: str) -> None: + config = get_zoo_config(name) + model = construct_model(name, config)() + assert model is not None + + +@pytest.mark.parametrize("name", ["MOLIR", "SuperFELTR"]) +def test_single_drug_zoo_entries_route_with_identity(name: str) -> None: + config = get_zoo_config(name) + assert config.drug_featurizer is not None + assert config.drug_featurizer.name == "identity" + validate(config) + + +def test_from_spec_resolves_literature_zoo() -> None: + config = from_spec("DrugGNN") + assert isinstance(config, ModelConfig) + assert config.predictor.name == "drugGNN" + assert config.cell_line_featurizer is not None + assert config.drug_featurizer is not None + + +def test_single_drug_zoo_membership() -> None: + single_names = list_zoo_names(include_external=False, scope=ModelScope.SINGLE_DRUG) + assert set(single_names) == { + "SingleDrugElasticNet", + "SingleDrugRandomForest", + "MOLIR", + "SuperFELTR", + } + for name in single_names: + model_class = construct_model(name) + assert model_class.is_single_drug() is True + assert get_zoo_config(name).scope == ModelScope.SINGLE_DRUG + + +def test_multi_drug_zoo_excludes_single_drug_scope() -> None: + multi_names = list_zoo_names(include_external=False, scope=ModelScope.MULTI_DRUG) + for name in multi_names: + model_class = construct_model(name) + assert model_class.is_single_drug() is False + assert get_zoo_config(name).scope == ModelScope.MULTI_DRUG diff --git a/tests/plugin/test_init.py b/tests/plugin/test_init.py new file mode 100644 index 000000000..008e06591 --- /dev/null +++ b/tests/plugin/test_init.py @@ -0,0 +1,275 @@ +"""Tests for the :mod:`drevalpy.plugin` facade. + +The facade is a compatibility promise, so the tests here are about the promise +rather than about behaviour: every name in ``__all__`` resolves, every alias +points at the same object as its underlying module (so the facade cannot drift +into holding a stale copy), and the wheel carries the PEP 561 marker that makes +the facade's annotations usable by a plugin's type checker. + +The packaging assertions at the bottom live here rather than in a new root-level +guard because they are about the same thing the facade is about - what an +installed consumer can see. ``py.typed`` is what makes the facade's annotations +usable, and ``dev-mode-exact`` is what stops the editable install from also +exporting ``tests``, ``tools`` and ``docs`` into every consumer's ``sys.path``. +""" + +from __future__ import annotations + +import importlib +import shutil +import sys +import tomllib + +import pytest +from upath import UPath + +from drevalpy import plugin +from tests._trusted_subprocess import run_trusted_python + +REPO_ROOT = UPath(__file__).resolve().parents[2] + +#: Where each exported name is defined, as ``alias -> (module, attribute)``. +#: Written out rather than derived from ``__module__`` so a symbol silently +#: moving between modules is a test failure and not a silently updated +#: expectation. The five per-registry ``register_*`` are all spelled ``register`` +#: in their own module; ``register_for_sides`` is a featurizer-only decorator +#: that keeps its own name. +EXPECTED_ORIGINS: dict[str, tuple[str, str]] = { + "BlockPredictor": ("drevalpy.components.predictors.abstract.block", "BlockPredictor"), + "BlockSpec": ("drevalpy.types.data.batch.feature_block", "BlockSpec"), + "CellLineFeatureSource": ("drevalpy.types.data.feature_source", "CellLineFeatureSource"), + "CellLineFeaturizer": ("drevalpy.components.featurizers.cell_line.base", "CellLineFeaturizer"), + "Dataset": ("drevalpy.types.data.dataset", "Dataset"), + "DenseViewCellLineFeaturizer": ( + "drevalpy.components.featurizers.cell_line.base", + "DenseViewCellLineFeaturizer", + ), + "DenseViewDrugFeaturizer": ("drevalpy.components.featurizers.drug.base", "DenseViewDrugFeaturizer"), + "DenseViewFeaturizer": ("drevalpy.components.featurizers._dense_view", "DenseViewFeaturizer"), + "DrugFeatureSource": ("drevalpy.types.data.feature_source", "DrugFeatureSource"), + "DrugFeaturizer": ("drevalpy.components.featurizers.drug.base", "DrugFeaturizer"), + "ExperimentResult": ("drevalpy.types.results", "ExperimentResult"), + "FeatureBlock": ("drevalpy.types.data.batch.feature_block", "FeatureBlock"), + "FeatureContract": ("drevalpy.components.contracts.contracts", "FeatureContract"), + "FeatureFormat": ("drevalpy.components.contracts.contracts", "FeatureFormat"), + "FeatureFreePredictor": ("drevalpy.components.predictors.abstract.feature_free", "FeatureFreePredictor"), + "FeatureSource": ("drevalpy.types.data.feature_source", "FeatureSource"), + "Featurizer": ("drevalpy.components.featurizers.base", "Featurizer"), + "FeaturizerStorageMixin": ("drevalpy.components.featurizers.storage", "FeaturizerStorageMixin"), + "HPOStrategy": ("drevalpy.components.featurizers.base", "HPOStrategy"), + "ImageVisualization": ("drevalpy.visualization.base", "ImageVisualization"), + "LiteratureReference": ("drevalpy.types.enums.literature_reference", "LiteratureReference"), + "MatrixPredictor": ("drevalpy.components.predictors.abstract.matrix", "MatrixPredictor"), + "ModelInputBatch": ("drevalpy.types.data.batch.model_input_batch", "ModelInputBatch"), + "ModelResult": ("drevalpy.types.results", "ModelResult"), + "ModelScope": ("drevalpy.types.enums.model_scope", "ModelScope"), + "MuDataLike": ("drevalpy.types.data.mudatalike", "MuDataLike"), + "PlotRequirement": ("drevalpy.visualization.requirements", "PlotRequirement"), + "PredictionMode": ("drevalpy.types.enums.prediction_mode", "PredictionMode"), + "Predictor": ("drevalpy.components.predictors.abstract.base", "Predictor"), + "ResponseBatch": ("drevalpy.types.data.batch.response_batch", "ResponseBatch"), + "RunResult": ("drevalpy.types.results", "RunResult"), + "Section": ("drevalpy.visualization.base", "Section"), + "SplitMask": ("drevalpy.types.data.split_mask", "SplitMask"), + "SplitMasks": ("drevalpy.types.data.split_masks", "SplitMasks"), + "SplitValidationError": ("drevalpy.registry.splitter", "SplitValidationError"), + "Splitter": ("drevalpy.registry.splitter", "Splitter"), + "TrainingContext": ("drevalpy.components.contracts.training_context", "TrainingContext"), + "Validation": ("drevalpy.registry.splitter", "Validation"), + "Visualization": ("drevalpy.visualization.base", "Visualization"), + "curve_quality_mask": ("drevalpy.data.quality", "curve_quality_mask"), + "graph_feature_block": ("drevalpy.types.data.batch.feature_block", "graph_feature_block"), + "merge_feature_blocks": ("drevalpy.types.data.batch.feature_block", "merge_feature_blocks"), + "metadata_feature_block": ("drevalpy.types.data.batch.feature_block", "metadata_feature_block"), + "numeric_feature_block": ("drevalpy.types.data.batch.feature_block", "numeric_feature_block"), + "ragged_feature_block": ("drevalpy.types.data.batch.feature_block", "ragged_feature_block"), + "register_cell_line_featurizer": ("drevalpy.registry.cell_line_featurizer", "register"), + "register_drug_featurizer": ("drevalpy.registry.drug_featurizer", "register"), + "register_for_sides": ("drevalpy.components.featurizers._side_binding", "register_for_sides"), + "register_predictor": ("drevalpy.registry.predictor", "register"), + "register_splitter": ("drevalpy.registry.splitter", "register"), + "register_visualization": ("drevalpy.registry.visualization", "register"), +} + + +@pytest.fixture(scope="module") +def pyproject() -> dict: + return tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + + +class TestPublicSurface: + @pytest.mark.parametrize("name", sorted(plugin.__all__)) + def test_every_exported_name_resolves(self, name): + assert hasattr(plugin, name) + + def test_all_is_sorted_and_unique(self): + assert list(plugin.__all__) == sorted(set(plugin.__all__)) + + def test_nothing_public_is_left_out_of_all(self): + public = {name for name in vars(plugin) if not name.startswith("_")} + modules = {name for name in public if isinstance(vars(plugin)[name], type(importlib))} + + assert public - modules == set(plugin.__all__) + + def test_the_facade_defines_nothing_itself(self): + """A definition here would be a second implementation to keep in sync.""" + own = [name for name in plugin.__all__ if getattr(vars(plugin)[name], "__module__", "") == "drevalpy.plugin"] + + assert own == [] + + +class TestAliasesPointAtTheRealSymbols: + @pytest.mark.parametrize(("alias", "origin"), sorted(EXPECTED_ORIGINS.items())) + def test_alias_is_the_same_object(self, alias, origin): + module_name, attribute = origin + module = importlib.import_module(module_name) + + assert getattr(plugin, alias) is getattr(module, attribute) + + def test_every_export_has_a_recorded_origin(self): + """Adding an export without recording where it comes from fails here.""" + assert sorted(EXPECTED_ORIGINS) == sorted(plugin.__all__) + + def test_the_five_register_aliases_are_distinct(self): + registrars = { + plugin.register_cell_line_featurizer, + plugin.register_drug_featurizer, + plugin.register_predictor, + plugin.register_splitter, + plugin.register_visualization, + } + + assert len(registrars) == 5 + + +class TestPyTypedMarker: + """PEP 561: without this file, a plugin's type checker treats drevalpy as untyped.""" + + def test_the_marker_exists_in_the_source_tree(self): + assert (REPO_ROOT / "drevalpy" / "py.typed").is_file() + + def test_the_wheel_target_names_the_marker_as_an_artifact(self, pyproject): + """``py.typed`` is not a ``.py`` file, so hatchling needs it listed.""" + wheel = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] + + assert "drevalpy/py.typed" in wheel["artifacts"] + + @pytest.mark.skipif(shutil.which("uv") is None, reason="needs uv to build a wheel") + def test_the_marker_ships_in_the_built_wheel(self, built_wheel_contents): + """Built for real: a marker missing from the wheel does nothing for consumers. + + The wheel comes from the session-scoped ``built_wheel_contents`` fixture + in ``tests/conftest.py``, which is shared with + ``tests/testing/test_init.py`` so the repository is only built once. + """ + assert "drevalpy/py.typed" in built_wheel_contents + + +class TestTheWheelShipsThePackageOnly: + """Its own repository layout is drevalpy's business, not a consumer's. + + Asserted apart from the ``py.typed`` check above because it is a different + packaging promise; both read the same session-shared build, so there is no + longer a reason to bundle them into one test. + """ + + @pytest.mark.skipif(shutil.which("uv") is None, reason="needs uv to build a wheel") + def test_no_repo_only_directory_is_in_the_wheel(self, built_wheel_contents): + leaked = sorted(name for name in built_wheel_contents if name.startswith(("tests/", "tools/", "docs/"))) + + assert leaked == [] + + +class TestEditableInstallDoesNotLeak: + """The default editable install puts the bare project root on ``sys.path``. + + That makes drevalpy's own ``tests``, ``tools`` and ``docs`` importable by + every consumer of the environment, so a plugin repo's ``import tests.x`` + resolved to *drevalpy's* tests. ``dev-mode-exact`` replaces the bare path + with a finder that maps only the ``drevalpy`` package. + """ + + def test_dev_mode_exact_is_enabled(self, pyproject): + assert pyproject["tool"]["hatch"]["build"]["dev-mode-exact"] is True + + def test_the_editables_runtime_dependency_is_declared(self, pyproject): + """``dev-mode-exact`` emits a finder importing ``editables`` at start-up. + + It has to be a real ``[project.dependencies]`` entry, not a dev-group + one. Dev groups do not propagate to consumers, and uv reads static + metadata straight from pyproject.toml for path dependencies rather than + from the built editable wheel that declares ``editables`` itself - so a + repo installing drevalpy as an editable path dependency got + ``ModuleNotFoundError: No module named 'editables'`` on every import. + """ + assert any(entry.startswith("editables") for entry in pyproject["project"]["dependencies"]) + assert not any(entry.startswith("editables") for entry in pyproject["dependency-groups"]["dev"]) + + def test_the_repo_top_level_directories_are_not_importable(self, tmp_path): + """Run from outside the repo, so only the installed paths are in play.""" + script = ( + "import importlib.util as u\n" + "print(u.find_spec('drevalpy') is not None)\n" + "print([name for name in ('tests', 'tools', 'docs') if u.find_spec(name) is not None])\n" + ) + + result = run_trusted_python(script, cwd=str(tmp_path)) + + assert result.returncode == 0, result.stderr + drevalpy_found, leaked = result.stdout.splitlines()[-2:] + assert drevalpy_found == "True" + assert leaked == "[]" + + def test_no_pth_file_exports_the_project_root(self): + """Belt and braces: the mechanism, not just its effect.""" + offenders = [ + path.name + for site_dir in sys.path + if site_dir.endswith("site-packages") + for path in UPath(site_dir).glob("*.pth") + if str(REPO_ROOT) in path.read_text(encoding="utf-8").splitlines() + ] + + assert offenders == [] + + +class TestFacadeIsSelfContained: + #: Extended tier: spawns an interpreter (~2.4s) to prove the facade needs no + #: other drevalpy import. Sharing a process with another test would defeat it. + pytestmark = pytest.mark.slow + + def test_importing_only_the_facade_is_enough_to_subclass(self): + """A plugin importing nothing but the facade must be able to declare a component.""" + script = ( + "from drevalpy.plugin import (\n" + " CellLineFeaturizer, FeatureFormat, BlockSpec, numeric_feature_block,\n" + " register_cell_line_featurizer,\n" + ")\n" + "import numpy as np\n" + "from typing import ClassVar\n" + "\n" + "@register_cell_line_featurizer(\n" + " 'facadeProbe', description='probe', contract=FeatureFormat.NUMERIC_MATRIX\n" + ")\n" + "class Probe(CellLineFeaturizer):\n" + " '''Probe.'''\n" + " entity_id_only: ClassVar[bool] = True\n" + " output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (\n" + " BlockSpec('probe', FeatureFormat.NUMERIC_MATRIX),\n" + " )\n" + " def _fit(self, source, **kwargs):\n" + " return self\n" + " def _transform_blocks(self, source, entity_ids):\n" + " return {'probe': numeric_feature_block(np.zeros((len(entity_ids), 1), dtype=np.float32))}\n" + " @property\n" + " def output_dim(self):\n" + " return 1\n" + "\n" + "from drevalpy.registry import cell_line_featurizer\n" + "print('facadeProbe' in cell_line_featurizer.list())\n" + ) + + result = run_trusted_python(script) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip().endswith("True") diff --git a/tests/registry/_helpers.py b/tests/registry/_helpers.py new file mode 100644 index 000000000..5539af57c --- /dev/null +++ b/tests/registry/_helpers.py @@ -0,0 +1,41 @@ +"""Shared registry-state helpers for the ``tests/registry`` tree. + +The component registries are process-global singletons and ``tests/conftest.py`` +repopulates the built-ins before every test, so a test that registers a name must +remove it again. ``register_builtin_components`` only *adds* - it does not evict - +so restoring state means clearing first and repopulating second. Doing only the +second half leaves the test's own names behind, which surfaces much later as a +duplicate-name ``ValueError`` in an unrelated test. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry +from drevalpy.registry.drug_featurizer import drug_featurizer_registry +from drevalpy.registry.predictor import predictor_registry + + +def clear_component_registries() -> None: + """Empty the cell-line featurizer, drug featurizer and predictor registries.""" + cell_line_featurizer_registry.clear() + drug_featurizer_registry.clear() + predictor_registry.clear() + + +def restore_component_registries() -> None: + """Drop every registration and re-register only the built-in components.""" + clear_component_registries() + register_builtin_components() + + +def isolated_component_registries() -> Iterator[None]: + """Yield with empty component registries, restoring the built-ins afterwards. + + Intended to back a module-local ``@pytest.fixture(autouse=True)``. + """ + clear_component_registries() + yield + restore_component_registries() diff --git a/tests/registry/cell_line_featurizer/test_registration.py b/tests/registry/cell_line_featurizer/test_registration.py new file mode 100644 index 000000000..54965956f --- /dev/null +++ b/tests/registry/cell_line_featurizer/test_registration.py @@ -0,0 +1,117 @@ +"""Tests for public cell-line featurizer registration and lookup helpers.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.registry.cell_line_featurizer import ( + get as get_cell_line_featurizer, +) +from drevalpy.registry.cell_line_featurizer import ( + list as list_cell_line_featurizers, +) +from drevalpy.registry.cell_line_featurizer import ( + metadata as get_cell_line_featurizer_metadata, +) +from drevalpy.registry.cell_line_featurizer import ( + register as register_cell_line_featurizer, +) +from tests.registry._helpers import isolated_component_registries + + +@pytest.fixture(autouse=True) +def _clear_registries() -> Iterator[None]: + yield from isolated_component_registries() + + +def test_register_and_lookup_cell_line_featurizer() -> None: + @register_cell_line_featurizer( + "dummyCellLine", + description="test cell line", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class DummyCellLine: + pass + + assert get_cell_line_featurizer("dummyCellLine") is DummyCellLine + assert vars(DummyCellLine)["contract"] == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + assert "dummyCellLine" in list_cell_line_featurizers() + + +def test_duplicate_registration_fails() -> None: + @register_cell_line_featurizer( + "dup", + description="first", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class First: + pass + + with pytest.raises(ValueError, match="already registered"): + + @register_cell_line_featurizer( + "dup", + description="second", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class Second: + pass + + +def test_unknown_component_fails() -> None: + with pytest.raises(ValueError, match="Unknown Cell line featurizer"): + get_cell_line_featurizer("missing") + + +def test_get_metadata_includes_output_format() -> None: + @register_cell_line_featurizer( + "graphFeat", + description="graph", + contract=FeatureFormat.GRAPH, + ) + class GraphFeat: + pass + + meta = get_cell_line_featurizer_metadata("graphFeat") + assert meta["output_format"] == "graph" + assert meta["description"] == "graph" + assert meta["tags"] == frozenset() + + +def test_duplicate_class_and_decorator_contract_prefers_the_decorator() -> None: + @register_cell_line_featurizer( + "conflict", + description="conflict", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class Conflict: + contract = FeatureContract(format=FeatureFormat.GRAPH) + + assert vars(Conflict)["contract"] == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + +def test_a_class_body_contract_is_a_valid_declaration() -> None: + @register_cell_line_featurizer("bodyContract", description="declares on the class body") + class BodyContract: + contract = FeatureContract(format=FeatureFormat.GRAPH) + + assert get_cell_line_featurizer_metadata("bodyContract")["output_format"] == "graph" + + +def test_a_class_body_format_shorthand_is_normalized() -> None: + @register_cell_line_featurizer("shorthandContract", description="format shorthand") + class ShorthandContract: + contract = FeatureFormat.RAGGED_SEQUENCE + + assert vars(ShorthandContract)["contract"] == FeatureContract(format=FeatureFormat.RAGGED_SEQUENCE) + + +def test_a_featurizer_declaring_no_contract_anywhere_is_rejected() -> None: + with pytest.raises(ValueError, match="no contract declared"): + + @register_cell_line_featurizer("noContract", description="missing contract") + class NoContract: + pass diff --git a/tests/registry/cell_line_featurizer/test_registry.py b/tests/registry/cell_line_featurizer/test_registry.py new file mode 100644 index 000000000..9b5410484 --- /dev/null +++ b/tests/registry/cell_line_featurizer/test_registry.py @@ -0,0 +1,100 @@ +"""Tests for CellLineFeaturizerRegistry type and singleton.""" + +from __future__ import annotations + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry +from drevalpy.registry.cell_line_featurizer._registry import CellLineFeaturizerRegistry +from drevalpy.registry.featurizer import FeaturizerRegistry + + +def test_cell_line_featurizer_registry_subclasses_the_shared_base() -> None: + assert issubclass(CellLineFeaturizerRegistry, FeaturizerRegistry) + + +def test_cell_line_featurizer_registry_uses_fixed_identity() -> None: + registry = CellLineFeaturizerRegistry() + + assert registry._registry_id == "cell_line_featurizer" + assert registry._label == "Cell line featurizer" + assert registry._display_name == "cell_line_featurizers" + + +def test_cell_line_featurizer_registry_declares_the_cell_line_side() -> None: + assert CellLineFeaturizerRegistry()._side == "cell_line" + + +def test_module_singleton_is_a_cell_line_featurizer_registry() -> None: + assert isinstance(cell_line_featurizer_registry, CellLineFeaturizerRegistry) + + +def test_isolated_registry_registers_with_a_contract() -> None: + registry = CellLineFeaturizerRegistry() + + @registry.register("localCellLine", description="local", contract=FeatureFormat.NUMERIC_MATRIX) + class LocalCellLine: + pass + + assert registry.get("localCellLine") is LocalCellLine + assert vars(LocalCellLine)["contract"] == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + +def test_registration_stamps_the_cell_line_side_onto_the_class() -> None: + registry = CellLineFeaturizerRegistry() + + @registry.register("sidedCellLine", description="sided", contract=FeatureFormat.NUMERIC_MATRIX) + class SidedCellLine: + pass + + assert SidedCellLine.side == "cell_line" + + +def test_registration_defaults_the_storage_key_to_the_registry_name() -> None: + registry = CellLineFeaturizerRegistry() + + @registry.register("storedCellLine", description="stored", contract=FeatureFormat.NUMERIC_MATRIX) + class StoredCellLine: + pass + + assert StoredCellLine.storage_key == "storedCellLine" + + +def test_metadata_reports_the_cell_line_display_name() -> None: + registry = CellLineFeaturizerRegistry() + + @registry.register("metaCellLine", description="meta", contract=FeatureFormat.GRAPH) + class MetaCellLine: + pass + + assert registry.get_metadata("metaCellLine")["registry"] == "cell_line_featurizers" + + +def test_metadata_listing_covers_every_registered_featurizer() -> None: + registry = _registry_with_two_featurizers() + + assert {row["name"] for row in registry.list_metadata()} == {"coreFeat", "baselineFeat"} + + +def test_metadata_listing_filters_by_tag() -> None: + registry = _registry_with_two_featurizers() + + assert {row["name"] for row in registry.list_metadata(tag="baseline")} == {"baselineFeat"} + + +def _registry_with_two_featurizers() -> CellLineFeaturizerRegistry: + registry = CellLineFeaturizerRegistry() + + @registry.register("coreFeat", description="core", contract=FeatureFormat.NUMERIC_MATRIX) + class CoreFeat: + pass + + @registry.register( + "baselineFeat", + description="baseline", + tags=("baseline",), + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class BaselineFeat: + pass + + return registry diff --git a/tests/registry/cell_line_featurizer/test_validate.py b/tests/registry/cell_line_featurizer/test_validate.py new file mode 100644 index 000000000..936d4618b --- /dev/null +++ b/tests/registry/cell_line_featurizer/test_validate.py @@ -0,0 +1,20 @@ +"""Tests for the cell-line featurizer validation shim. + +``drevalpy/registry/cell_line_featurizer/_validate.py`` is a pure re-export of the +shared featurizer validation, so the only behaviour to pin is the identity of the +re-exported name. The validation itself is covered in +``tests/registry/featurizer/test_validate.py``. +""" + +from __future__ import annotations + +from drevalpy.registry.cell_line_featurizer import _validate +from drevalpy.registry.featurizer._validate import validate_featurizer_input_views + + +def test_re_exports_the_shared_validator() -> None: + assert _validate.validate_featurizer_input_views is validate_featurizer_input_views + + +def test_exports_only_the_shared_validator() -> None: + assert _validate.__all__ == ["validate_featurizer_input_views"] diff --git a/tests/registry/components/test_abstract.py b/tests/registry/components/test_abstract.py new file mode 100644 index 000000000..22373744d --- /dev/null +++ b/tests/registry/components/test_abstract.py @@ -0,0 +1,78 @@ +"""Tests for the shared abstract-member check run at registration time.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +import pytest + +from drevalpy.registry.components._abstract import abstract_members, validate_no_abstract_methods + + +class _Base(ABC): + """Two-method abstract base used to build the fixtures below.""" + + @abstractmethod + def alpha(self) -> None: + """First required member.""" + + @abstractmethod + def beta(self) -> None: + """Second required member.""" + + +class _Partial(_Base): + """Implements one of the two required members.""" + + def alpha(self) -> None: + """Implemented.""" + + +class _Complete(_Partial): + """Implements both required members.""" + + def beta(self) -> None: + """Implemented.""" + + +class _Plain: + """A class that is not an ABC at all.""" + + +def test_a_plain_class_has_no_abstract_members() -> None: + assert abstract_members(_Plain) == () + + +def test_a_complete_subclass_has_no_abstract_members() -> None: + assert abstract_members(_Complete) == () + + +def test_abstract_members_are_reported_sorted() -> None: + assert abstract_members(_Base) == ("alpha", "beta") + + +def test_only_the_unimplemented_members_are_reported() -> None: + assert abstract_members(_Partial) == ("beta",) + + +def test_a_plain_class_passes_validation() -> None: + validate_no_abstract_methods("predictor", "plain", _Plain) + + +def test_a_complete_subclass_passes_validation() -> None: + validate_no_abstract_methods("predictor", "complete", _Complete) + + +def test_an_incomplete_subclass_is_rejected() -> None: + with pytest.raises(ValueError, match=r"predictor 'partial' \(_Partial\) does not implement beta"): + validate_no_abstract_methods("predictor", "partial", _Partial) + + +def test_the_error_names_every_missing_member() -> None: + with pytest.raises(ValueError, match="does not implement alpha, beta"): + validate_no_abstract_methods("predictor", "base", _Base) + + +def test_the_error_suggests_the_fix() -> None: + with pytest.raises(ValueError, match="register a concrete subclass"): + validate_no_abstract_methods("cell_line_featurizer", "base", _Base) diff --git a/tests/registry/components/test_base.py b/tests/registry/components/test_base.py new file mode 100644 index 000000000..5a6b65c6e --- /dev/null +++ b/tests/registry/components/test_base.py @@ -0,0 +1,17 @@ +"""Tests for shared registry base helpers.""" + +from __future__ import annotations + +from drevalpy.registry.components import ComponentRegistry +from drevalpy.registry.featurizer import FeaturizerRegistry +from drevalpy.registry.predictor import PredictorRegistry + + +def test_required_fields_are_explicit_per_registry() -> None: + assert ComponentRegistry._required_fields == ("description",) + assert FeaturizerRegistry._required_fields == ("description", "contract") + assert PredictorRegistry._required_fields == ( + "description", + "cell_line_contract", + "drug_contract", + ) diff --git a/tests/registry/components/test_contract_assignment.py b/tests/registry/components/test_contract_assignment.py new file mode 100644 index 000000000..5b60e8daa --- /dev/null +++ b/tests/registry/components/test_contract_assignment.py @@ -0,0 +1,122 @@ +"""Tests for :mod:`drevalpy.registry.components._contract_assignment`. + +Mirrors the private module with the underscore stripped. Both concrete registries +delegate contract resolution here, so the precedence rule - decorator argument +over class-body declaration - and its error messages are pinned once, on plain +classes, without going through a registration decorator. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.registry.components._contract_assignment import assign_contract, declared_contract + +GRAPH = FeatureContract(format=FeatureFormat.GRAPH) +MATRIX = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + +class TestAssignContract: + def test_uses_the_decorator_contract_when_the_class_declares_none(self): + class Component: + pass + + assign_contract(Component, "contract", GRAPH) + + assert Component.contract == GRAPH + + def test_the_decorator_wins_over_a_class_body_declaration(self): + class Component: + contract = MATRIX + + assign_contract(Component, "contract", GRAPH) + + assert Component.contract == GRAPH + + def test_falls_back_to_the_class_body_declaration(self): + class Component: + contract = GRAPH + + assign_contract(Component, "contract", None) + + assert Component.contract == GRAPH + + def test_normalizes_a_class_body_format_shorthand(self): + class Component: + contract = FeatureFormat.GRAPH + + assign_contract(Component, "contract", None) + + assert Component.contract == GRAPH + + def test_a_declaration_inherited_from_a_base_class_is_usable(self): + class Base: + contract = MATRIX + + class Component(Base): + pass + + assign_contract(Component, "contract", None) + + assert Component.__dict__["contract"] == MATRIX + + def test_assigns_each_named_attribute_independently(self): + class Predictor: + pass + + assign_contract(Predictor, "cell_line_contract", MATRIX) + assign_contract(Predictor, "drug_contract", GRAPH) + + assert (Predictor.cell_line_contract, Predictor.drug_contract) == (MATRIX, GRAPH) + + def test_rejects_a_component_that_declares_nothing(self): + class Component: + pass + + with pytest.raises(ValueError, match="no cell_line_contract declared"): + assign_contract(Component, "cell_line_contract", None) + + def test_the_error_names_the_class_and_both_ways_to_fix_it(self): + class Component: + pass + + with pytest.raises(ValueError, match=r"Component: .*pass contract= to the .*or set it on the class body"): + assign_contract(Component, "contract", None) + + +class TestDeclaredContract: + def test_returns_a_declared_contract_unchanged(self): + class Component: + contract = GRAPH + + assert declared_contract(Component, "contract") is GRAPH + + def test_promotes_a_bare_format_to_a_contract(self): + class Component: + contract = FeatureFormat.NUMERIC_MATRIX + + assert declared_contract(Component, "contract") == MATRIX + + def test_an_explicit_none_counts_as_undeclared(self): + class Component: + contract = None + + with pytest.raises(ValueError, match="no contract declared"): + declared_contract(Component, "contract") + + def test_reports_an_unusable_declaration_as_invalid_rather_than_missing(self): + class Component: + contract = "numeric_matrix" + + with pytest.raises(ValueError, match="class-body contract is invalid"): + declared_contract(Component, "contract") + + def test_keeps_the_underlying_type_error_as_the_cause(self): + class Component: + contract = 42 + + with pytest.raises(ValueError, match="class-body contract is invalid") as excinfo: + declared_contract(Component, "contract") + + assert isinstance(excinfo.value.__cause__, TypeError) diff --git a/tests/registry/components/test_metadata.py b/tests/registry/components/test_metadata.py new file mode 100644 index 000000000..29dfa40eb --- /dev/null +++ b/tests/registry/components/test_metadata.py @@ -0,0 +1,190 @@ +"""Tests for the catalog metadata dicts built for registered components. + +These are pure functions over class attributes, so every case is a bare class - +no registry involved and therefore no global registry state to restore. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.registry.components._metadata import ( + base_component_metadata, + featurizer_component_metadata, + predictor_component_metadata, +) +from drevalpy.types.enums.literature_reference import LiteratureReference + +_EMPTY_REFERENCE_FIELDS = { + "repo_url": "", + "citation": "", + "citation_doi": "", + "citation_text": "", + "deviations": "", +} + + +class _Bare: + pass + + +class _Described: + description = "a described component" + tags = frozenset({"baseline"}) + + +class _WithDoi: + description = "doi reference" + reference = LiteratureReference( + repo_url="https://github.com/example/repo", + citation_doi="10.1234/example", + deviations="none", + ) + + +class _WithCitationText: + description = "text reference" + reference = LiteratureReference( + repo_url="https://github.com/example/repo", + citation_text="Doe et al., 2024", + ) + + +class _WithBadReference: + description = "not a reference" + reference = "https://example.org/paper" + + +def test_base_metadata_carries_registry_and_name() -> None: + meta = base_component_metadata("predictors", "demo", _Described) + + assert meta["registry"] == "predictors" + assert meta["name"] == "demo" + assert meta["class_name"] == "_Described" + + +def test_base_metadata_copies_description_and_tags() -> None: + meta = base_component_metadata("predictors", "demo", _Described) + + assert meta["description"] == "a described component" + assert meta["tags"] == frozenset({"baseline"}) + + +def test_base_metadata_defaults_missing_description_and_tags() -> None: + meta = base_component_metadata("predictors", "bare", _Bare) + + assert meta["description"] == "" + assert meta["tags"] == frozenset() + + +def test_base_metadata_coerces_a_none_description() -> None: + class NoneDescription: + description = None + + meta = base_component_metadata("predictors", "none", NoneDescription) + + assert meta["description"] == "" + + +def test_base_metadata_leaves_reference_fields_empty_without_a_reference() -> None: + meta = base_component_metadata("predictors", "bare", _Bare) + + assert {key: meta[key] for key in _EMPTY_REFERENCE_FIELDS} == _EMPTY_REFERENCE_FIELDS + + +def test_base_metadata_ignores_a_reference_of_the_wrong_type() -> None: + meta = base_component_metadata("predictors", "bad", _WithBadReference) + + assert {key: meta[key] for key in _EMPTY_REFERENCE_FIELDS} == _EMPTY_REFERENCE_FIELDS + + +def test_base_metadata_expands_a_doi_into_a_resolvable_url() -> None: + meta = base_component_metadata("predictors", "doi", _WithDoi) + + assert meta["citation"] == "https://doi.org/10.1234/example" + assert meta["citation_doi"] == "10.1234/example" + + +def test_base_metadata_copies_the_reference_repo_and_deviations() -> None: + meta = base_component_metadata("predictors", "doi", _WithDoi) + + assert meta["repo_url"] == "https://github.com/example/repo" + assert meta["deviations"] == "none" + + +def test_base_metadata_falls_back_to_the_citation_text() -> None: + meta = base_component_metadata("predictors", "text", _WithCitationText) + + assert meta["citation"] == "Doe et al., 2024" + assert meta["citation_text"] == "Doe et al., 2024" + + +def test_featurizer_metadata_reports_the_contract_format() -> None: + class GraphFeaturizer: + description = "graph featurizer" + contract = FeatureContract(format=FeatureFormat.GRAPH) + + meta = featurizer_component_metadata("drug_featurizers", "graph", GraphFeaturizer) + + assert meta["output_format"] == "graph" + + +def test_featurizer_metadata_defaults_precompute_to_false() -> None: + class NumericFeaturizer: + description = "numeric featurizer" + contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + meta = featurizer_component_metadata("drug_featurizers", "numeric", NumericFeaturizer) + + assert meta["precompute"] is False + + +def test_featurizer_metadata_reports_an_opted_in_precompute() -> None: + class PrecomputedFeaturizer: + description = "precomputed featurizer" + contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + precompute = True + + meta = featurizer_component_metadata("drug_featurizers", "precomputed", PrecomputedFeaturizer) + + assert meta["precompute"] is True + + +def test_featurizer_metadata_keeps_the_shared_base_fields() -> None: + class NumericFeaturizer: + description = "numeric featurizer" + contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + meta = featurizer_component_metadata("drug_featurizers", "numeric", NumericFeaturizer) + + assert meta["registry"] == "drug_featurizers" + assert meta["description"] == "numeric featurizer" + + +def test_featurizer_metadata_requires_a_contract() -> None: + with pytest.raises(TypeError, match="must define a contract"): + featurizer_component_metadata("drug_featurizers", "bare", _Bare) + + +def test_predictor_metadata_reports_the_input_interface() -> None: + class FeatureFreeLike: + description = "feature free" + input_interface = "feature_free" + + meta = predictor_component_metadata("predictors", "featureFree", FeatureFreeLike) + + assert meta["input_interface"] == "feature_free" + + +def test_predictor_metadata_defaults_the_input_interface_to_empty() -> None: + meta = predictor_component_metadata("predictors", "bare", _Bare) + + assert meta["input_interface"] == "" + + +def test_predictor_metadata_keeps_the_shared_base_fields() -> None: + meta = predictor_component_metadata("predictors", "demo", _Described) + + assert meta["registry"] == "predictors" + assert meta["tags"] == frozenset({"baseline"}) diff --git a/tests/registry/components/test_metadata_validate.py b/tests/registry/components/test_metadata_validate.py new file mode 100644 index 000000000..5b8f3eae0 --- /dev/null +++ b/tests/registry/components/test_metadata_validate.py @@ -0,0 +1,128 @@ +"""Tests for registry class-state validation and role checks.""" + +from __future__ import annotations + +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.registry.components._metadata_validate import validate_registered_class +from drevalpy.registry.components._registration_metadata import ( + apply_registration_metadata, + normalize_registration_metadata, +) +from drevalpy.registry.featurizer import FeaturizerRegistry +from drevalpy.registry.predictor import PredictorRegistry +from drevalpy.types.enums.literature_reference import LiteratureReference + + +def test_literature_reference_is_accepted() -> None: + class Lit: + cell_line_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + drug_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + apply_registration_metadata( + Lit, + normalize_registration_metadata( + "lit model", + reference=LiteratureReference( + repo_url="https://github.com/example/repo", + citation_doi="10.1234/example", + deviations="none", + ), + ), + ) + validate_registered_class( + "predictor", + "lit", + Lit, + required_fields=PredictorRegistry._required_fields, + ) + + +def test_literature_reference_missing_fields_fails_on_normalize() -> None: + with pytest.raises(ValueError, match="invalid fields"): + normalize_registration_metadata( + "lit model", + reference=LiteratureReference(repo_url="https://github.com/example/repo"), + ) + + +def test_featurizer_role_validation_requires_contract() -> None: + class Native: + description = "native" + tags: frozenset[str] = frozenset() + reference = None + + with pytest.raises(ValueError, match="missing=\\['contract'\\]"): + validate_registered_class( + "drug_featurizer", + "native", + Native, + required_fields=FeaturizerRegistry._required_fields, + ) + + +def test_predictor_role_validation_requires_contracts() -> None: + class Native: + description = "native" + tags: frozenset[str] = frozenset() + reference = None + + with pytest.raises(ValueError, match="missing=\\['cell_line_contract', 'drug_contract'\\]"): + validate_registered_class( + "predictor", + "native", + Native, + required_fields=PredictorRegistry._required_fields, + ) + + +def test_missing_description_fails() -> None: + class Empty: + tags: frozenset[str] = frozenset() + + with pytest.raises(ValueError, match="missing=\\['description'\\]"): + validate_registered_class( + "predictor", + "empty", + Empty, + required_fields=("description",), + ) + + +def test_wrong_type_contract_fails() -> None: + class BadContract: + description = "bad" + tags: frozenset[str] = frozenset() + reference = None + contract = "numeric_matrix" + + with pytest.raises(ValueError, match="invalid=\\['contract'\\]"): + validate_registered_class( + "drug_featurizer", + "bad", + BadContract, + required_fields=FeaturizerRegistry._required_fields, + ) + + +def test_register_rejects_blank_description_before_class_body() -> None: + registry = FeaturizerRegistry("test", "Test", "tests") + with pytest.raises(ValueError, match="description must be a non-empty string"): + registry.register( + "blank", + description=" ", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + + +def test_register_rejects_bad_contract_before_class_body() -> None: + registry = FeaturizerRegistry("test", "Test", "tests") + with pytest.raises( + (TypeError, Exception), match="FeatureContract|FeatureFormat|did not match any element in the union" + ): + registry.register( + "badContract", + description="demo", + contract="numeric_matrix", # type: ignore[arg-type] + ) diff --git a/tests/registry/components/test_registration_metadata.py b/tests/registry/components/test_registration_metadata.py new file mode 100644 index 000000000..9c0310284 --- /dev/null +++ b/tests/registry/components/test_registration_metadata.py @@ -0,0 +1,76 @@ +"""Tests for normalized registration metadata helpers.""" + +from __future__ import annotations + +import pytest + +from drevalpy.registry.components._registration_metadata import ( + RegistrationMetadata, + apply_registration_metadata, + normalize_registration_metadata, +) +from drevalpy.types.enums.literature_reference import LiteratureReference + + +def test_normalize_registration_metadata_strips_tags_and_description() -> None: + metadata = normalize_registration_metadata( + " demo ", + tags=(" baseline ", "", "omics"), + reference=LiteratureReference( + repo_url="https://github.com/example/repo", + citation_doi="10.1234/example", + deviations="none", + ), + ) + assert metadata == RegistrationMetadata( + description="demo", + tags=frozenset({"baseline", "omics"}), + reference=LiteratureReference( + repo_url="https://github.com/example/repo", + citation_doi="10.1234/example", + deviations="none", + ), + ) + + +def test_normalize_registration_metadata_rejects_blank_description() -> None: + with pytest.raises(ValueError, match="description must be a non-empty string"): + normalize_registration_metadata(" ") + + +def test_normalize_registration_metadata_rejects_bare_string_tags() -> None: + with pytest.raises(TypeError, match="iterable of strings"): + normalize_registration_metadata("demo", tags="baseline") + + +def test_normalize_registration_metadata_rejects_non_string_tags() -> None: + with pytest.raises(TypeError, match="tags must contain strings"): + normalize_registration_metadata("demo", tags=("ok", 1)) # type: ignore[arg-type] + + +def test_normalize_registration_metadata_rejects_bad_reference_type() -> None: + with pytest.raises((TypeError, Exception), match="LiteratureReference|did not match any element in the union"): + normalize_registration_metadata("demo", reference="not-a-reference") # type: ignore[arg-type] + + +def test_normalize_registration_metadata_rejects_incomplete_reference() -> None: + with pytest.raises(ValueError, match="invalid fields"): + normalize_registration_metadata( + "demo", + reference=LiteratureReference(repo_url="https://github.com/example/repo"), + ) + + +def test_apply_registration_metadata_assigns_fields() -> None: + class Target: + pass + + metadata = RegistrationMetadata( + description="demo", + tags=frozenset({"baseline"}), + reference=None, + ) + apply_registration_metadata(Target, metadata) + assert Target.description == "demo" # type: ignore[attr-defined] + assert Target.tags == frozenset({"baseline"}) # type: ignore[attr-defined] + assert Target.reference is None # type: ignore[attr-defined] diff --git a/tests/registry/dataset/test_io.py b/tests/registry/dataset/test_io.py new file mode 100644 index 000000000..c029cefca --- /dev/null +++ b/tests/registry/dataset/test_io.py @@ -0,0 +1,129 @@ +"""Tests for dataset-registry config file I/O. + +``get_config_path`` resolves through ``drevalpy.data._paths.get_config_dir``, which +honours ``DREVALPY_CONFIG_DIR``. Pointing that at ``tmp_path`` keeps every test off +the developer's real config file. +""" + +from __future__ import annotations + +import json + +import pytest +from filelock import FileLock, Timeout +from upath import UPath + +from drevalpy.registry.dataset import _io +from drevalpy.registry.dataset._models import DatasetEntry, DrevalConfig, SourceEntry + + +@pytest.fixture +def config_dir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> UPath: + """Redirect the drevalpy config directory into ``tmp_path``.""" + monkeypatch.setenv("DREVALPY_CONFIG_DIR", str(tmp_path)) + return UPath(tmp_path) + + +def test_lock_timeout_is_bounded() -> None: + assert _io._LOCK_TIMEOUT == 10 + + +def test_config_path_lives_in_the_config_dir(config_dir: UPath) -> None: + assert _io.get_config_path() == config_dir / "datasets.json" + + +def test_lock_path_is_a_sibling_of_the_config_file(config_dir: UPath) -> None: + assert _io._lock_path() == config_dir / "datasets.lock" + + +def test_config_lock_creates_the_lock_file(config_dir: UPath) -> None: + config_dir.mkdir(parents=True, exist_ok=True) + + with _io.config_lock(): + assert _io._lock_path().is_file() + + +def test_config_lock_releases_on_exit(config_dir: UPath) -> None: + config_dir.mkdir(parents=True, exist_ok=True) + + with _io.config_lock(): + pass + + with _io.config_lock(): + assert _io._lock_path().is_file() + + +def test_config_lock_is_exclusive_while_held(config_dir: UPath) -> None: + config_dir.mkdir(parents=True, exist_ok=True) + contender = FileLock(_io._lock_path(), timeout=0) + + with _io.config_lock(), pytest.raises(Timeout): + contender.acquire() + + +def test_config_lock_is_reentrant_within_one_process(config_dir: UPath) -> None: + config_dir.mkdir(parents=True, exist_ok=True) + + with _io.config_lock(), _io.config_lock(): + assert _io._lock_path().is_file() + + +def test_config_lock_stays_held_until_the_outermost_block_exits(config_dir: UPath) -> None: + config_dir.mkdir(parents=True, exist_ok=True) + contender = FileLock(_io._lock_path(), timeout=0) + + with _io.config_lock(): + with _io.config_lock(): + pass + # The inner block exiting must not release the lock for everyone else. + with pytest.raises(Timeout): + contender.acquire() + + +def test_load_config_returns_defaults_when_the_file_is_missing(config_dir: UPath) -> None: + assert _io.load_config() == DrevalConfig() + + +def test_load_config_parses_an_existing_file(config_dir: UPath) -> None: + _io.get_config_path().write_text( + json.dumps( + { + "sources": {"local": "file:///data"}, + "datasets": {"Toy": {"source": "local", "file": "toy.h5mu"}}, + } + ), + encoding="utf-8", + ) + + config = _io.load_config() + + assert config.sources == {"local": SourceEntry(url="file:///data")} + assert config.datasets == {"Toy": DatasetEntry(source="local", file="toy.h5mu")} + + +def test_save_config_creates_the_config_directory(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DREVALPY_CONFIG_DIR", str(tmp_path / "nested" / "config")) + + _io.save_config(DrevalConfig()) + + assert _io.get_config_path().is_file() + + +def test_save_config_writes_indented_json_with_a_trailing_newline(config_dir: UPath) -> None: + _io.save_config(DrevalConfig(sources={"local": SourceEntry(url="file:///data")})) + + written = _io.get_config_path().read_text(encoding="utf-8") + + assert written.endswith("\n") + assert '\n "sources"' in written + + +def test_save_then_load_round_trips_storage_options(config_dir: UPath) -> None: + original = DrevalConfig( + sources={"s3": SourceEntry(url="s3://bucket/", storage_options={"anon": True})}, + datasets={"Toy": DatasetEntry(source="s3", file="toy.h5mu")}, + ) + + _io.save_config(original) + + assert _io.load_config() == original diff --git a/tests/registry/dataset/test_models.py b/tests/registry/dataset/test_models.py new file mode 100644 index 000000000..8b755099d --- /dev/null +++ b/tests/registry/dataset/test_models.py @@ -0,0 +1,95 @@ +"""Tests for the dataset-registry pydantic config models.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from drevalpy.registry.dataset._models import DatasetEntry, DrevalConfig, SourceEntry + + +def test_source_entry_defaults_to_no_storage_options() -> None: + assert SourceEntry(url="s3://bucket/").storage_options == {} + + +def test_source_entry_from_raw_accepts_a_bare_url() -> None: + assert SourceEntry.from_raw("s3://bucket/") == SourceEntry(url="s3://bucket/") + + +def test_source_entry_from_raw_accepts_a_mapping() -> None: + entry = SourceEntry.from_raw({"url": "s3://bucket/", "storage_options": {"anon": True}}) + + assert entry == SourceEntry(url="s3://bucket/", storage_options={"anon": True}) + + +def test_source_entry_to_raw_collapses_to_a_string_without_options() -> None: + assert SourceEntry(url="s3://bucket/").to_raw() == "s3://bucket/" + + +def test_source_entry_to_raw_keeps_a_mapping_with_options() -> None: + entry = SourceEntry(url="s3://bucket/", storage_options={"anon": True}) + + assert entry.to_raw() == {"url": "s3://bucket/", "storage_options": {"anon": True}} + + +def test_source_entry_requires_a_url() -> None: + with pytest.raises(ValidationError, match="url"): + SourceEntry() # type: ignore[call-arg] + + +def test_dataset_entry_requires_source_and_file() -> None: + with pytest.raises(ValidationError, match="file"): + DatasetEntry(source="local") # type: ignore[call-arg] + + +def test_config_defaults_are_empty() -> None: + config = DrevalConfig() + + assert config.sources == {} + assert config.datasets == {} + + +def test_config_rejects_unknown_root_keys() -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + DrevalConfig(cache_dir="/var/cache") # type: ignore[call-arg] + + +def test_config_from_raw_ignores_absent_sections() -> None: + assert DrevalConfig.from_raw({}) == DrevalConfig() + + +def test_config_from_raw_parses_mixed_source_shapes() -> None: + config = DrevalConfig.from_raw( + { + "sources": { + "plain": "https://example.org/", + "with_options": {"url": "s3://bucket/", "storage_options": {"anon": True}}, + }, + "datasets": {"Toy": {"source": "plain", "file": "toy.h5mu"}}, + } + ) + + assert config.sources["plain"] == SourceEntry(url="https://example.org/") + assert config.sources["with_options"].storage_options == {"anon": True} + assert config.datasets["Toy"] == DatasetEntry(source="plain", file="toy.h5mu") + + +def test_config_to_raw_serializes_sources_and_datasets() -> None: + config = DrevalConfig( + sources={"plain": SourceEntry(url="https://example.org/")}, + datasets={"Toy": DatasetEntry(source="plain", file="toy.h5mu")}, + ) + + assert config.to_raw() == { + "sources": {"plain": "https://example.org/"}, + "datasets": {"Toy": {"source": "plain", "file": "toy.h5mu"}}, + } + + +def test_config_round_trips_through_raw() -> None: + config = DrevalConfig( + sources={"s3": SourceEntry(url="s3://bucket/", storage_options={"anon": True})}, + datasets={"Toy": DatasetEntry(source="s3", file="toy.h5mu")}, + ) + + assert DrevalConfig.from_raw(config.to_raw()) == config diff --git a/tests/registry/dataset/test_registry.py b/tests/registry/dataset/test_registry.py new file mode 100644 index 000000000..3b28452d1 --- /dev/null +++ b/tests/registry/dataset/test_registry.py @@ -0,0 +1,198 @@ +"""Tests for :class:`~drevalpy.registry.dataset._registry.DatasetRegistry`. + +Every test drives a freshly constructed ``DatasetRegistry`` rather than the +``dataset_registry`` singleton, and every registration is written into a +``tmp_path`` config directory, so neither the module singleton nor the +developer's real ``datasets.json`` is mutated. +""" + +from __future__ import annotations + +import json + +import pytest +from upath import UPath + +from drevalpy.registry import dataset as dataset_facade +from drevalpy.registry.dataset._models import DatasetEntry, SourceEntry +from drevalpy.registry.dataset._registry import DatasetRegistry, dataset_registry + +_BUILTIN_DATASET = "GDSC1" +_BUILTIN_SOURCE = "orakl" + + +@pytest.fixture +def config_path(tmp_path, monkeypatch: pytest.MonkeyPatch) -> UPath: + """Redirect the drevalpy config directory into ``tmp_path``.""" + monkeypatch.setenv("DREVALPY_CONFIG_DIR", str(tmp_path)) + return UPath(tmp_path) / "datasets.json" + + +@pytest.fixture +def registry(config_path: UPath) -> DatasetRegistry: + """A registry whose custom entries live in an empty ``tmp_path`` config.""" + return DatasetRegistry() + + +def test_builtin_datasets_are_loaded_from_the_packaged_json(registry: DatasetRegistry) -> None: + assert registry.builtin_datasets[_BUILTIN_DATASET] == DatasetEntry( + source=_BUILTIN_SOURCE, file=f"{_BUILTIN_DATASET}.h5mu" + ) + + +def test_builtin_sources_are_loaded_from_the_packaged_json(registry: DatasetRegistry) -> None: + assert registry.builtin_sources[_BUILTIN_SOURCE].url.startswith("s3://") + + +def test_custom_entries_are_empty_without_a_config_file(registry: DatasetRegistry) -> None: + assert registry.custom_datasets == {} + assert registry.custom_sources == {} + + +def test_dataset_names_are_sorted(registry: DatasetRegistry) -> None: + assert registry.dataset_names == sorted(registry.dataset_names) + + +def test_source_names_are_sorted(registry: DatasetRegistry) -> None: + assert registry.source_names == sorted(registry.source_names) + + +def test_is_registered_recognizes_a_builtin(registry: DatasetRegistry) -> None: + assert registry.is_registered(_BUILTIN_DATASET) is True + + +def test_is_registered_rejects_an_unknown_name(registry: DatasetRegistry) -> None: + assert registry.is_registered("NotADataset") is False + + +def test_register_source_persists_the_entry(registry: DatasetRegistry, config_path: UPath) -> None: + registry.register_source("local", "file:///data") + + assert json.loads(config_path.read_text(encoding="utf-8"))["sources"] == {"local": "file:///data"} + + +def test_register_source_keeps_storage_options(registry: DatasetRegistry) -> None: + registry.register_source("bucket", "s3://bucket/", {"anon": True}) + + assert registry.custom_sources["bucket"] == SourceEntry(url="s3://bucket/", storage_options={"anon": True}) + + +def test_custom_sources_override_builtins(registry: DatasetRegistry) -> None: + registry.register_source(_BUILTIN_SOURCE, "file:///override") + + assert registry.sources[_BUILTIN_SOURCE].url == "file:///override" + assert registry.builtin_sources[_BUILTIN_SOURCE].url != "file:///override" + + +def test_register_dataset_requires_a_known_source(registry: DatasetRegistry) -> None: + with pytest.raises(KeyError, match="Source 'ghost' not registered"): + registry.register_dataset("Toy", "ghost", "toy.h5mu") + + +def test_register_dataset_accepts_a_builtin_source(registry: DatasetRegistry) -> None: + registry.register_dataset("Toy", _BUILTIN_SOURCE, "toy.h5mu") + + assert registry.datasets["Toy"] == DatasetEntry(source=_BUILTIN_SOURCE, file="toy.h5mu") + + +def test_register_dataset_persists_the_entry(registry: DatasetRegistry, config_path: UPath) -> None: + registry.register_source("local", "file:///data") + registry.register_dataset("Toy", "local", "toy.h5mu") + + assert json.loads(config_path.read_text(encoding="utf-8"))["datasets"] == { + "Toy": {"source": "local", "file": "toy.h5mu"} + } + + +def test_unregister_dataset_removes_a_custom_entry(registry: DatasetRegistry) -> None: + registry.register_source("local", "file:///data") + registry.register_dataset("Toy", "local", "toy.h5mu") + + registry.unregister_dataset("Toy") + + assert registry.custom_datasets == {} + + +def test_unregister_dataset_rejects_an_unknown_name(registry: DatasetRegistry) -> None: + with pytest.raises(KeyError, match="Dataset 'Toy' not in custom registry"): + registry.unregister_dataset("Toy") + + +def test_unregister_dataset_refuses_a_builtin(registry: DatasetRegistry) -> None: + registry.register_dataset(_BUILTIN_DATASET, _BUILTIN_SOURCE, "override.h5mu") + + with pytest.raises(KeyError, match="is built-in and cannot be unregistered"): + registry.unregister_dataset(_BUILTIN_DATASET) + + +def test_unregister_source_removes_a_custom_entry(registry: DatasetRegistry) -> None: + registry.register_source("local", "file:///data") + + registry.unregister_source("local") + + assert registry.custom_sources == {} + + +def test_unregister_source_rejects_an_unknown_name(registry: DatasetRegistry) -> None: + with pytest.raises(KeyError, match="Source 'local' not in custom registry"): + registry.unregister_source("local") + + +def test_unregister_source_refuses_a_builtin(registry: DatasetRegistry) -> None: + registry.register_source(_BUILTIN_SOURCE, "file:///override") + + with pytest.raises(KeyError, match="is built-in and cannot be unregistered"): + registry.unregister_source(_BUILTIN_SOURCE) + + +def test_unregister_source_refuses_a_referenced_source(registry: DatasetRegistry) -> None: + registry.register_source("local", "file:///data") + registry.register_dataset("Toy", "local", "toy.h5mu") + + with pytest.raises(ValueError, match=r"still referenced by datasets \['Toy'\]"): + registry.unregister_source("local") + + +def test_reload_picks_up_external_edits(registry: DatasetRegistry, config_path: UPath) -> None: + assert registry.custom_datasets == {} + config_path.write_text( + json.dumps({"sources": {}, "datasets": {"External": {"source": _BUILTIN_SOURCE, "file": "e.h5mu"}}}), + encoding="utf-8", + ) + + registry.reload() + + assert "External" in registry.custom_datasets + + +def test_to_dataframe_labels_builtin_and_custom_origins(registry: DatasetRegistry) -> None: + registry.register_dataset("Toy", _BUILTIN_SOURCE, "toy.h5mu") + + frame = registry.to_dataframe() + + assert list(frame.columns) == ["Name", "Source", "File", "Origin"] + assert frame.set_index("Name").loc["Toy", "Origin"] == "custom" + assert frame.set_index("Name").loc[_BUILTIN_DATASET, "Origin"] == "built-in" + + +def test_repr_renders_without_an_index(registry: DatasetRegistry) -> None: + rendered = repr(registry) + + assert _BUILTIN_DATASET in rendered + assert not rendered.startswith("0") + + +def test_repr_html_emits_a_table(registry: DatasetRegistry) -> None: + assert "<table" in registry._repr_html_() + + +def test_module_singleton_exposes_the_builtin_datasets() -> None: + assert _BUILTIN_DATASET in dataset_registry.builtin_datasets + + +def test_module_list_delegates_to_the_singleton() -> None: + assert dataset_facade.list() == dataset_registry.dataset_names + + +def test_module_table_delegates_to_the_singleton() -> None: + assert list(dataset_facade.table().columns) == ["Name", "Source", "File", "Origin"] diff --git a/tests/registry/drug_featurizer/test_registration.py b/tests/registry/drug_featurizer/test_registration.py new file mode 100644 index 000000000..20f5be370 --- /dev/null +++ b/tests/registry/drug_featurizer/test_registration.py @@ -0,0 +1,96 @@ +"""Tests for public drug featurizer registration and lookup helpers.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.registry.drug_featurizer import ( + get as get_drug_featurizer, +) +from drevalpy.registry.drug_featurizer import ( + list as list_drug_featurizers, +) +from drevalpy.registry.drug_featurizer import ( + metadata as get_drug_featurizer_metadata, +) +from drevalpy.registry.drug_featurizer import ( + register as register_drug_featurizer, +) +from tests.registry._helpers import isolated_component_registries + + +@pytest.fixture(autouse=True) +def _clear_registries() -> Iterator[None]: + yield from isolated_component_registries() + + +def test_register_and_lookup_drug_featurizer() -> None: + @register_drug_featurizer( + "dummyDrug", + description="test drug", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class DummyDrug: + pass + + assert get_drug_featurizer("dummyDrug") is DummyDrug + assert vars(DummyDrug)["contract"] == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + assert "dummyDrug" in list_drug_featurizers() + + +def test_unknown_drug_component_fails() -> None: + with pytest.raises(ValueError, match="Unknown Drug featurizer"): + get_drug_featurizer("missing") + + +def test_get_drug_metadata_includes_output_format() -> None: + @register_drug_featurizer( + "graphDrug", + description="graph", + contract=FeatureFormat.GRAPH, + ) + class GraphDrug: + pass + + meta = get_drug_featurizer_metadata("graphDrug") + assert meta["output_format"] == "graph" + assert meta["description"] == "graph" + + +def test_the_decorator_contract_overrides_the_class_body() -> None: + @register_drug_featurizer( + "drugOverridden", + description="decorator wins", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class DrugOverridden: + contract = FeatureContract(format=FeatureFormat.GRAPH) + + assert vars(DrugOverridden)["contract"] == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + +def test_a_class_body_contract_is_a_valid_declaration() -> None: + @register_drug_featurizer("drugBodyContract", description="declares on the class body") + class DrugBodyContract: + contract = FeatureContract(format=FeatureFormat.GRAPH) + + assert get_drug_featurizer_metadata("drugBodyContract")["output_format"] == "graph" + + +def test_a_featurizer_declaring_no_contract_anywhere_is_rejected() -> None: + with pytest.raises(ValueError, match="no contract declared"): + + @register_drug_featurizer("noContractDrug", description="missing contract") + class NoContractDrug: + pass + + +def test_an_invalid_class_body_contract_is_rejected() -> None: + with pytest.raises(ValueError, match="class-body contract is invalid"): + + @register_drug_featurizer("badContractDrug", description="wrong type") + class BadContractDrug: + contract = "numeric_matrix_but_a_plain_string" diff --git a/tests/registry/drug_featurizer/test_registry.py b/tests/registry/drug_featurizer/test_registry.py new file mode 100644 index 000000000..969ae89ce --- /dev/null +++ b/tests/registry/drug_featurizer/test_registry.py @@ -0,0 +1,106 @@ +"""Tests for DrugFeaturizerRegistry type and singleton.""" + +from __future__ import annotations + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.registry.drug_featurizer import drug_featurizer_registry +from drevalpy.registry.drug_featurizer._registry import DrugFeaturizerRegistry +from drevalpy.registry.featurizer import FeaturizerRegistry + + +def test_drug_featurizer_registry_subclasses_the_shared_base() -> None: + assert issubclass(DrugFeaturizerRegistry, FeaturizerRegistry) + + +def test_drug_featurizer_registry_uses_fixed_identity() -> None: + registry = DrugFeaturizerRegistry() + + assert registry._registry_id == "drug_featurizer" + assert registry._label == "Drug featurizer" + assert registry._display_name == "drug_featurizers" + + +def test_drug_featurizer_registry_declares_the_drug_side() -> None: + assert DrugFeaturizerRegistry()._side == "drug" + + +def test_module_singleton_is_a_drug_featurizer_registry() -> None: + assert isinstance(drug_featurizer_registry, DrugFeaturizerRegistry) + + +def test_isolated_registry_registers_with_a_contract() -> None: + registry = DrugFeaturizerRegistry() + + @registry.register("localDrug", description="local", contract=FeatureFormat.GRAPH) + class LocalDrug: + pass + + assert registry.get("localDrug") is LocalDrug + assert vars(LocalDrug)["contract"] == FeatureContract(format=FeatureFormat.GRAPH) + + +def test_registration_stamps_the_drug_side_onto_the_class() -> None: + registry = DrugFeaturizerRegistry() + + @registry.register("sidedDrug", description="sided", contract=FeatureFormat.NUMERIC_MATRIX) + class SidedDrug: + pass + + assert SidedDrug.side == "drug" + + +def test_registration_keeps_an_explicit_storage_key() -> None: + registry = DrugFeaturizerRegistry() + + @registry.register("storedDrug", description="stored", contract=FeatureFormat.NUMERIC_MATRIX) + class StoredDrug: + storage_key = "shared_bucket" + + assert StoredDrug.storage_key == "shared_bucket" + + +def test_metadata_reports_the_drug_display_name() -> None: + registry = DrugFeaturizerRegistry() + + @registry.register("metaDrug", description="meta", contract=FeatureFormat.GRAPH) + class MetaDrug: + pass + + assert registry.get_metadata("metaDrug")["registry"] == "drug_featurizers" + + +def test_metadata_listing_covers_every_registered_featurizer() -> None: + registry = _registry_with_two_featurizers() + + assert {row["name"] for row in registry.list_metadata()} == {"coreDrug", "baselineDrug"} + + +def test_metadata_listing_filters_by_tag() -> None: + registry = _registry_with_two_featurizers() + + assert {row["name"] for row in registry.list_metadata(tag="baseline")} == {"baselineDrug"} + + +def test_metadata_listing_carries_the_registered_tags() -> None: + registry = _registry_with_two_featurizers() + + assert registry.list_metadata(tag="baseline")[0]["tags"] == frozenset({"baseline"}) + + +def _registry_with_two_featurizers() -> DrugFeaturizerRegistry: + registry = DrugFeaturizerRegistry() + + @registry.register("coreDrug", description="core", contract=FeatureFormat.NUMERIC_MATRIX) + class CoreDrug: + pass + + @registry.register( + "baselineDrug", + description="baseline", + tags=("baseline",), + contract=FeatureFormat.GRAPH, + ) + class BaselineDrug: + pass + + return registry diff --git a/tests/registry/drug_featurizer/test_validate.py b/tests/registry/drug_featurizer/test_validate.py new file mode 100644 index 000000000..ddac49e92 --- /dev/null +++ b/tests/registry/drug_featurizer/test_validate.py @@ -0,0 +1,20 @@ +"""Tests for the drug featurizer validation shim. + +``drevalpy/registry/drug_featurizer/_validate.py`` is a pure re-export of the shared +featurizer validation, so the only behaviour to pin is the identity of the +re-exported name. The validation itself is covered in +``tests/registry/featurizer/test_validate.py``. +""" + +from __future__ import annotations + +from drevalpy.registry.drug_featurizer import _validate +from drevalpy.registry.featurizer._validate import validate_featurizer_input_views + + +def test_re_exports_the_shared_validator() -> None: + assert _validate.validate_featurizer_input_views is validate_featurizer_input_views + + +def test_exports_only_the_shared_validator() -> None: + assert _validate.__all__ == ["validate_featurizer_input_views"] diff --git a/tests/registry/featurizer/test_base.py b/tests/registry/featurizer/test_base.py new file mode 100644 index 000000000..6a9029c6f --- /dev/null +++ b/tests/registry/featurizer/test_base.py @@ -0,0 +1,62 @@ +"""Tests for FeaturizerRegistry type and singletons.""" + +from __future__ import annotations + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.registry.cell_line_featurizer import ( + cell_line_featurizer_registry, +) +from drevalpy.registry.drug_featurizer import drug_featurizer_registry +from drevalpy.registry.featurizer import FeaturizerRegistry + + +def test_module_featurizer_singletons_use_fixed_identity() -> None: + assert cell_line_featurizer_registry._registry_id == "cell_line_featurizer" + assert drug_featurizer_registry._registry_id == "drug_featurizer" + assert cell_line_featurizer_registry._display_name == "cell_line_featurizers" + assert drug_featurizer_registry._display_name == "drug_featurizers" + + +def test_isolated_featurizer_registry_registers_with_contract() -> None: + registry = FeaturizerRegistry("test", "Test featurizer", "test_featurizers") + + @registry.register("localFeat", description="local", contract=FeatureFormat.GRAPH) + class LocalFeat: + pass + + assert registry.get("localFeat") is LocalFeat + assert vars(LocalFeat)["contract"] == FeatureContract(format=FeatureFormat.GRAPH) + + +def test_register_existing_restores_registry_name() -> None: + registry = FeaturizerRegistry("test", "Test component", "test_components") + decorated = registry.register( + "restored", + description="restored", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + + @decorated + class Restored: + pass + + assert vars(Restored)["registry_name"] == "restored" + registry.clear() + assert registry.list_names() == [] + + registry.register_existing("restored", Restored) + assert registry.get("restored") is Restored + assert vars(Restored)["registry_name"] == "restored" + + +def test_registry_clear() -> None: + registry = FeaturizerRegistry("test", "Test component", "test_components") + decorated = registry.register("x", description="x", contract=FeatureFormat.NUMERIC_MATRIX) + + @decorated + class X: + pass + + assert registry.list_names() == ["x"] + registry.clear() + assert registry.list_names() == [] diff --git a/tests/registry/featurizer/test_validate.py b/tests/registry/featurizer/test_validate.py new file mode 100644 index 000000000..43897a397 --- /dev/null +++ b/tests/registry/featurizer/test_validate.py @@ -0,0 +1,78 @@ +"""Tests for featurizer input-view declaration validation at registration time.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.registry.cell_line_featurizer import ( + get as get_cell_line_featurizer, +) +from drevalpy.registry.cell_line_featurizer import ( + register as register_cell_line_featurizer, +) +from drevalpy.types.data.batch.feature_block import numeric_feature_block +from tests.registry._helpers import isolated_component_registries + + +@pytest.fixture(autouse=True) +def _clear_registries() -> Iterator[None]: + yield from isolated_component_registries() + + +class _ConcreteCellLineFeaturizer(CellLineFeaturizer): + """Minimal concrete featurizer, so registration is not rejected as abstract.""" + + def _fit(self, source, *, entity_ids=None, pair_expanded_ids=None, pair_expanded_es_ids=None): + return self + + def _transform_blocks(self, source, entity_ids): + return {"probe": numeric_feature_block(np.zeros((len(entity_ids), 1), dtype=np.float32))} + + @property + def output_dim(self) -> int: + return 1 + + +def test_featurizer_registration_requires_declared_input_views() -> None: + with pytest.raises(ValueError, match="does not declare its input views"): + + @register_cell_line_featurizer( + "undeclaredViews", + description="no input views declared", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class UndeclaredViews(_ConcreteCellLineFeaturizer): + pass + + +def test_declared_input_views_allow_registration() -> None: + @register_cell_line_featurizer( + "declaredViews", + description="declares its input views", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class DeclaredViews(_ConcreteCellLineFeaturizer): + input_views = ("methylation",) + + assert get_cell_line_featurizer("declaredViews").resolve_input_views() == ("methylation",) + + +def test_a_featurizer_with_unimplemented_abstract_methods_is_rejected() -> None: + with pytest.raises(ValueError, match=r"does not implement _fit, _transform_blocks"): + + @register_cell_line_featurizer( + "abstractFeaturizer", + description="forgot the subclass hooks", + contract=FeatureFormat.NUMERIC_MATRIX, + ) + class AbstractFeaturizer(CellLineFeaturizer): + input_views = ("methylation",) + + @property + def output_dim(self) -> int: + return 0 diff --git a/tests/registry/predictor/test_metadata.py b/tests/registry/predictor/test_metadata.py new file mode 100644 index 000000000..37114deb6 --- /dev/null +++ b/tests/registry/predictor/test_metadata.py @@ -0,0 +1,56 @@ +"""Tests for the predictor catalog metadata builder. + +``drevalpy/registry/predictor/_metadata.py`` delegates to +``registry/components/_metadata.base_component_metadata`` and adds a single field, so +these tests pin the delegation and the added field. The shared base fields are +covered in ``tests/registry/components/test_metadata.py``. +""" + +from __future__ import annotations + +from drevalpy.registry.components._metadata import base_component_metadata +from drevalpy.registry.predictor._metadata import predictor_component_metadata + + +class _FeatureFreeLike: + description = "feature free predictor" + tags = frozenset({"baseline"}) + input_interface = "feature_free" + + +def test_metadata_adds_the_input_interface() -> None: + meta = predictor_component_metadata("predictors", "featureFree", _FeatureFreeLike) + + assert meta["input_interface"] == "feature_free" + + +def test_metadata_defaults_the_input_interface_to_empty() -> None: + class NoInterface: + description = "no declared interface" + + meta = predictor_component_metadata("predictors", "bare", NoInterface) + + assert meta["input_interface"] == "" + + +def test_metadata_is_the_base_metadata_plus_the_input_interface() -> None: + meta = predictor_component_metadata("predictors", "featureFree", _FeatureFreeLike) + + expected = base_component_metadata("predictors", "featureFree", _FeatureFreeLike) + expected["input_interface"] = "feature_free" + assert meta == expected + + +def test_metadata_omits_the_legacy_capability_fields() -> None: + meta = predictor_component_metadata("predictors", "featureFree", _FeatureFreeLike) + + for dropped in ( + "cell_line_format", + "drug_format", + "supported_modes", + "scope", + "supports_early_stopping", + "required_cell_line_views", + "required_drug_views", + ): + assert dropped not in meta diff --git a/tests/registry/predictor/test_registration.py b/tests/registry/predictor/test_registration.py new file mode 100644 index 000000000..ac7eec54a --- /dev/null +++ b/tests/registry/predictor/test_registration.py @@ -0,0 +1,121 @@ +"""Tests for public predictor registration and lookup helpers.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.registry.predictor import metadata as get_predictor_metadata +from drevalpy.registry.predictor import register as register_predictor +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from tests.registry._helpers import isolated_component_registries + + +@pytest.fixture(autouse=True) +def _clear_registries() -> Iterator[None]: + yield from isolated_component_registries() + + +class _ConcretePredictor(FeatureFreePredictor): + """Minimal concrete predictor, so registration is not rejected as abstract.""" + + def _fit(self, batch: ModelInputBatch) -> None: + return None + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.zeros(batch.n_pairs, dtype=np.float64) + + +def test_decorator_returns_original_class() -> None: + @register_predictor( + "dummyPred", + description="pred", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class DummyPred(_ConcretePredictor): + pass + + assert vars(DummyPred)["registry_name"] == "dummyPred" + assert vars(DummyPred)["cell_line_contract"] == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + assert vars(DummyPred)["drug_contract"] == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + +def test_predictor_metadata_facade_returns_catalog_metadata() -> None: + @register_predictor( + "catalogPred", + description="catalog shape", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class CatalogPred(_ConcretePredictor): + pass + + meta = get_predictor_metadata("catalogPred") + assert meta["registry"] == "predictors" + assert meta["name"] == "catalogPred" + assert meta["input_interface"] == "feature_free" + + +def test_class_body_contracts_are_accepted() -> None: + @register_predictor("bodyContractPred", description="declares on the class body") + class BodyContractPred(_ConcretePredictor): + cell_line_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + drug_contract = FeatureContract(format=FeatureFormat.GRAPH) + + assert BodyContractPred.cell_line_contract == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + assert BodyContractPred.drug_contract == FeatureContract(format=FeatureFormat.GRAPH) + + +def test_class_body_contracts_accept_the_format_shorthand() -> None: + @register_predictor("shorthandPred", description="format shorthand on the class body") + class ShorthandPred(_ConcretePredictor): + cell_line_contract = FeatureFormat.NUMERIC_MATRIX + drug_contract = FeatureFormat.RAGGED_SEQUENCE + + assert ShorthandPred.cell_line_contract == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + assert ShorthandPred.drug_contract == FeatureContract(format=FeatureFormat.RAGGED_SEQUENCE) + + +def test_an_invalid_class_body_contract_is_rejected() -> None: + with pytest.raises(TypeError, match="class-body cell_line_contract is invalid"): + + class BadBodyPred(_ConcretePredictor): + cell_line_contract = "numeric_matrix_but_a_plain_string" + + +def test_the_decorator_contract_overrides_the_class_body() -> None: + @register_predictor( + "overriddenPred", + description="decorator wins", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class OverriddenPred(_ConcretePredictor): + cell_line_contract = FeatureContract(format=FeatureFormat.GRAPH) + + assert OverriddenPred.cell_line_contract == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + +def test_a_predictor_declaring_no_contract_anywhere_is_rejected() -> None: + with pytest.raises(ValueError, match="no cell_line_contract declared"): + + @register_predictor("noContractPred", description="missing contracts") + class NoContractPred(_ConcretePredictor): + pass + + +def test_a_predictor_missing_only_the_drug_contract_is_rejected() -> None: + with pytest.raises(ValueError, match="no drug_contract declared"): + + @register_predictor( + "halfContractPred", + description="only the cell-line side", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class HalfContractPred(_ConcretePredictor): + pass diff --git a/tests/registry/predictor/test_registry.py b/tests/registry/predictor/test_registry.py new file mode 100644 index 000000000..35abf8410 --- /dev/null +++ b/tests/registry/predictor/test_registry.py @@ -0,0 +1,37 @@ +"""Tests for PredictorRegistry type and singleton.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.registry.predictor import PredictorRegistry, predictor_registry +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +def test_predictor_registry_uses_fixed_identity() -> None: + registry = PredictorRegistry() + assert registry._registry_id == "predictor" + assert predictor_registry._display_name == "predictors" + + +def test_isolated_predictor_registry_registers_with_contracts() -> None: + registry = PredictorRegistry() + + @registry.register( + "localPred", + description="local", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.GRAPH, + ) + class LocalPred(FeatureFreePredictor): + def _fit(self, batch: ModelInputBatch) -> None: + return None + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.zeros(batch.n_pairs, dtype=np.float64) + + assert registry.get("localPred") is LocalPred + assert vars(LocalPred)["cell_line_contract"] == FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + assert vars(LocalPred)["drug_contract"] == FeatureContract(format=FeatureFormat.GRAPH) diff --git a/tests/registry/predictor/test_validate.py b/tests/registry/predictor/test_validate.py new file mode 100644 index 000000000..668a24742 --- /dev/null +++ b/tests/registry/predictor/test_validate.py @@ -0,0 +1,205 @@ +"""Tests for predictor class invariants enforced at registration.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract, FeatureFormat +from drevalpy.components.predictors.abstract.block import BlockPredictor +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.models.config._predictor_traits import needs_identity_drug_routing +from drevalpy.registry.predictor import predictor_registry +from drevalpy.registry.predictor import register as register_predictor +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.types.enums.model_scope import ModelScope +from tests.registry._helpers import isolated_component_registries + + +@pytest.fixture(autouse=True) +def _clear_registries() -> Iterator[None]: + yield from isolated_component_registries() + + +class _ConcreteMatrix(MatrixPredictor): + """Minimal concrete matrix predictor.""" + + def _fit_matrix(self, x: np.ndarray, y: np.ndarray) -> None: + return None + + def _predict_matrix(self, x: np.ndarray) -> np.ndarray: + return np.zeros(len(x), dtype=np.float64) + + +class _ConcreteBlock(BlockPredictor): + """Minimal concrete block predictor.""" + + def _fit(self, batch: ModelInputBatch) -> None: + return None + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.zeros(batch.n_pairs, dtype=np.float64) + + +class _ConcreteFeatureFree(FeatureFreePredictor): + """Minimal concrete feature-free predictor.""" + + def _fit(self, batch: ModelInputBatch) -> None: + return None + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.zeros(batch.n_pairs, dtype=np.float64) + + +def test_predictor_must_inherit_exactly_one_leaf_interface() -> None: + with pytest.raises(ValueError, match="exactly one of"): + + @register_predictor( + "noLeafPred", + description="missing leaf", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class NoLeafPred: + pass + + +def test_matrix_predictor_requires_numeric_contracts() -> None: + with pytest.raises(ValueError, match="requires numeric_matrix cell_line contract"): + + @register_predictor( + "graphMatrixPred", + description="bad matrix", + cell_line_contract=FeatureFormat.GRAPH, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class GraphMatrixPred(_ConcreteMatrix): + pass + + +def test_single_drug_feature_predictor_needs_no_routing_declaration() -> None: + @register_predictor( + "plainSingleDrug", + description="declares only its scope", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class PlainSingleDrug(_ConcreteMatrix): + scope = ModelScope.SINGLE_DRUG + + assert predictor_registry.get("plainSingleDrug") is PlainSingleDrug + assert needs_identity_drug_routing("plainSingleDrug") is True + + +def test_feature_free_single_drug_skips_identity_routing() -> None: + @register_predictor( + "freeSingleDrug", + description="feature free single drug", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class FreeSingleDrug(_ConcreteFeatureFree): + scope = ModelScope.SINGLE_DRUG + + assert predictor_registry.get("freeSingleDrug") is FreeSingleDrug + assert needs_identity_drug_routing("freeSingleDrug") is False + + +def test_registered_predictor_scope_defaults_to_multi_drug() -> None: + @register_predictor( + "scopedPred", + description="scope default", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class ScopedPred(_ConcreteBlock): + pass + + assert ScopedPred.scope is ModelScope.MULTI_DRUG + + +def test_register_existing_rejects_invalid_predictor_class() -> None: + class InvalidRestored: + description = "invalid" + tags: frozenset[str] = frozenset() + reference = None + cell_line_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + drug_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + with pytest.raises(ValueError, match="exactly one of"): + predictor_registry.register_existing("invalidRestored", InvalidRestored) + + +def test_register_existing_restores_valid_predictor() -> None: + @register_predictor( + "restorablePred", + description="restorable", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class RestorablePred(_ConcreteBlock): + pass + + predictor_registry.clear() + assert predictor_registry.list_names() == [] + predictor_registry.register_existing("restorablePred", RestorablePred) + assert predictor_registry.get("restorablePred") is RestorablePred + + +# --------------------------------------------------------------------------- +# Abstract-member rejection +# --------------------------------------------------------------------------- + + +def test_a_predictor_missing_its_subclass_hooks_is_rejected() -> None: + with pytest.raises(ValueError, match=r"does not implement _fit, _predict"): + + @register_predictor( + "abstractPred", + description="forgot _fit and _predict", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class AbstractPred(BlockPredictor): + pass + + +def test_a_matrix_predictor_missing_its_matrix_hooks_is_rejected() -> None: + with pytest.raises(ValueError, match=r"does not implement _fit_matrix, _predict_matrix"): + + @register_predictor( + "abstractMatrixPred", + description="forgot the matrix hooks", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class AbstractMatrixPred(MatrixPredictor): + pass + + +def test_the_rejection_names_the_registry_and_the_name() -> None: + with pytest.raises(ValueError, match=r"predictor 'namedPred' \(NamedPred\)"): + + @register_predictor( + "namedPred", + description="names itself in the error", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, + ) + class NamedPred(BlockPredictor): + pass + + +def test_register_existing_also_rejects_an_abstract_class() -> None: + class AbstractRestored(BlockPredictor): + description = "abstract" + tags: frozenset[str] = frozenset() + reference = None + cell_line_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + drug_contract = FeatureContract(format=FeatureFormat.NUMERIC_MATRIX) + + with pytest.raises(ValueError, match="does not implement"): + predictor_registry.register_existing("abstractRestored", AbstractRestored) diff --git a/tests/registry/splitter/test_init.py b/tests/registry/splitter/test_init.py new file mode 100644 index 000000000..9bd7bb4e0 --- /dev/null +++ b/tests/registry/splitter/test_init.py @@ -0,0 +1,41 @@ +"""Tests for the :mod:`drevalpy.registry.splitter` package surface. + +The barrel is more than a re-export list: ``register``/``get``/``list``/``table``/ +``metadata`` are thin module-level facades over the ``splitter_registry`` +singleton, and the twenty-odd call sites in the package use the facade rather +than the singleton. Only the surface is asserted - the registry's own behaviour +belongs to ``test_registry.py`` beside it - and nothing here registers a mode, +so the process-global registry is left exactly as it was found. The assertions +themselves, forwarding included, live in ``tests/_barrel_surface.py``. + +``table()`` is deliberately not called: it materialises a ``pandas`` DataFrame, +and ``tests/test_import_cost_policy.py`` exists precisely because pandas is +expensive. Its presence and callability are what the barrel promises. +""" + +from __future__ import annotations + +from drevalpy.registry import splitter +from drevalpy.registry.splitter._registry import splitter_registry +from tests._barrel_surface import SingletonFacadeSurface + +#: Facade functions the barrel defines itself, with no module of their own. +FACADE_FUNCTIONS = ("get", "list", "metadata", "register", "table") + +#: ``re-exported name -> private module that defines it``. +EXPECTED_ORIGINS: dict[str, str] = { + "SplitValidationError": "drevalpy.registry.splitter._validation", + "Splitter": "drevalpy.registry.splitter._registry", + "SplitterRegistry": "drevalpy.registry.splitter._registry", + "Validation": "drevalpy.registry.splitter._validation", + "splitter_registry": "drevalpy.registry.splitter._registry", +} + + +class TestSplitterSurface(SingletonFacadeSurface): + barrel = splitter + origins = EXPECTED_ORIGINS + unpinned_names = FACADE_FUNCTIONS + callable_names = FACADE_FUNCTIONS + singleton = splitter_registry + keys_attribute = "modes" diff --git a/tests/registry/splitter/test_registry.py b/tests/registry/splitter/test_registry.py new file mode 100644 index 000000000..d3e72288f --- /dev/null +++ b/tests/registry/splitter/test_registry.py @@ -0,0 +1,334 @@ +"""Tests for :class:`~drevalpy.registry.splitter._registry.SplitterRegistry`. + +Registration mutates registry state, so every test registers into a locally +constructed ``SplitterRegistry``. The module singleton is only read, never +written, which keeps the built-in modes intact for the rest of the suite. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import numpy as np +import pytest + +from drevalpy.registry import splitter as splitter_facade +from drevalpy.registry.splitter._registry import SplitterRegistry, splitter_registry +from drevalpy.registry.splitter._validation import SplitValidationError +from drevalpy.types.data.split_mask import SplitMask +from drevalpy.types.data.split_masks import SplitMasks + +_SHAPE = (4, 3) +_BUILTIN_MODES = ("LCO", "LDO", "LPO", "LTO") + + +class _FakeMuDataset: + """Minimal ``MuDataLike`` stand-in with one tissue per cell line.""" + + @property + def cell_line_ids(self) -> np.ndarray: + """Row identifiers.""" + return np.array([f"CL_{i}" for i in range(_SHAPE[0])]) + + @property + def drug_ids(self) -> np.ndarray: + """Column identifiers.""" + return np.array([f"D_{i}" for i in range(_SHAPE[1])]) + + @property + def response_matrix(self) -> np.ndarray: + """Fully observed response matrix.""" + return np.ones(_SHAPE) + + def get_tissue(self, ids: np.ndarray) -> np.ndarray: + """One distinct tissue per cell line.""" + return np.array([f"tissue_{i}" for i in range(_SHAPE[0])]) + + def response_layer_names(self) -> list[str]: + """Names of the available response layers.""" + return ["relevance_score", "fold_change"] + + def get_response_layer(self, name: str) -> np.ndarray: + """Quality layers on which every curve passes the default thresholds.""" + return np.full(_SHAPE, 9.0 if name == "relevance_score" else -2.0) + + +def _mask(rows: tuple[int, ...]) -> SplitMask: + array = np.zeros(_SHAPE, dtype=bool) + array[list(rows), :] = True + return SplitMask(array) + + +def _lco_fold() -> SplitMasks: + return SplitMasks(train=_mask((0, 1)), test=_mask((2, 3)), val=SplitMask(np.zeros(_SHAPE, dtype=bool))) + + +def _leaking_fold() -> SplitMasks: + return SplitMasks(train=_mask((0, 1)), test=_mask((1, 2)), val=SplitMask(np.zeros(_SHAPE, dtype=bool))) + + +@pytest.fixture +def registry() -> SplitterRegistry: + return SplitterRegistry() + + +@pytest.fixture +def mudataset() -> _FakeMuDataset: + return _FakeMuDataset() + + +@pytest.fixture +def register_valid(registry: SplitterRegistry) -> Callable[..., Any]: + """Register a one-fold LCO splitter and return the wrapped callable.""" + + def _register(mode: str = "MY_LCO", description: str = "one clean fold"): + @registry.register(mode, description, validation="LCO") + def splitter(mudataset, n_splits=5, validation_ratio=0.1, random_state=42): + """Return a single leakage-free fold.""" + return [_lco_fold()] + + return splitter + + return _register + + +def test_a_new_registry_has_no_modes(registry: SplitterRegistry) -> None: + assert registry.modes == [] + + +def test_register_returns_the_wrapped_splitter(register_valid: Callable[..., Any]) -> None: + wrapped = register_valid() + + assert callable(wrapped) + + +def test_register_preserves_the_wrapped_function_name(register_valid: Callable[..., Any]) -> None: + wrapped = register_valid() + + assert wrapped.__name__ == "splitter" + + +def test_registered_mode_is_listed(registry: SplitterRegistry, register_valid: Callable[..., Any]) -> None: + register_valid() + + assert registry.modes == ["MY_LCO"] + + +def test_modes_are_sorted(registry: SplitterRegistry, register_valid: Callable[..., Any]) -> None: + register_valid(mode="ZZZ") + register_valid(mode="AAA") + + assert registry.modes == ["AAA", "ZZZ"] + + +def test_get_returns_the_wrapped_splitter(registry: SplitterRegistry, register_valid: Callable[..., Any]) -> None: + wrapped = register_valid() + + assert registry.get("MY_LCO") is wrapped + + +def test_get_rejects_an_unknown_mode(registry: SplitterRegistry) -> None: + with pytest.raises(ValueError, match=r"Unknown split mode 'nope'\. Registered: \[\]"): + registry.get("nope") + + +def test_re_registering_a_mode_is_rejected(registry: SplitterRegistry, register_valid: Callable[..., Any]) -> None: + register_valid(description="first") + + with pytest.raises(ValueError, match="Splitter mode 'MY_LCO' already registered"): + register_valid(description="second") + + +def test_the_rejected_re_registration_leaves_the_original( + registry: SplitterRegistry, register_valid: Callable[..., Any] +) -> None: + register_valid(description="first") + + with pytest.raises(ValueError): + register_valid(description="second") + + assert registry.describe("MY_LCO") == "first" + + +def test_override_replaces_an_existing_mode(registry: SplitterRegistry) -> None: + @registry.register("MY_LCO", "first", validation="LCO") + def first(mudataset, n_splits=5, validation_ratio=0.1, random_state=42): + """Return a single leakage-free fold.""" + return [_lco_fold()] + + @registry.register("MY_LCO", "second", validation="LCO", override=True) + def second(mudataset, n_splits=5, validation_ratio=0.1, random_state=42): + """Return a single leakage-free fold.""" + return [_lco_fold()] + + assert registry.describe("MY_LCO") == "second" + assert registry.get("MY_LCO") is second + + +def test_the_module_facade_forwards_override(monkeypatch: pytest.MonkeyPatch) -> None: + recorded: dict[str, Any] = {} + + def fake_register(mode, description, validation, *, override=False): + recorded.update(mode=mode, description=description, validation=validation, override=override) + return lambda fn: fn + + monkeypatch.setattr(splitter_registry, "register", fake_register) + + splitter_facade.register("X", "d", "LCO", override=True) + + assert recorded == {"mode": "X", "description": "d", "validation": "LCO", "override": True} + + +def test_describe_returns_the_registered_description( + registry: SplitterRegistry, register_valid: Callable[..., Any] +) -> None: + register_valid() + + assert registry.describe("MY_LCO") == "one clean fold" + + +def test_describe_of_an_unknown_mode_is_empty(registry: SplitterRegistry) -> None: + assert registry.describe("nope") == "" + + +def test_resolve_maps_a_mode_name_to_a_splitter(registry: SplitterRegistry, register_valid: Callable[..., Any]) -> None: + wrapped = register_valid() + + assert registry.resolve("MY_LCO") is wrapped + + +def test_resolve_passes_a_callable_through(registry: SplitterRegistry) -> None: + def splitter(mudataset, n_splits=5, validation_ratio=0.1, random_state=42): + return [] + + assert registry.resolve(splitter) is splitter # type: ignore[arg-type] + + +def test_the_wrapper_runs_validation(registry: SplitterRegistry, mudataset: _FakeMuDataset) -> None: + @registry.register("LEAKY", "leaks a cell line", validation="LCO") + def leaky(mudataset, n_splits=5, validation_ratio=0.1, random_state=42): + """Return a fold whose train and test share a cell line.""" + return [_leaking_fold()] + + with pytest.raises(SplitValidationError, match="LCO validation failed"): + leaky(mudataset) + + +def test_the_wrapper_injects_default_metadata(register_valid: Callable[..., Any], mudataset: _FakeMuDataset) -> None: + wrapped = register_valid() + + folds = wrapped(mudataset, n_splits=3, validation_ratio=0.2, random_state=7) + + assert folds[0].metadata == { + "mode": "MY_LCO", + "fold_index": 0, + "n_splits": 3, + "validation_ratio": 0.2, + "random_state": 7, + } + + +def test_the_wrapper_does_not_overwrite_splitter_metadata( + registry: SplitterRegistry, mudataset: _FakeMuDataset +) -> None: + @registry.register("ANNOTATED", "sets its own mode", validation="LCO") + def annotated(mudataset, n_splits=5, validation_ratio=0.1, random_state=42): + """Return a fold that already declares its mode.""" + fold = _lco_fold() + fold.metadata["mode"] = "custom" + return [fold] + + folds = annotated(mudataset) + + assert folds[0].metadata["mode"] == "custom" + + +def test_retain_only_drops_unlisted_modes(registry: SplitterRegistry, register_valid: Callable[..., Any]) -> None: + register_valid(mode="KEEP") + register_valid(mode="DROP") + + registry.retain_only(frozenset({"KEEP"})) + + assert registry.modes == ["KEEP"] + + +def test_retain_only_forgets_the_dropped_description( + registry: SplitterRegistry, register_valid: Callable[..., Any] +) -> None: + register_valid(mode="DROP") + + registry.retain_only(frozenset()) + + assert registry.describe("DROP") == "" + + +def test_to_dataframe_lists_mode_description_and_validation( + registry: SplitterRegistry, register_valid: Callable[..., Any] +) -> None: + register_valid() + + frame = registry.to_dataframe() + + assert list(frame.columns) == ["Mode", "Description", "Validation"] + assert frame.iloc[0].tolist() == ["MY_LCO", "one clean fold", "LCO"] + + +def test_repr_renders_without_an_index(registry: SplitterRegistry, register_valid: Callable[..., Any]) -> None: + register_valid() + + rendered = repr(registry) + + assert "MY_LCO" in rendered + assert not rendered.startswith("0") + + +def test_repr_html_emits_a_table(registry: SplitterRegistry, register_valid: Callable[..., Any]) -> None: + register_valid() + + assert "<table" in registry._repr_html_() + + +def test_the_singleton_holds_every_builtin_mode() -> None: + assert set(_BUILTIN_MODES).issubset(splitter_registry.modes) + + +def test_module_list_delegates_to_the_singleton() -> None: + assert splitter_facade.list() == splitter_registry.modes + + +def test_module_get_delegates_to_the_singleton() -> None: + assert splitter_facade.get("LPO") is splitter_registry.get("LPO") + + +def test_module_table_delegates_to_the_singleton() -> None: + assert list(splitter_facade.table().columns) == ["Mode", "Description", "Validation"] + + +def test_get_metadata_reports_the_registry_fields( + registry: SplitterRegistry, register_valid: Callable[..., Any] +) -> None: + register_valid() + + assert registry.get_metadata("MY_LCO") == { + "registry": "splitters", + "name": "MY_LCO", + "description": "one clean fold", + "validation": "LCO", + } + + +def test_get_metadata_rejects_an_unknown_mode(registry: SplitterRegistry) -> None: + with pytest.raises(ValueError, match="Unknown split mode 'nope'"): + registry.get_metadata("nope") + + +def test_list_metadata_covers_every_mode(registry: SplitterRegistry, register_valid: Callable[..., Any]) -> None: + register_valid(mode="ZZZ") + register_valid(mode="AAA") + + assert [row["name"] for row in registry.list_metadata()] == ["AAA", "ZZZ"] + + +def test_module_metadata_delegates_to_the_singleton() -> None: + assert splitter_facade.metadata("LPO") == splitter_registry.get_metadata("LPO") diff --git a/tests/registry/splitter/test_validation.py b/tests/registry/splitter/test_validation.py new file mode 100644 index 000000000..3c7d02866 --- /dev/null +++ b/tests/registry/splitter/test_validation.py @@ -0,0 +1,149 @@ +"""Tests for split validation: the leakage constraints enforced after every split. + +Masks are written out by hand rather than produced by a splitter, so each test +states exactly which leakage it does or does not contain. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.registry.splitter._validation import _VALIDATORS, SplitValidationError, validate_folds +from drevalpy.types.data.split_mask import SplitMask +from drevalpy.types.data.split_masks import SplitMasks + +_SHAPE = (4, 3) + + +class _FakeMuDataset: + """Minimal ``MuDataLike`` stand-in supplying cell-line ids and tissues.""" + + def __init__(self, tissues: tuple[str, ...]) -> None: + self._tissues = np.array(tissues) + + @property + def cell_line_ids(self) -> np.ndarray: + """Row identifiers, one per tissue label.""" + return np.array([f"CL_{i}" for i in range(len(self._tissues))]) + + @property + def drug_ids(self) -> np.ndarray: + """Column identifiers.""" + return np.array([f"D_{i}" for i in range(_SHAPE[1])]) + + @property + def response_matrix(self) -> np.ndarray: + """Fully observed response matrix.""" + return np.ones((len(self._tissues), _SHAPE[1])) + + def get_tissue(self, ids: np.ndarray) -> np.ndarray: + """Return the tissue label per requested cell-line id.""" + return self._tissues + + def response_layer_names(self) -> list[str]: + """Names of the available response layers.""" + return ["relevance_score", "fold_change"] + + def get_response_layer(self, name: str) -> np.ndarray: + """Quality layers on which every curve passes the default thresholds.""" + shape = (len(self._tissues), _SHAPE[1]) + return np.full(shape, 9.0 if name == "relevance_score" else -2.0) + + +def _mask(rows: tuple[int, ...], cols: tuple[int, ...]) -> SplitMask: + array = np.zeros(_SHAPE, dtype=bool) + for row in rows: + for col in cols: + array[row, col] = True + return SplitMask(array) + + +def _fold(train: SplitMask, test: SplitMask) -> SplitMasks: + return SplitMasks(train=train, test=test, val=SplitMask(np.zeros(_SHAPE, dtype=bool))) + + +@pytest.fixture +def mudataset() -> _FakeMuDataset: + return _FakeMuDataset(("lung", "lung", "skin", "skin")) + + +def test_split_validation_error_is_a_value_error() -> None: + assert issubclass(SplitValidationError, ValueError) + + +def test_every_declared_mode_has_a_validator() -> None: + assert set(_VALIDATORS) == {"LCO", "LDO", "LPO", "LTO"} + + +def test_unknown_validation_mode_is_rejected(mudataset: _FakeMuDataset) -> None: + fold = _fold(_mask((0,), (0,)), _mask((1,), (0,))) + + with pytest.raises(KeyError): + validate_folds([fold], "NOPE", mudataset) # type: ignore[arg-type] + + +def test_no_folds_is_vacuously_valid(mudataset: _FakeMuDataset) -> None: + validate_folds([], "LPO", mudataset) + + +@pytest.mark.parametrize( + ("mode", "train", "test"), + [ + pytest.param("LCO", _mask((0, 1), (0, 1, 2)), _mask((2, 3), (0, 1, 2)), id="lco-disjoint-rows"), + pytest.param("LDO", _mask((0, 1, 2, 3), (0,)), _mask((0, 1, 2, 3), (1, 2)), id="ldo-disjoint-cols"), + pytest.param("LPO", _mask((0, 1), (0,)), _mask((2, 3), (1,)), id="lpo-disjoint-pairs"), + pytest.param("LTO", _mask((0, 1), (0, 1, 2)), _mask((2, 3), (0, 1, 2)), id="lto-disjoint-tissues"), + ], +) +def test_valid_folds_pass(mode: str, train: SplitMask, test: SplitMask, mudataset: _FakeMuDataset) -> None: + validate_folds([_fold(train, test)], mode, mudataset) # type: ignore[arg-type] + + +def test_lco_rejects_a_cell_line_in_both_sides(mudataset: _FakeMuDataset) -> None: + fold = _fold(_mask((0, 1), (0,)), _mask((1, 2), (1,))) + + with pytest.raises(SplitValidationError, match="LCO validation failed"): + validate_folds([fold], "LCO", mudataset) + + +def test_ldo_rejects_a_drug_in_both_sides(mudataset: _FakeMuDataset) -> None: + fold = _fold(_mask((0,), (0, 1)), _mask((1,), (1, 2))) + + with pytest.raises(SplitValidationError, match="LDO validation failed"): + validate_folds([fold], "LDO", mudataset) + + +def test_lto_rejects_a_tissue_in_both_sides(mudataset: _FakeMuDataset) -> None: + fold = _fold(_mask((0,), (0, 1, 2)), _mask((1,), (0, 1, 2))) + + with pytest.raises(SplitValidationError, match="LTO validation failed"): + validate_folds([fold], "LTO", mudataset) + + +def test_lto_names_the_overlapping_tissue(mudataset: _FakeMuDataset) -> None: + fold = _fold(_mask((0,), (0,)), _mask((1,), (1,))) + + with pytest.raises(SplitValidationError, match=r"\['lung'\]"): + validate_folds([fold], "LTO", mudataset) + + +def test_lpo_rejects_a_pair_in_both_sides(mudataset: _FakeMuDataset) -> None: + fold = _fold(_mask((0,), (0, 1)), _mask((0,), (1, 2))) + + with pytest.raises(SplitValidationError, match="LPO validation failed"): + validate_folds([fold], "LPO", mudataset) + + +def test_lpo_tolerates_a_shared_row_and_column(mudataset: _FakeMuDataset) -> None: + fold = _fold(_mask((0,), (0,)), _mask((0,), (1,))) + + validate_folds([fold], "LPO", mudataset) + + +def test_the_failing_fold_index_is_reported(mudataset: _FakeMuDataset) -> None: + valid = _fold(_mask((0, 1), (0, 1, 2)), _mask((2, 3), (0, 1, 2))) + leaking = _fold(_mask((0,), (0,)), _mask((0,), (0,))) + + with pytest.raises(SplitValidationError, match=r"fold 1"): + validate_folds([valid, leaking], "LPO", mudataset) diff --git a/tests/registry/test_base.py b/tests/registry/test_base.py new file mode 100644 index 000000000..7be3c2f5c --- /dev/null +++ b/tests/registry/test_base.py @@ -0,0 +1,149 @@ +"""Tests for the shared :class:`~drevalpy.registry._base.Registry` ABC. + +Every test operates on a locally constructed registry, never on a module +singleton, so nothing here can leak an entry into the global stores that the +autouse ``_ensure_registries_populated`` fixture repopulates. +""" + +from __future__ import annotations + +from typing import Any + +import pandas as pd +import pytest + +from drevalpy.registry._base import Registry + + +class _ExampleRegistry(Registry): + """Minimal concrete registry exposing an ``add`` seam for tests.""" + + def __init__(self) -> None: + super().__init__("example", "Example component", "examples") + + def add(self, name: str, cls: type[Any]) -> None: + """Insert *cls* under *name* without any registration validation.""" + self._store[name] = cls + + def _component_metadata(self, name: str, cls: type[Any]) -> dict[str, Any]: + return { + "registry": self._display_name, + "name": name, + "description": getattr(cls, "description", ""), + "tags": getattr(cls, "tags", frozenset()), + } + + +class _Alpha: + description = "first component" + tags = frozenset({"baseline"}) + + +class _Beta: + description = "second component" + tags = frozenset({"omics", "baseline"}) + + +@pytest.fixture +def registry() -> _ExampleRegistry: + populated = _ExampleRegistry() + populated.add("alpha", _Alpha) + populated.add("beta", _Beta) + return populated + + +def test_registry_is_abstract() -> None: + with pytest.raises(TypeError, match="_component_metadata"): + Registry("example", "Example", "examples") # type: ignore[abstract] + + +def test_constructor_stores_identity() -> None: + registry = _ExampleRegistry() + + assert registry._registry_id == "example" + assert registry._label == "Example component" + assert registry._display_name == "examples" + assert registry.list_names() == [] + + +def test_get_returns_registered_class(registry: _ExampleRegistry) -> None: + assert registry.get("alpha") is _Alpha + + +def test_get_unknown_name_lists_available(registry: _ExampleRegistry) -> None: + with pytest.raises(ValueError, match=r"Unknown Example component: 'missing'\. Available: \['alpha', 'beta'\]"): + registry.get("missing") + + +def test_list_names_reflects_insertion_order(registry: _ExampleRegistry) -> None: + assert registry.list_names() == ["alpha", "beta"] + + +def test_get_metadata_delegates_to_component_metadata(registry: _ExampleRegistry) -> None: + assert registry.get_metadata("alpha") == { + "registry": "examples", + "name": "alpha", + "description": "first component", + "tags": frozenset({"baseline"}), + } + + +def test_list_metadata_returns_every_component(registry: _ExampleRegistry) -> None: + rows = registry.list_metadata() + + assert [row["name"] for row in rows] == ["alpha", "beta"] + + +def test_list_metadata_filters_by_tag(registry: _ExampleRegistry) -> None: + rows = registry.list_metadata(tag="omics") + + assert [row["name"] for row in rows] == ["beta"] + + +def test_list_metadata_strips_whitespace_from_tag(registry: _ExampleRegistry) -> None: + rows = registry.list_metadata(tag=" omics ") + + assert [row["name"] for row in rows] == ["beta"] + + +def test_clear_empties_the_store(registry: _ExampleRegistry) -> None: + registry.clear() + + assert registry.list_names() == [] + + +def test_retain_only_drops_unlisted_names(registry: _ExampleRegistry) -> None: + registry.retain_only(frozenset({"beta"})) + + assert registry.list_names() == ["beta"] + + +def test_retain_only_keeps_names_it_does_not_know(registry: _ExampleRegistry) -> None: + registry.retain_only(frozenset({"alpha", "beta", "never-registered"})) + + assert registry.list_names() == ["alpha", "beta"] + + +def test_to_dataframe_renders_name_description_and_sorted_tags(registry: _ExampleRegistry) -> None: + frame = registry.to_dataframe() + + assert list(frame.columns) == ["Name", "Description", "Tags"] + pd.testing.assert_series_equal( + frame["Tags"], + pd.Series(["baseline", "baseline, omics"], name="Tags"), + ) + + +def test_to_dataframe_of_empty_registry_has_no_rows() -> None: + assert _ExampleRegistry().to_dataframe().empty + + +def test_repr_is_the_dataframe_without_the_index(registry: _ExampleRegistry) -> None: + rendered = repr(registry) + + assert "alpha" in rendered + assert not rendered.startswith("0") + + +def test_repr_html_emits_a_table(registry: _ExampleRegistry) -> None: + assert "<table" in registry._repr_html_() diff --git a/tests/registry/test_builtins.py b/tests/registry/test_builtins.py new file mode 100644 index 000000000..7f10faad0 --- /dev/null +++ b/tests/registry/test_builtins.py @@ -0,0 +1,145 @@ +"""Tests that built-in component registration declares explicit contracts and metadata.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from drevalpy.components.contracts.contracts import FeatureContract +from drevalpy.registry._builtins import _registry_for, ensure_predictor_registered +from drevalpy.registry.cell_line_featurizer import ( + get as get_cell_line_featurizer, +) +from drevalpy.registry.cell_line_featurizer import ( + list as list_cell_line_featurizers, +) +from drevalpy.registry.drug_featurizer import ( + get as get_drug_featurizer, +) +from drevalpy.registry.drug_featurizer import ( + list as list_drug_featurizers, +) +from drevalpy.registry.predictor import get as get_predictor +from tests._trusted_subprocess import run_trusted_python +from tests.registry._helpers import restore_component_registries + + +@pytest.fixture(autouse=True) +def _register_components() -> Iterator[None]: + restore_component_registries() + yield + restore_component_registries() + + +def test_builtin_cell_line_featurizers_declare_contract() -> None: + for name in list_cell_line_featurizers(): + cls = get_cell_line_featurizer(name) + assert "contract" in cls.__dict__, name + assert isinstance(cls.contract, FeatureContract) + + +def test_builtin_drug_featurizers_declare_contract() -> None: + for name in list_drug_featurizers(): + cls = get_drug_featurizer(name) + assert "contract" in cls.__dict__, name + assert isinstance(cls.contract, FeatureContract) + + +def test_bpe_pharmaformer_has_literature_reference() -> None: + from drevalpy.registry.drug_featurizer import metadata as get_drug_featurizer_metadata + + meta = get_drug_featurizer_metadata("bpePharmaformer") + assert meta["repo_url"] == "https://github.com/zhouyuru1205/PharmaFormer" + assert meta["citation_doi"] == "10.1038/s41698-025-01082-6" + assert meta["citation"].startswith("https://doi.org/10.1038/") + assert meta["deviations"] + + +class TestDiscoveryInAFreshProcess: + """Extended tier: spawns an interpreter to prove registration needs no test setup. + + A class so the marker sits at class level; the in-process registration tests in + this file stay in the fast tier. + """ + + pytestmark = pytest.mark.slow + + def test_fresh_process_discovery_returns_all_builtins(self) -> None: + script = """ +from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry +from drevalpy.registry.drug_featurizer import drug_featurizer_registry +from drevalpy.registry.predictor import predictor_registry +assert len(cell_line_featurizer_registry.list_metadata()) == 17 +assert len(drug_featurizer_registry.list_metadata()) == 10 +assert len(predictor_registry.list_metadata()) == 27 +print("ok") +""" + completed = run_trusted_python(script) + assert completed.returncode == 0, completed.stderr + assert "ok" in completed.stdout + + +def test_literature_predictors_register_from_split_modules() -> None: + for name in ("precily", "srmf", "molir", "superfeltr", "pharmaFormer", "dipk", "sparsego"): + ensure_predictor_registered(name) + cls = get_predictor(name) + assert cls.registry_name == name + + +def test_naive_predictors_register_from_package() -> None: + for name in ("naiveMean", "naiveDrugMean", "naiveMeanEffects"): + ensure_predictor_registered(name) + cls = get_predictor(name) + assert cls.registry_name == name + + +class TestRegistryDispatchOnRescan: + """``_registry_for`` recovers a class's registry from what registration stamped on it. + + This is the dispatch ``_reregister_from_module`` runs for every class in an + already-imported module, which is how the registries refill after a ``clear()``. + """ + + def test_a_cell_line_featurizer_goes_back_to_the_cell_line_registry(self) -> None: + from drevalpy.registry.cell_line_featurizer._registry import cell_line_featurizer_registry + + cls = get_cell_line_featurizer(list_cell_line_featurizers()[0]) + + assert _registry_for(cls) is cell_line_featurizer_registry + + def test_a_drug_featurizer_goes_back_to_the_drug_registry(self) -> None: + from drevalpy.registry.drug_featurizer._registry import drug_featurizer_registry + + cls = get_drug_featurizer(list_drug_featurizers()[0]) + + assert _registry_for(cls) is drug_featurizer_registry + + def test_a_predictor_goes_back_to_the_predictor_registry(self) -> None: + from drevalpy.registry.predictor._registry import predictor_registry + + assert _registry_for(get_predictor("naiveMean")) is predictor_registry + + def test_the_side_takes_precedence_over_a_predictor_contract(self) -> None: + """A class carrying both is a featurizer: ``side`` is only ever stamped by a featurizer registry.""" + from drevalpy.registry.drug_featurizer._registry import drug_featurizer_registry + + class Ambiguous: + side = "drug" + cell_line_contract = object() + + assert _registry_for(Ambiguous) is drug_featurizer_registry + + def test_a_sideless_featurizer_falls_back_to_the_cell_line_registry(self) -> None: + from drevalpy.registry.cell_line_featurizer._registry import cell_line_featurizer_registry + + class Sideless: + contract = object() + + assert _registry_for(Sideless) is cell_line_featurizer_registry + + def test_a_plain_class_belongs_to_no_registry(self) -> None: + class NotAComponent: + pass + + assert _registry_for(NotAComponent) is None diff --git a/tests/registry/test_extensions.py b/tests/registry/test_extensions.py new file mode 100644 index 000000000..1e27e1269 --- /dev/null +++ b/tests/registry/test_extensions.py @@ -0,0 +1,444 @@ +"""Tests for external component and zoo loading.""" + +from __future__ import annotations + +import hashlib +import sys +import textwrap +from collections.abc import Iterator +from pathlib import Path + +import numpy as np +import pytest + +from drevalpy.models import construct_model +from drevalpy.models.config import ModelConfig +from drevalpy.models.zoo import clear_external_zoo, get_zoo_config, list_zoo_names, load_external_zoo_file +from drevalpy.registry._builtins import is_known_builtin_predictor +from drevalpy.registry._extensions import ( + _extension_module_name, + load_extension_dir, + load_extension_file, + load_extensions, +) +from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry +from drevalpy.registry.cell_line_featurizer import ( + get as get_cell_line_featurizer, +) +from drevalpy.registry.cell_line_featurizer import ( + list as list_cell_line_featurizers, +) +from drevalpy.registry.predictor import ( + get as get_predictor, +) +from drevalpy.registry.predictor import ( + list as list_predictors, +) +from drevalpy.registry.predictor import predictor_registry +from tests._trusted_subprocess import run_trusted_python +from tests.registry._helpers import restore_component_registries + + +@pytest.fixture(autouse=True) +def _restore_registries() -> Iterator[None]: + """Evict whatever a test in this file registered into the process-global state. + + Nearly every test here loads an extension, and loading an extension registers + into the component registries and the external zoo for the whole process. + ``tests/conftest.py`` repopulates the built-ins before each test but + ``register_builtin_components`` only ever *adds* - it does not evict - so + without this teardown the extensions outlive their tests. The + ``BUILTIN_*_NAMES`` sets in :mod:`drevalpy.registry._builtins` are lazy + singletons that cache on first access, so whichever test resolves them next + counts a leaked extension as a built-in, and + ``tests/docs/test_docs_structure.py`` fails whenever it happens to run after + this file rather than before it. Mirrors the fixture in + ``tests/models/config/test_spec.py``. + """ + yield + restore_component_registries() + clear_external_zoo() + + +def test_load_extension_file_registers_components(tmp_path: Path) -> None: + ext_file = tmp_path / "toy_extension.py" + ext_file.write_text( + """ +from __future__ import annotations + +import numpy as np + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.types.data.batch.feature_block import numeric_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.registry.predictor import register +from drevalpy.registry.cell_line_featurizer import register as register_cell_line_featurizer + + +@register_cell_line_featurizer( + "toyCellLine", + description="Toy featurizer", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class ToyCellLineFeaturizer(CellLineFeaturizer): + entity_id_only = True + + def _fit(self, source, *, entity_ids=None, pair_expanded_ids=None, pair_expanded_es_ids=None): + self._output_dim = 1 + return self + + def _transform_blocks(self, source, entity_ids): + values = np.ones((len(entity_ids), 1), dtype=np.float32) + return {"toy": numeric_feature_block(values)} + + @property + def output_dim(self): + return self._output_dim + + +@register( + "toyPredictor", + description="Toy predictor", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class ToyPredictor(FeatureFreePredictor): + def _fit(self, batch: ModelInputBatch) -> None: + return None + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.zeros(batch.n_pairs, dtype=np.float64) +""", + encoding="utf-8", + ) + before = set(list_cell_line_featurizers()) + load_extension_file(ext_file) + assert "toyCellLine" in list_cell_line_featurizers() + assert "toyCellLine" not in before + get_cell_line_featurizer("toyCellLine") + get_predictor("toyPredictor") + + +def test_load_extension_dir_imports_sorted_files(tmp_path: Path) -> None: + (tmp_path / "b_ext.py").write_text( + "from drevalpy.registry.predictor import register as register_predictor\n" + "from drevalpy.components.contracts.contracts import FeatureFormat\n" + "from drevalpy.types.data.batch.model_input_batch import ModelInputBatch\n" + "from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor\n" + "import numpy as np\n" + "@register_predictor('toyB', description='b',\n" + " cell_line_contract=FeatureFormat.NUMERIC_MATRIX,\n" + " drug_contract=FeatureFormat.NUMERIC_MATRIX)\n" + "class ToyB(FeatureFreePredictor):\n" + " def _fit(self, batch: ModelInputBatch): return None\n" + " def _predict(self, batch: ModelInputBatch): return np.zeros(batch.n_pairs)\n", + encoding="utf-8", + ) + (tmp_path / "a_ext.py").write_text( + "from drevalpy.registry.predictor import register as register_predictor\n" + "from drevalpy.components.contracts.contracts import FeatureFormat\n" + "from drevalpy.types.data.batch.model_input_batch import ModelInputBatch\n" + "from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor\n" + "import numpy as np\n" + "@register_predictor('toyA', description='a',\n" + " cell_line_contract=FeatureFormat.NUMERIC_MATRIX,\n" + " drug_contract=FeatureFormat.NUMERIC_MATRIX)\n" + "class ToyA(FeatureFreePredictor):\n" + " def _fit(self, batch: ModelInputBatch): return None\n" + " def _predict(self, batch: ModelInputBatch): return np.zeros(batch.n_pairs)\n", + encoding="utf-8", + ) + load_extension_dir(tmp_path) + get_predictor("toyA") + get_predictor("toyB") + + +def test_external_zoo_references_extension_components(tmp_path: Path) -> None: + ext_dir = tmp_path / "ext" + ext_dir.mkdir() + (ext_dir / "components.py").write_text( + """ +import numpy as np +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.types.data.batch.feature_block import numeric_feature_block +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +from drevalpy.registry.predictor import register as register_predictor +from drevalpy.registry.cell_line_featurizer import register as register_cell_line_featurizer + +@register_cell_line_featurizer( + "externalCellLine", + description="ext", + contract=FeatureFormat.NUMERIC_MATRIX, +) +class ExternalCellLineFeaturizer(CellLineFeaturizer): + entity_id_only = True + + def _fit(self, source, *, entity_ids=None, pair_expanded_ids=None, pair_expanded_es_ids=None): + self._output_dim = 1 + return self + def _transform_blocks(self, source, entity_ids): + return {"ext": numeric_feature_block(np.ones((len(entity_ids), 1), dtype=np.float32))} + @property + def output_dim(self): + return self._output_dim + +@register_predictor( + "externalPredictor", + description="ext", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class ExternalPredictor(FeatureFreePredictor): + def _fit(self, batch: ModelInputBatch) -> None: + if batch.response is None: + msg = "response required" + raise ValueError(msg) + self._mean = float(np.mean(batch.response)) + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.full(batch.n_pairs, self._mean, dtype=np.float64) + + def get_state(self) -> dict[str, object]: + if not hasattr(self, "_mean"): + return {} + return {"mean": self._mean} + + def set_state(self, state: dict[str, object]) -> None: + if "mean" in state: + self._mean = float(state["mean"]) + + def is_fitted(self) -> bool: + return hasattr(self, "_mean") +""", + encoding="utf-8", + ) + zoo_file = tmp_path / "external_zoo.yaml" + zoo_file.write_text( + """ +externalToy: + predictor: externalPredictor +""", + encoding="utf-8", + ) + load_extensions(directories=[ext_dir], zoo_files=[zoo_file]) + assert "externalToy" in list_zoo_names(include_external=True) + config = get_zoo_config("externalToy") + model = construct_model("externalToy", config)() + + import anndata as ad + import mudata as md + import pandas as pd + + from drevalpy.types import SplitMask, SplitMasks + from drevalpy.types.data.dataset import Dataset + + cl_ids = np.array(["cl1", "cl2"]) + drug_ids = np.array(["d1", "d2"]) + response_matrix = np.array([[1.0, 1.0], [3.0, 3.0]], dtype=np.float32) + response_ad = ad.AnnData( + X=response_matrix, + obs=pd.DataFrame({"cell_line_name": cl_ids, "tissue": ["L", "B"]}, index=cl_ids), + var=pd.DataFrame(index=drug_ids), + ) + mudataset = Dataset(md.MuData({"response": response_ad}), name="test") + split = SplitMasks( + train=SplitMask(np.array([[True, True], [False, False]])), + test=SplitMask(np.array([[False, False], [True, True]])), + val=SplitMask(np.zeros((2, 2), dtype=bool)), + ) + model.train(mudataset, split) + preds = model.predict(mudataset, split) + assert np.isfinite(preds).all() + + +def test_load_external_zoo_single_entry_file(tmp_path: Path) -> None: + zoo_file = tmp_path / "single.yaml" + zoo_file.write_text( + """ +name: customNaive +predictor: naiveMean +""", + encoding="utf-8", + ) + names = load_external_zoo_file(zoo_file) + assert names == ["customNaive"] + config = get_zoo_config("customNaive") + assert isinstance(config, ModelConfig) + assert config.predictor.name == "naiveMean" + + +def test_load_external_zoo_invalid_entry_reports_path(tmp_path: Path) -> None: + zoo_file = tmp_path / "bad_zoo.yaml" + zoo_file.write_text( + """ +brokenEntry: + predictor: naiveMean + unknown_key: true +""", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="brokenEntry"): + load_external_zoo_file(zoo_file) + + +def test_extension_module_name_uses_stable_path_digest(tmp_path: Path) -> None: + ext_file = tmp_path / "stable.py" + ext_file.write_text("# noop\n", encoding="utf-8") + resolved = ext_file.resolve() + expected_digest = hashlib.sha256(str(resolved).encode()).hexdigest()[:16] + assert _extension_module_name(resolved) == f"drevalpy_user_extension_stable_{expected_digest}" + + +def test_failed_extension_file_does_not_leave_sys_modules_or_registry_mutation(tmp_path: Path) -> None: + ext_file = tmp_path / "broken_extension.py" + ext_file.write_text( + """ +from drevalpy.registry.predictor import register as register_predictor +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +import numpy as np + +@register_predictor( + "brokenPartial", + description="partial", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class BrokenPartial(FeatureFreePredictor): + def _fit(self, batch: ModelInputBatch) -> None: + return None + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.zeros(batch.n_pairs) + +raise RuntimeError("fail after partial registration") +""", + encoding="utf-8", + ) + module_name = _extension_module_name(ext_file.resolve()) + before_predictors = set(list_predictors()) + with pytest.raises(ImportError, match="broken_extension.py"): + load_extension_file(ext_file) + assert module_name not in sys.modules + assert set(list_predictors()) == before_predictors + + +def test_is_known_builtin_predictor_public_query() -> None: + assert is_known_builtin_predictor("elasticNet") + assert not is_known_builtin_predictor("notARealPredictor") + + +#: Installs a meta-path finder that turns any import of an optional predictor +#: family into an ``ImportError``, so a code path that reaches for one fails +#: loudly instead of silently pulling in xgboost, lightgbm or dipk. +#: +#: The two tests below share this preamble but deliberately **not** a process. +#: Each asserts that a particular pristine entry point - loading a user extension +#: file, and looking up a native component - stays clear of the optional +#: families, and a shared interpreter would let the first one's import graph +#: answer for the second. That is why both are in the extended tier rather than +#: merged into one child: two interpreter spawns are the test. +_BLOCK_OPTIONAL_FAMILIES = textwrap.dedent(""" + import importlib.abc + import importlib.machinery + import sys + + blocked = { + "xgboost": "blocked xgboost", + "lightgbm": "blocked lightgbm", + "drevalpy.components.predictors.literature.dipk.predictor": "blocked dipk", + } + + class BlockLoader(importlib.abc.Loader): + def __init__(self, message: str) -> None: + self.message = message + + def create_module(self, spec): + raise ImportError(self.message) + + def exec_module(self, module): + raise ImportError(self.message) + + class BlockFinder(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path, target=None): + if fullname in blocked: + return importlib.machinery.ModuleSpec(fullname, BlockLoader(blocked[fullname])) + return None + + sys.meta_path.insert(0, BlockFinder()) + """) + + +class TestSubprocessImportIsolation: + """Extended tier: each test spawns a fresh interpreter (~2s each). + + They are not merged into one child process on purpose - see the comment on + ``_BLOCK_OPTIONAL_FAMILIES`` above. The class exists so the marker applies at + class level while the other tests in this file stay in the fast tier. + """ + + pytestmark = pytest.mark.slow + + def test_subprocess_extension_load_does_not_import_optional_families(self, tmp_path: Path) -> None: + ext_file = tmp_path / "isolated_extension.py" + ext_file.write_text( + """ +from drevalpy.registry.predictor import register as register_predictor +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch +from drevalpy.components.predictors.abstract.feature_free import FeatureFreePredictor +import numpy as np + +@register_predictor( + "isolatedPredictor", + description="isolated", + cell_line_contract=FeatureFormat.NUMERIC_MATRIX, + drug_contract=FeatureFormat.NUMERIC_MATRIX, +) +class IsolatedPredictor(FeatureFreePredictor): + def _fit(self, batch: ModelInputBatch) -> None: + return None + + def _predict(self, batch: ModelInputBatch) -> np.ndarray: + return np.zeros(batch.n_pairs, dtype=np.float64) +""", + encoding="utf-8", + ) + script = _BLOCK_OPTIONAL_FAMILIES + textwrap.dedent(f""" + from drevalpy.registry._extensions import load_extension_file + from drevalpy.registry.predictor import get as get_predictor + + load_extension_file({str(ext_file)!r}) + cls = get_predictor("isolatedPredictor") + assert cls.__name__ == "IsolatedPredictor" + """) + completed = run_trusted_python(script) + assert completed.returncode == 0, completed.stdout + completed.stderr + + def test_subprocess_native_lookup_does_not_import_optional_families(self) -> None: + script = _BLOCK_OPTIONAL_FAMILIES + textwrap.dedent(""" + from drevalpy.registry.cell_line_featurizer import get as get_cell_line_featurizer + from drevalpy.registry.predictor import get as get_predictor + + get_cell_line_featurizer("identity") + get_predictor("elasticNet") + """) + completed = run_trusted_python(script) + assert completed.returncode == 0, completed.stdout + completed.stderr + + +def test_unknown_builtin_predictor_raises_value_error() -> None: + predictor_registry.clear() + with pytest.raises(ValueError, match="Unknown Predictor"): + get_predictor("notRegisteredAnywhere") + + +def test_unknown_builtin_featurizer_raises_value_error() -> None: + cell_line_featurizer_registry.clear() + with pytest.raises(ValueError, match="Unknown Cell line featurizer"): + get_cell_line_featurizer("notRegisteredAnywhere") diff --git a/tests/registry/test_plugins.py b/tests/registry/test_plugins.py new file mode 100644 index 000000000..f63983a6b --- /dev/null +++ b/tests/registry/test_plugins.py @@ -0,0 +1,370 @@ +"""Tests for entry-point plugin discovery. + +``discover_plugins`` is called at import time from ``drevalpy/registry/__init__.py``, +so its module-level ``_discovered`` latch is already ``True`` by the time any test +runs. Every test here resets the latch through ``monkeypatch``, which restores the +original value on teardown, so the latch is left exactly as the rest of the suite +found it. The failure/success ledgers are process-global for the same reason and are +patched the same way. +""" + +from __future__ import annotations + +import importlib.metadata +import logging +from typing import Any + +import pytest + +from drevalpy.registry import _plugins + +_GROUP = "drevalpy.plugins" + + +class _StubEntryPoint: + """Stand-in for :class:`importlib.metadata.EntryPoint` recording ``load`` calls.""" + + def __init__(self, name: str, error: Exception | None = None, value: str = "") -> None: + self.name = name + self.value = value or f"{name}_pkg:register" + self.loaded = False + self._error = error + + def load(self) -> Any: + """Record the call and optionally fail the way a broken plugin would.""" + self.loaded = True + if self._error is not None: + raise self._error + return object() + + +class _EntryPointRecorder: + """Replacement for ``importlib.metadata.entry_points`` that records its kwargs.""" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + self.entry_points: list[_StubEntryPoint] = [] + + def __call__(self, **kwargs: Any) -> list[_StubEntryPoint]: + """Record the query and return the entry points the test installed.""" + self.calls.append(kwargs) + return self.entry_points + + +@pytest.fixture +def unlatched(monkeypatch: pytest.MonkeyPatch) -> None: + """Reset the one-shot discovery latch for the duration of a single test.""" + monkeypatch.setattr(_plugins, "_discovered", False) + + +@pytest.fixture +def ledgers(monkeypatch: pytest.MonkeyPatch) -> None: + """Give the test empty loaded/failed ledgers, restored on teardown.""" + monkeypatch.setattr(_plugins, "_LOADED_PLUGINS", {}) + monkeypatch.setattr(_plugins, "_FAILED_PLUGINS", {}) + + +@pytest.fixture +def entry_points(monkeypatch: pytest.MonkeyPatch) -> _EntryPointRecorder: + """Install a recording stub over the ``importlib.metadata`` boundary.""" + recorder = _EntryPointRecorder() + monkeypatch.setattr(importlib.metadata, "entry_points", recorder) + return recorder + + +@pytest.fixture +def lenient(monkeypatch: pytest.MonkeyPatch) -> None: + """Guarantee non-strict behaviour regardless of the ambient environment.""" + monkeypatch.delenv(_plugins.STRICT_ENV_VAR, raising=False) + + +def test_import_time_discovery_has_already_run() -> None: + assert _plugins._discovered is True + + +def test_discovery_queries_only_the_drevalpy_plugin_group(unlatched: None, entry_points: _EntryPointRecorder) -> None: + _plugins.discover_plugins() + + assert entry_points.calls == [{"group": _GROUP}] + + +def test_discovery_loads_every_entry_point(unlatched: None, entry_points: _EntryPointRecorder) -> None: + entry_points.entry_points.extend([_StubEntryPoint("first"), _StubEntryPoint("second")]) + + _plugins.discover_plugins() + + assert [ep.loaded for ep in entry_points.entry_points] == [True, True] + + +def test_discovery_sets_the_latch(unlatched: None, entry_points: _EntryPointRecorder) -> None: + _plugins.discover_plugins() + + assert _plugins._discovered is True + + +def test_second_call_is_a_no_op(unlatched: None, entry_points: _EntryPointRecorder) -> None: + _plugins.discover_plugins() + _plugins.discover_plugins() + + assert len(entry_points.calls) == 1 + + +def test_latched_call_does_not_query_entry_points(entry_points: _EntryPointRecorder) -> None: + _plugins.discover_plugins() + + assert entry_points.calls == [] + + +def test_a_failing_plugin_does_not_abort_discovery( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, +) -> None: + entry_points.entry_points.extend( + [ + _StubEntryPoint("broken", error=RuntimeError("boom")), + _StubEntryPoint("healthy"), + ] + ) + + _plugins.discover_plugins() + + assert entry_points.entry_points[1].loaded is True + + +def test_a_failing_plugin_is_reported_as_a_warning( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, + caplog: pytest.LogCaptureFixture, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("broken", error=RuntimeError("boom"))) + + with caplog.at_level(logging.WARNING, logger=_plugins.__name__): + _plugins.discover_plugins() + + assert "Failed to load drevalpy plugin 'broken'" in caplog.text + + +def test_the_warning_points_at_the_failure_ledger( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, + caplog: pytest.LogCaptureFixture, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("broken", error=RuntimeError("boom"))) + + with caplog.at_level(logging.WARNING, logger=_plugins.__name__): + _plugins.discover_plugins() + + assert "get_failed_plugins()" in caplog.text + assert _plugins.STRICT_ENV_VAR in caplog.text + + +# --------------------------------------------------------------------------- +# Failure ledger +# --------------------------------------------------------------------------- + + +def test_a_failing_plugin_is_recorded( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("broken", error=RuntimeError("boom"))) + + _plugins.discover_plugins() + + assert list(_plugins.get_failed_plugins()) == ["broken"] + + +def test_the_recorded_failure_is_the_traceback( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("broken", error=RuntimeError("boom"))) + + _plugins.discover_plugins() + + assert "RuntimeError: boom" in _plugins.get_failed_plugins()["broken"] + + +def test_get_failed_plugins_returns_a_copy( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("broken", error=RuntimeError("boom"))) + _plugins.discover_plugins() + + _plugins.get_failed_plugins().clear() + + assert list(_plugins.get_failed_plugins()) == ["broken"] + + +def test_a_healthy_plugin_is_not_recorded_as_failed( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("healthy")) + + _plugins.discover_plugins() + + assert _plugins.get_failed_plugins() == {} + + +def test_a_healthy_plugin_is_recorded_with_its_entry_point_value( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("healthy", value="my_pkg.plugin:setup")) + + _plugins.discover_plugins() + + assert _plugins.get_loaded_plugins() == {"healthy": "my_pkg.plugin:setup"} + + +def test_get_loaded_plugins_returns_a_copy( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("healthy")) + _plugins.discover_plugins() + + _plugins.get_loaded_plugins().clear() + + assert list(_plugins.get_loaded_plugins()) == ["healthy"] + + +def test_a_recovered_plugin_leaves_the_failure_ledger( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, + monkeypatch: pytest.MonkeyPatch, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("flaky", error=RuntimeError("boom"))) + _plugins.discover_plugins() + assert "flaky" in _plugins.get_failed_plugins() + + entry_points.entry_points[:] = [_StubEntryPoint("flaky")] + monkeypatch.setattr(_plugins, "_discovered", False) + _plugins.discover_plugins() + + assert _plugins.get_failed_plugins() == {} + + +def test_a_regressed_plugin_leaves_the_loaded_ledger( + unlatched: None, + ledgers: None, + lenient: None, + entry_points: _EntryPointRecorder, + monkeypatch: pytest.MonkeyPatch, +) -> None: + entry_points.entry_points.append(_StubEntryPoint("flaky")) + _plugins.discover_plugins() + + entry_points.entry_points[:] = [_StubEntryPoint("flaky", error=RuntimeError("boom"))] + monkeypatch.setattr(_plugins, "_discovered", False) + _plugins.discover_plugins() + + assert _plugins.get_loaded_plugins() == {} + + +# --------------------------------------------------------------------------- +# Strict mode +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", " On "]) +def test_strict_mode_recognises_truthy_values(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv(_plugins.STRICT_ENV_VAR, value) + + assert _plugins.strict_plugins_enabled() is True + + +@pytest.mark.parametrize("value", ["", "0", "false", "no", "off", "maybe"]) +def test_strict_mode_rejects_other_values(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv(_plugins.STRICT_ENV_VAR, value) + + assert _plugins.strict_plugins_enabled() is False + + +def test_strict_mode_is_off_when_unset(lenient: None) -> None: + assert _plugins.strict_plugins_enabled() is False + + +def test_strict_mode_re_raises_a_plugin_failure( + unlatched: None, + ledgers: None, + entry_points: _EntryPointRecorder, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_plugins.STRICT_ENV_VAR, "1") + entry_points.entry_points.append(_StubEntryPoint("broken", error=RuntimeError("boom"))) + + with pytest.raises(RuntimeError, match="boom"): + _plugins.discover_plugins() + + +def test_strict_mode_still_records_the_failure( + unlatched: None, + ledgers: None, + entry_points: _EntryPointRecorder, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_plugins.STRICT_ENV_VAR, "1") + entry_points.entry_points.append(_StubEntryPoint("broken", error=RuntimeError("boom"))) + + with pytest.raises(RuntimeError): + _plugins.discover_plugins() + + assert "broken" in _plugins.get_failed_plugins() + + +def test_strict_mode_leaves_the_latch_set( + unlatched: None, + ledgers: None, + entry_points: _EntryPointRecorder, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_plugins.STRICT_ENV_VAR, "1") + entry_points.entry_points.append(_StubEntryPoint("broken", error=RuntimeError("boom"))) + + with pytest.raises(RuntimeError): + _plugins.discover_plugins() + + assert _plugins._discovered is True + + +def test_strict_mode_does_not_load_plugins_after_the_failure( + unlatched: None, + ledgers: None, + entry_points: _EntryPointRecorder, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv(_plugins.STRICT_ENV_VAR, "1") + entry_points.entry_points.extend( + [ + _StubEntryPoint("broken", error=RuntimeError("boom")), + _StubEntryPoint("healthy"), + ] + ) + + with pytest.raises(RuntimeError): + _plugins.discover_plugins() + + assert entry_points.entry_points[1].loaded is False diff --git a/tests/registry/visualization/test_init.py b/tests/registry/visualization/test_init.py new file mode 100644 index 000000000..241016ad6 --- /dev/null +++ b/tests/registry/visualization/test_init.py @@ -0,0 +1,36 @@ +"""Tests for the :mod:`drevalpy.registry.visualization` package surface. + +Like its splitter sibling, this barrel is a facade over one process-global +registry singleton rather than a plain re-export list, and the sixteen dependents +in the package go through the facade. Every assertion is read-only, so no +registration is triggered and no registry teardown is needed; the assertions +themselves live in ``tests/_barrel_surface.py``. + +``table()`` and ``applicable()`` are checked for presence only: the first builds +a ``pandas`` DataFrame and the second needs a full ``ExperimentResult``, and both +behaviours are owned by ``test_registry.py`` beside this file. +""" + +from __future__ import annotations + +from drevalpy.registry import visualization +from drevalpy.registry.visualization._registry import visualization_registry +from tests._barrel_surface import SingletonFacadeSurface + +#: Facade functions the barrel defines itself, with no module of their own. +FACADE_FUNCTIONS = ("applicable", "get", "list", "metadata", "register", "table") + +#: ``re-exported name -> private module that defines it``. +EXPECTED_ORIGINS: dict[str, str] = { + "VisualizationRegistry": "drevalpy.registry.visualization._registry", + "visualization_registry": "drevalpy.registry.visualization._registry", +} + + +class TestVisualizationSurface(SingletonFacadeSurface): + barrel = visualization + origins = EXPECTED_ORIGINS + unpinned_names = FACADE_FUNCTIONS + callable_names = FACADE_FUNCTIONS + singleton = visualization_registry + keys_attribute = "names" diff --git a/tests/registry/visualization/test_registry.py b/tests/registry/visualization/test_registry.py new file mode 100644 index 000000000..cdc5bdc27 --- /dev/null +++ b/tests/registry/visualization/test_registry.py @@ -0,0 +1,302 @@ +"""Tests for :class:`~drevalpy.registry.visualization._registry.VisualizationRegistry`. + +Registration is destructive - ``register`` raises on a duplicate name - and the +autouse ``_ensure_registries_populated`` fixture in ``tests/conftest.py`` keeps the +module singleton populated for the whole session. So every test that registers +anything does so against a locally constructed ``VisualizationRegistry``, and the +singleton is only ever read. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from drevalpy.registry import visualization as visualization_facade +from drevalpy.registry.visualization._registry import VisualizationRegistry, visualization_registry +from drevalpy.visualization.requirements import PlotRequirement + + +class _RecordingExperiment: + """``ExperimentResult`` stand-in recording the requirement sets it was asked about.""" + + def __init__(self, *, answer: bool = True) -> None: + self.answer = answer + self.asked: list[frozenset[Any]] = [] + + def satisfies(self, requirements: frozenset[Any]) -> bool: + """Record the query and return the canned answer.""" + self.asked.append(requirements) + return self.answer + + +class _SelectiveExperiment: + """``ExperimentResult`` stand-in that only satisfies single-model plots.""" + + def satisfies(self, requirements: frozenset[Any]) -> bool: + """Reject anything needing more than one model.""" + return PlotRequirement.MULTIPLE_MODELS not in requirements + + +@pytest.fixture +def registry() -> VisualizationRegistry: + return VisualizationRegistry() + + +@pytest.fixture +def violin(registry: VisualizationRegistry) -> type[Any]: + @registry.register("violin", "distribution per model") + class Violin: + pass + + return Violin + + +def test_a_new_registry_has_no_names(registry: VisualizationRegistry) -> None: + assert registry.names == [] + + +def test_register_returns_the_decorated_class(violin: type[Any]) -> None: + assert violin.__name__ == "Violin" + + +def test_register_stamps_the_registry_name_onto_the_class(violin: type[Any]) -> None: + assert violin.registry_name == "violin" + + +def test_registered_name_is_listed(registry: VisualizationRegistry, violin: type[Any]) -> None: + assert registry.names == ["violin"] + + +def test_names_are_sorted(registry: VisualizationRegistry) -> None: + registry.register("zebra")(type("Zebra", (), {})) + registry.register("aardvark")(type("Aardvark", (), {})) + + assert registry.names == ["aardvark", "zebra"] + + +def test_list_names_matches_the_names_property(registry: VisualizationRegistry, violin: type[Any]) -> None: + assert registry.list_names() == registry.names + + +def test_duplicate_registration_is_rejected(registry: VisualizationRegistry, violin: type[Any]) -> None: + with pytest.raises(ValueError, match="Visualization 'violin' already registered"): + + @registry.register("violin") + class SecondViolin: + pass + + +def test_override_replaces_an_existing_name(registry: VisualizationRegistry, violin: type[Any]) -> None: + @registry.register("violin", "replacement", override=True) + class ReplacementViolin: + pass + + assert registry.get("violin") is ReplacementViolin + assert registry.describe("violin") == "replacement" + + +def test_the_module_facade_forwards_override(monkeypatch: pytest.MonkeyPatch) -> None: + recorded: dict[str, Any] = {} + + def fake_register(name, description, *, result_type, requirements, override): + recorded.update( + name=name, + description=description, + result_type=result_type, + requirements=requirements, + override=override, + ) + return lambda cls: cls + + monkeypatch.setattr(visualization_registry, "register", fake_register) + + visualization_facade.register("x", "d", override=True) + + assert recorded["override"] is True + + +def test_get_returns_the_registered_class(registry: VisualizationRegistry, violin: type[Any]) -> None: + assert registry.get("violin") is violin + + +def test_get_rejects_an_unknown_name(registry: VisualizationRegistry, violin: type[Any]) -> None: + with pytest.raises(ValueError, match=r"Unknown visualization 'heatmap'\. Registered: \['violin'\]"): + registry.get("heatmap") + + +def test_describe_returns_the_registered_description(registry: VisualizationRegistry, violin: type[Any]) -> None: + assert registry.describe("violin") == "distribution per model" + + +def test_describe_of_an_unknown_name_is_empty(registry: VisualizationRegistry) -> None: + assert registry.describe("heatmap") == "" + + +def test_describe_defaults_to_an_empty_description(registry: VisualizationRegistry) -> None: + registry.register("bare")(type("Bare", (), {})) + + assert registry.describe("bare") == "" + + +def test_applicable_delegates_to_the_experiment(registry: VisualizationRegistry) -> None: + requirements = frozenset({PlotRequirement.MULTIPLE_FOLDS}) + registry.register("folds", requirements=requirements)(type("Folds", (), {})) + experiment = _RecordingExperiment() + + registry.applicable(experiment) # type: ignore[arg-type] + + assert experiment.asked == [requirements] + + +def test_applicable_returns_classes_the_experiment_accepts(registry: VisualizationRegistry, violin: type[Any]) -> None: + assert registry.applicable(_RecordingExperiment()) == [violin] # type: ignore[arg-type] + + +def test_applicable_drops_classes_the_experiment_rejects(registry: VisualizationRegistry, violin: type[Any]) -> None: + assert registry.applicable(_RecordingExperiment(answer=False)) == [] # type: ignore[arg-type] + + +def test_applicable_filters_per_class_requirements(registry: VisualizationRegistry) -> None: + single = registry.register("single")(type("Single", (), {})) + registry.register("comparison", requirements=frozenset({PlotRequirement.MULTIPLE_MODELS}))( + type("Comparison", (), {}) + ) + + assert registry.applicable(_SelectiveExperiment()) == [single] # type: ignore[arg-type] + + +def test_applicable_on_an_empty_registry_is_empty(registry: VisualizationRegistry) -> None: + assert registry.applicable(_RecordingExperiment()) == [] # type: ignore[arg-type] + + +def test_retain_only_drops_unlisted_names(registry: VisualizationRegistry, violin: type[Any]) -> None: + registry.register("heatmap", "per-model heatmap")(type("Heatmap", (), {})) + + registry.retain_only(frozenset({"violin"})) + + assert registry.names == ["violin"] + + +def test_retain_only_forgets_the_dropped_description(registry: VisualizationRegistry, violin: type[Any]) -> None: + registry.retain_only(frozenset()) + + assert registry.describe("violin") == "" + + +def test_retain_only_frees_the_name_for_re_registration(registry: VisualizationRegistry, violin: type[Any]) -> None: + registry.retain_only(frozenset()) + + @registry.register("violin", "replacement") + class ReplacementViolin: + pass + + assert registry.get("violin") is ReplacementViolin + + +def test_repr_renders_without_an_index(registry: VisualizationRegistry, violin: type[Any]) -> None: + rendered = repr(registry) + + assert "violin" in rendered + assert "distribution per model" in rendered + assert not rendered.startswith("0") + + +def test_repr_of_an_empty_registry_is_just_the_header(registry: VisualizationRegistry) -> None: + assert repr(registry) == "Empty DataFrame\nColumns: []\nIndex: []" + + +def test_repr_html_emits_a_table(registry: VisualizationRegistry, violin: type[Any]) -> None: + assert "<table" in registry._repr_html_() + + +def test_to_dataframe_lists_the_registry_columns(registry: VisualizationRegistry, violin: type[Any]) -> None: + frame = registry.to_dataframe() + + assert list(frame.columns) == ["Name", "Description", "Result type", "Requirements"] + assert frame.iloc[0].tolist() == ["violin", "distribution per model", "ExperimentResult", ""] + + +def test_to_dataframe_renders_requirements(registry: VisualizationRegistry) -> None: + registry.register("folds", requirements=frozenset({PlotRequirement.MULTIPLE_FOLDS}))(type("Folds", (), {})) + + assert str(PlotRequirement.MULTIPLE_FOLDS) in registry.to_dataframe().iloc[0]["Requirements"] + + +def test_get_metadata_reports_the_registry_fields(registry: VisualizationRegistry, violin: type[Any]) -> None: + assert registry.get_metadata("violin") == { + "registry": "visualizations", + "name": "violin", + "class_name": "Violin", + "description": "distribution per model", + "result_type": "ExperimentResult", + "requirements": frozenset(), + } + + +def test_get_metadata_rejects_an_unknown_name(registry: VisualizationRegistry) -> None: + with pytest.raises(ValueError, match="Unknown visualization 'heatmap'"): + registry.get_metadata("heatmap") + + +def test_get_metadata_carries_the_result_type(registry: VisualizationRegistry) -> None: + registry.register("per_model", result_type="ModelResult")(type("PerModel", (), {})) + + assert registry.get_metadata("per_model")["result_type"] == "ModelResult" + + +def test_list_metadata_covers_every_name(registry: VisualizationRegistry) -> None: + registry.register("zebra")(type("Zebra", (), {})) + registry.register("aardvark")(type("Aardvark", (), {})) + + assert [row["name"] for row in registry.list_metadata()] == ["aardvark", "zebra"] + + +def test_the_singleton_is_populated_by_builtin_registration() -> None: + assert visualization_registry.names + + +def test_the_singleton_stamps_registry_names_onto_builtin_plots() -> None: + name = visualization_registry.names[0] + + assert visualization_registry.get(name).registry_name == name + + +def test_builtin_registration_is_a_no_op_once_populated() -> None: + from drevalpy.registry._builtins import _register_builtin_visualizations + + before = visualization_registry.names + + _register_builtin_visualizations() + + assert visualization_registry.names == before + + +def test_module_list_delegates_to_the_singleton() -> None: + assert visualization_facade.list() == visualization_registry.names + + +def test_module_get_delegates_to_the_singleton() -> None: + name = visualization_registry.names[0] + + assert visualization_facade.get(name) is visualization_registry.get(name) + + +def test_module_table_returns_a_dataframe() -> None: + frame = visualization_facade.table() + + assert list(frame.columns) == ["Name", "Description", "Result type", "Requirements"] + assert frame["Name"].tolist() == visualization_registry.names + + +def test_module_metadata_delegates_to_the_singleton() -> None: + name = visualization_registry.names[0] + + assert visualization_facade.metadata(name) == visualization_registry.get_metadata(name) + + +def test_module_applicable_delegates_to_the_singleton() -> None: + experiment = _RecordingExperiment(answer=False) + + assert visualization_facade.applicable(experiment) == [] # type: ignore[arg-type] diff --git a/tests/synthetic/__init__.py b/tests/synthetic/__init__.py new file mode 100644 index 000000000..12f8839b8 --- /dev/null +++ b/tests/synthetic/__init__.py @@ -0,0 +1,83 @@ +"""Deterministic synthetic datasets used in place of downloadable real data. + +``builders`` holds the raw-omics MuData factory that the session fixture in +``tests/conftest.py`` exposes; ``variants`` holds the deliberately degenerate +shapes individual tests ask for, such as partial modality coverage; ``results`` +holds the result-object factories that stand in for a completed training run. +""" + +from __future__ import annotations + +from tests.synthetic.builders import ( + BPE_LENGTH, + BUILTIN_MEASURE, + CHEMBERTA_DIM, + CNV_MODALITY, + FINGERPRINT_BITS, + N_CELL_LINES, + N_DRUGS, + N_GENES, + N_METHYLATION_SITES, + N_PATHWAYS, + N_TISSUES, + OMICS_MODALITIES, + RESPONSE_LAYERS, + SMILESVEC_DIM, + build_synthetic_dataset, + synthetic_gene_symbols, +) +from tests.synthetic.results import ( + DEFAULT_DATASET_NAME, + DEFAULT_MODEL_NAMES, + DEFAULT_SPLIT_MODE, + NORMALIZED_METRIC, + REFERENCE_MODEL, + make_experiment_result, + make_metrics, + make_model_result, + make_run_result, +) +from tests.synthetic.variants import ( + EXCLUDED_MODELS, + MODEL_DEFECTS, + PARTIAL_COVERAGE, + SAVE_LOAD_DEFECTS, + SUPPORTED_GLOBAL_MODELS, + SUPPORTED_SINGLE_DRUG_MODELS, + build_partial_coverage_dataset, +) + +__all__ = [ + "BPE_LENGTH", + "BUILTIN_MEASURE", + "CHEMBERTA_DIM", + "CNV_MODALITY", + "DEFAULT_DATASET_NAME", + "DEFAULT_MODEL_NAMES", + "DEFAULT_SPLIT_MODE", + "EXCLUDED_MODELS", + "FINGERPRINT_BITS", + "MODEL_DEFECTS", + "NORMALIZED_METRIC", + "N_CELL_LINES", + "N_DRUGS", + "N_GENES", + "N_METHYLATION_SITES", + "N_PATHWAYS", + "N_TISSUES", + "OMICS_MODALITIES", + "PARTIAL_COVERAGE", + "REFERENCE_MODEL", + "RESPONSE_LAYERS", + "SAVE_LOAD_DEFECTS", + "SMILESVEC_DIM", + "SUPPORTED_GLOBAL_MODELS", + "SUPPORTED_SINGLE_DRUG_MODELS", + "build_partial_coverage_dataset", + "build_synthetic_dataset", + "make_experiment_result", + "make_metrics", + "make_model_result", + "make_run_result", + "synthetic_gene_symbols", +] diff --git a/tests/synthetic/builders.py b/tests/synthetic/builders.py new file mode 100644 index 000000000..00b503efb --- /dev/null +++ b/tests/synthetic/builders.py @@ -0,0 +1,357 @@ +"""Raw-omics MuData factory backing the whole test suite. + +The suite used to depend on a gitignored ``data/`` directory holding downloaded +screens, which meant a clean checkout could not run a single test. This module +replaces that with a deterministic, fully in-memory dataset that carries the +same structural slots a published ``.h5mu`` does. + +Only *raw* data is authored here: omics matrices, tissue labels and +``canonical_smiles``. Everything a featurizer can derive from rdkit is derived +by the real featurizer, so the fixture cannot drift away from what the library +computes. The remaining views (``chemberta``, ``smilesvec``, ``bpe_smiles``, +``pathway_features``) come from pretrained weights or annotation downloads in +production, so they are filled with seeded noise of the correct width rather +than fetched over the network. + +Modality names are taken from :data:`drevalpy.types.data.modalities.OMICS_ACCESSORS` +rather than written as literals, so the fixture always stores omics under the +key the published datasets actually use. +""" + +from __future__ import annotations + +import io +from collections.abc import Mapping +from typing import Any, Final + +import anndata as ad +import mudata as md +import numpy as np +import pandas as pd + +from drevalpy.components.featurizers.cell_line.gene_lists import ( + gene_names_from_list_csv, + resolve_gene_list_path, +) +from drevalpy.data.utils import CELL_LINE_IDENTIFIER, TISSUE_IDENTIFIER +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.modalities import OMICS_ACCESSORS, resolve_omics_accessor + +#: Roughly the smallest shape that keeps every downstream library happy. At 2x2 +#: sklearn emits low-bootstrap warnings and subword-nmt refuses to learn BPE +#: codes ("no pair has frequency >= 2"), both of which are errors under -W. +N_CELL_LINES: Final = 24 +N_DRUGS: Final = 8 + +#: ``LTO`` runs ``KFold(n_splits=2)`` over the unique tissues and then carves a +#: validation tissue out of the training half, so three is the hard floor. Six +#: leaves four cell lines per tissue, which keeps each fold's training half wide +#: enough for the single-drug models. +N_TISSUES: Final = 6 + +N_GENES: Final = 16 +N_METHYLATION_SITES: Final = 20 +N_PATHWAYS: Final = 2 + +FINGERPRINT_BITS: Final = 128 +CHEMBERTA_DIM: Final = 768 +BPE_LENGTH: Final = 128 +SMILESVEC_DIM: Final = 100 + +#: A curve-metric layer name curation really emits. ``response.X`` holds pEC50 - +#: curation does not duplicate it as a layer - so this one is derived from ``X``. +BUILTIN_MEASURE: Final = "LN_IC50" +RESPONSE_LAYERS: Final = (BUILTIN_MEASURE, "AUC", "IC50") + +DATASET_NAME: Final = "SYNTH" +SEED: Final = 20240612 + +#: Modality key the fixture stores copy-number data under. Resolved through the +#: accessor map so the fixture follows the datasets, not the public alias. +CNV_MODALITY: Final = resolve_omics_accessor("copy_number_variation_gistic") + +#: Every omics modality the fixture carries, keyed by the modality name a +#: ``.h5mu`` would use. +OMICS_MODALITIES: Final = tuple(resolve_omics_accessor(name) for name in OMICS_ACCESSORS) + +#: Which axis each omics view is measured over, keyed by public omics name. +_VAR_AXIS: Final[Mapping[str, str]] = { + "gene_expression": "gene", + "proteomics": "gene", + "mutations": "gene", + "methylation": "cpg", + "copy_number_variation_gistic": "gene", +} + +_TISSUES: Final = ("Lung", "Blood", "Skin", "Colon", "Brain", "Breast") + +#: Real, rdkit-parseable drug SMILES. Real molecules rather than toy strings so +#: fingerprints, molecular graphs and learned BPE merges are all non-degenerate. +_DRUGS: Final = ( + ("176870", "Erlotinib", "COCCOC1=C(C=C2C(=C1)C(=NC=N2)NC3=CC=CC(=C3)C#C)OCCOC"), + ("123631", "Gefitinib", "COC1=C(C=C2C(=C1)N=CN=C2NC3=CC(=C(C=C3)F)Cl)OCCCN4CCOCC4"), + ("208908", "Lapatinib", "CS(=O)(=O)CCNCC1=CC2=C(C=C1)N=CN=C2NC3=CC(=C(C=C3)OCC4=CC(=CC=C4)F)Cl"), + ("5291", "Imatinib", "CC1=C(C=C(C=C1)NC(=O)C2=CC=C(C=C2)CN3CCN(CC3)C)NC4=NC=CC(=N4)C5=CN=CC=C5"), + ("216239", "Sorafenib", "CNC(=O)C1=NC=CC(=C1)OC2=CC=C(C=C2)NC(=O)NC3=CC(=C(C=C3)Cl)C(F)(F)F"), + ("5329102", "Sunitinib", "CCN(CC)CCNC(=O)C1=C(NC(=C1C)C=C2C3=C(C=CC(=C3)F)NC2=O)C"), + ("3062316", "Dasatinib", "CC1=NC(=CC(=N1)N2CCN(CC2)CCO)NC3=NC=C(S3)C(=O)NC4=C(C=CC=C4Cl)C"), + ("6450551", "Axitinib", "CNC(=O)C1=CC=CC=C1SC2=CC3=C(C=C2)C(=NN3)C=CC4=CC=CC=N4"), +) + +#: Sparse gaps in the response matrix, as ``(cell_line_index, drug_index)``. +#: Kept deliberately thin: the splitters must still leave every tissue and every +#: drug with observed pairs in each fold. +_UNMEASURED_PAIRS: Final = ((0, 3), (5, 1), (9, 6), (14, 0), (18, 5), (22, 7)) + + +def synthetic_gene_symbols(n: int = N_GENES) -> list[str]: + """Return the first *n* gene symbols shipped in ``landmark_genes.csv``. + + Drawing from the packaged gene list rather than inventing symbols is what + makes the ``landmarkGenes`` featurizer select a non-empty subset, and means + the fixture cannot drift out of sync with the shipped lists. + + :param n: Number of symbols to return. + :returns: Ordered, de-duplicated gene symbols. + :raises ValueError: If the packaged list holds fewer than *n* symbols. + """ + symbols = list(dict.fromkeys(gene_names_from_list_csv(resolve_gene_list_path("landmark_genes")))) + if len(symbols) < n: + msg = f"landmark_genes.csv only provides {len(symbols)} symbols, need {n}" + raise ValueError(msg) + return symbols[:n] + + +def _cell_line_ids() -> np.ndarray: + return np.array([f"CVCL_S{index:03d}" for index in range(N_CELL_LINES)], dtype=object) + + +def _tissue_labels() -> np.ndarray: + return np.array([_TISSUES[index % N_TISSUES] for index in range(N_CELL_LINES)], dtype=object) + + +def _var_names(axis: str) -> list[str]: + if axis == "cpg": + return [ + f"chr{index % 22 + 1}:{1_000_000 + index * 977}-{1_000_800 + index * 977}" + for index in range(N_METHYLATION_SITES) + ] + return synthetic_gene_symbols() + + +def _omics_matrix(public_name: str, rng: np.random.Generator, n_rows: int, n_cols: int) -> np.ndarray: + """Draw an omics matrix whose value range is plausible for *public_name*.""" + if public_name == "mutations": + return rng.binomial(1, 0.25, size=(n_rows, n_cols)).astype(np.float32) + if public_name == "methylation": + return rng.beta(2.0, 2.0, size=(n_rows, n_cols)).astype(np.float32) + if public_name == "copy_number_variation_gistic": + return rng.integers(-2, 3, size=(n_rows, n_cols)).astype(np.float32) + return rng.normal(6.0, 1.5, size=(n_rows, n_cols)).astype(np.float32) + + +def _response_anndata(rng: np.random.Generator, cell_line_ids: np.ndarray) -> ad.AnnData: + """Build the ``response`` modality: the pair matrix plus drug metadata.""" + drug_ids = np.array([drug[0] for drug in _DRUGS], dtype=object) + matrix = rng.normal(2.0, 1.0, size=(N_CELL_LINES, N_DRUGS)).astype(np.float32) + for row, column in _UNMEASURED_PAIRS: + matrix[row, column] = np.nan + + response = ad.AnnData( + X=matrix, + obs=pd.DataFrame( + { + CELL_LINE_IDENTIFIER: [f"SYNTH-{index:03d}" for index in range(N_CELL_LINES)], + TISSUE_IDENTIFIER: _tissue_labels(), + }, + index=pd.Index(cell_line_ids, name="cellosaurus_id"), + ), + var=pd.DataFrame( + { + "drug_name": [drug[1] for drug in _DRUGS], + "canonical_smiles": [drug[2] for drug in _DRUGS], + }, + index=pd.Index(drug_ids, name="pubchem_id"), + ), + ) + response.layers[BUILTIN_MEASURE] = ((6.0 - matrix) * np.log(10.0)).astype(np.float32) + response.layers["AUC"] = rng.uniform(0.0, 1.0, size=matrix.shape).astype(np.float32) + response.layers["IC50"] = np.power(10.0, 6.0 - matrix).astype(np.float32) + # The built-in splitters filter on the CurveCurator quality layers, so a + # dataset without them cannot be split. Every curve passes here, keeping the + # folds determined by _UNMEASURED_PAIRS alone. + for name, layer in _quality_layers(matrix.shape).items(): + response.layers[name] = layer + return response + + +def _quality_layers(shape: tuple[int, int]) -> dict[str, np.ndarray]: + """Quality layers on which every synthetic curve passes comfortably. + + Values sit far from the thresholds in + :func:`drevalpy.data.quality.curve_quality_mask`, so a boundary change there + cannot silently reclassify a synthetic pair. + """ + passing = { + "relevance_score": 9.0, + "fold_change": -2.0, + "p_value": 1e-9, + "log_p_value": 9.0, + "f_value": 400.0, + "f_value_sam": 80.0, + "R2": 0.99, + "RMSE": 0.02, + "signal_quality": 1.0, + "slope": 3.0, + "front": 1.0, + "back": 0.05, + "regulation": -1.0, + # Not filter options, but layers every curated dataset carries: the + # per-parameter standard errors CurveCurator derives from the fit's + # Jacobian. + "pec50_error": 0.05, + "slope_error": 0.1, + "front_error": 0.01, + "back_error": 0.01, + } + return {name: np.full(shape, value, dtype=np.float32) for name, value in passing.items()} + + +def _omics_anndata( + public_name: str, + rng: np.random.Generator, + cell_line_ids: np.ndarray, + n_covered: int, +) -> ad.AnnData: + var_names = _var_names(_VAR_AXIS[public_name]) + covered = cell_line_ids[:n_covered] + return ad.AnnData( + X=_omics_matrix(public_name, rng, len(covered), len(var_names)), + obs=pd.DataFrame(index=pd.Index(covered, name="cellosaurus_id")), + var=pd.DataFrame(index=pd.Index(var_names, name="feature")), + ) + + +def _pathways_gmt(genes: list[str]) -> str: + """Build a two-set GMT string; GSVA hard-codes ``min_size=5``.""" + half = max(5, len(genes) // 2) + sets = {"SYNTH_PATHWAY_A": genes[:half], "SYNTH_PATHWAY_B": genes[-half:]} + return "".join("\t".join([name, "synthetic", *members]) + "\n" for name, members in sets.items()) + + +def _sparsego_uns(genes: list[str]) -> dict[str, str]: + """Build ``uns['sparsego']`` in the two-file text form the fixtures use.""" + terms = ("GO:0006259", "GO:0008283") + ontology_rows = [f"{terms[0]}\t{terms[1]}\tdefault"] + for index, gene in enumerate(genes): + ontology_rows.append(f"{terms[index % len(terms)]}\t{gene}\tgene") + return { + "gene2ind": "".join(f"{index}\t{gene}\n" for index, gene in enumerate(genes)), + "ontology": "".join(row + "\n" for row in ontology_rows), + } + + +def _bpe_codes(smiles: list[str], *, num_symbols: int = 50) -> str: + """Learn real BPE merges from the fixture SMILES. + + ``uns['bpe_codes']`` is not read by any library code -- the PharmaFormer + featurizer relearns merges at fit time -- but the published datasets carry + it, so the fixture does too. Learning them for real also proves the fixture + SMILES are dense enough for ``subword-nmt``, which refuses to merge when no + character pair occurs twice. + + :param smiles: SMILES strings to learn merges from. + :param num_symbols: Number of merge operations to learn. + :returns: BPE codes file contents. + """ + from subword_nmt.learn_bpe import learn_bpe + + codes = io.StringIO() + learn_bpe(io.StringIO("\n".join(smiles) + "\n"), codes, num_symbols=num_symbols, verbose=False) + return codes.getvalue() + + +def _derived_drug_views(dataset: Dataset) -> None: + """Fill ``response.varm`` and ``uns['drug_graphs']`` from the SMILES column. + + Fingerprints and molecular graphs are produced by the library's own + featurizers, so the fixture can never disagree with what production + computes. Graphs are stored as plain dicts of arrays because that is what + ``h5py`` can round-trip, and what real ``.h5mu`` files contain. + """ + from drevalpy.components.featurizers.drug.drug_graph import DrugGraphFeaturizer + from drevalpy.components.featurizers.drug.fingerprints import FingerprintsFeaturizer + from drevalpy.types.data.feature_source import DrugFeatureSource + + drug_ids = dataset.drug_ids + source = DrugFeatureSource(dataset, drug_ids) + response = dataset.mdata.mod["response"] + + fingerprints = FingerprintsFeaturizer(n_bits=FINGERPRINT_BITS) + response.varm["morgan_fingerprint"] = fingerprints._compute_from_source(source, drug_ids) + + graphs = DrugGraphFeaturizer()._compute_from_source(source, drug_ids) + dataset.mdata.uns["drug_graphs"] = { + str(drug_id): _graph_to_dict(graph) + for drug_id, graph in zip(drug_ids, graphs, strict=True) + if graph is not None + } + + +def _graph_to_dict(graph: Any) -> dict[str, np.ndarray]: + return { + "x": np.asarray(graph.x, dtype=np.float32), + "edge_index": np.asarray(graph.edge_index, dtype=np.int64), + "edge_attr": np.asarray(graph.edge_attr, dtype=np.float32), + } + + +def _pretrained_views(dataset: Dataset, rng: np.random.Generator) -> None: + """Fill the views that would otherwise need a weight or annotation download.""" + response = dataset.mdata.mod["response"] + response.varm["chemberta"] = rng.normal(size=(N_DRUGS, CHEMBERTA_DIM)).astype(np.float32) + response.varm["bpe_smiles"] = rng.normal(size=(N_DRUGS, BPE_LENGTH)).astype(np.float32) + response.varm["smilesvec"] = rng.normal(size=(N_DRUGS, SMILESVEC_DIM)).astype(np.float32) + response.obsm["pathway_features"] = rng.normal(size=(N_CELL_LINES, N_PATHWAYS)).astype(np.float32) + + +def build_synthetic_dataset( + *, + name: str = DATASET_NAME, + omics_coverage: Mapping[str, int] | None = None, + seed: int = SEED, +) -> Dataset: + """Build the synthetic raw-omics :class:`~drevalpy.types.data.dataset.Dataset`. + + :param name: Dataset name recorded on the returned object. + :param omics_coverage: Optional public-omics-name to cell-line-count map. + Any omics view left out is given full coverage. Reducing a count drops + trailing cell lines from that modality, which is what makes the + predictors' NaN-filtering path fire; see + :mod:`tests.synthetic.variants`. + :param seed: Seed for every drawn matrix, so the dataset is reproducible. + :returns: A dataset with complete metadata, five omics modalities, four + drug views, pathway scores and the auxiliary ``uns`` payloads. + """ + rng = np.random.default_rng(seed) + cell_line_ids = _cell_line_ids() + coverage = dict(omics_coverage or {}) + + modalities: dict[str, ad.AnnData] = {"response": _response_anndata(rng, cell_line_ids)} + for public_name, accessor in OMICS_ACCESSORS.items(): + n_covered = int(coverage.get(public_name, N_CELL_LINES)) + modalities[accessor] = _omics_anndata(public_name, rng, cell_line_ids, n_covered) + + md.set_options(pull_on_update=False) + mdata = md.MuData(modalities) + mdata.obs = modalities["response"].obs.copy() + + genes = synthetic_gene_symbols() + mdata.uns["pathways_gmt"] = _pathways_gmt(genes) + mdata.uns["sparsego"] = _sparsego_uns(genes) + mdata.uns["bpe_codes"] = _bpe_codes([drug[2] for drug in _DRUGS]) + + dataset = Dataset(mdata, name=name) + _derived_drug_views(dataset) + _pretrained_views(dataset, rng) + return dataset diff --git a/tests/synthetic/results.py b/tests/synthetic/results.py new file mode 100644 index 000000000..025632936 --- /dev/null +++ b/tests/synthetic/results.py @@ -0,0 +1,223 @@ +"""Result-object factories for tests that need a populated experiment. + +The production path builds :class:`~drevalpy.types.results.run.RunResult` objects +inside the training loop, so anything that consumes results - the visualization +plots, the report writer, the result serializers - would otherwise need a full +training run to get an input. These factories produce the same shapes directly. + +The defaults are chosen so a bare :func:`make_experiment_result` satisfies every +plot requirement in the package: three models (``critical_difference`` feeds +``scipy.stats.friedmanchisquare``, which raises below three), equal fold counts +across models, and enough pairs per fold for ``regression_scatter`` to have at +least two rows per group. One model is named ``NaiveMeanEffectsPredictor`` so +:meth:`~drevalpy.types.results.experiment.ExperimentResult.normalize` finds its +default reference. +""" + +from __future__ import annotations + +from typing import Any, Final + +import numpy as np + +from drevalpy.evaluation import AVAILABLE_METRICS +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.types.results.model import ModelResult +from drevalpy.types.results.run import RunResult + +#: ``normalize()`` uses this name as its default reference model. +REFERENCE_MODEL: Final = "NaiveMeanEffectsPredictor" + +#: Three models keeps ``critical_difference`` (Friedman test) constructible. +DEFAULT_MODEL_NAMES: Final = (REFERENCE_MODEL, "ElasticNet", "RandomForest") + +DEFAULT_DATASET_NAME: Final = "SyntheticDataset" +DEFAULT_SPLIT_MODE: Final = "LPO" + +#: Suffix older drevalpy releases appended to the normalized copy of a metric. +#: ``normalize()`` no longer emits it - it recomputes every metric under its +#: plain name - so the builders below do not either; the constant stays for the +#: tests that pin the plots' tolerance of results written by those releases. +NORMALIZED_METRIC: Final = "Pearson: normalized" + + +def make_metrics(*, seed: int = 0) -> dict[str, float]: + """Build a metrics dict covering every metric the package reports. + + Args: + seed: Seed for the deterministic pseudo-random values. + + Returns: + Mapping of metric name to score, holding every key in + :data:`drevalpy.evaluation.AVAILABLE_METRICS` - the same key set a run + carries in production, before and after normalization. + """ + rng = np.random.default_rng(seed) + return {name: float(rng.uniform(0.1, 0.9)) for name in AVAILABLE_METRICS} + + +def make_run_result( + *, + model_name: str = "ElasticNet", + dataset_name: str = DEFAULT_DATASET_NAME, + fold_index: int = 0, + fold_id: str | None = None, + split_mode: str = DEFAULT_SPLIT_MODE, + n_pairs: int = 20, + n_cell_lines: int = 5, + n_drugs: int = 4, + metrics: dict[str, float] | None = None, + best_hyperparameters: dict[str, Any] | None = None, + fold_metadata: dict[str, Any] | None = None, + randomization: tuple[str, str] | None = None, + seed: int | None = None, +) -> RunResult: + """Build one fold's worth of predictions for a single model. + + ``cell_line_ids`` and ``drug_ids`` are cycled independently over the + requested cardinalities, so every pair is unique as long as + ``n_pairs <= n_cell_lines * n_drugs`` and the two counts are coprime; the + defaults satisfy both. + + Args: + model_name: Value for ``RunResult.model_name``. + dataset_name: Value for ``RunResult.dataset_name``. Every run in one + experiment must agree on this. + fold_index: Zero-based fold number. + fold_id: Value for ``RunResult.fold_id``. Defaults to + ``f"fold_{fold_index}"``, which is what ``normalize()`` matches runs + on across models. + split_mode: Value for ``RunResult.split_mode``. Every run in one + experiment must agree on this. + n_pairs: Number of cell-line/drug pairs in the fold. + n_cell_lines: Number of distinct cell-line ids to cycle through. + n_drugs: Number of distinct drug ids to cycle through. + metrics: Metrics dict. Defaults to :func:`make_metrics`. + best_hyperparameters: Value for ``RunResult.best_hyperparameters``. + fold_metadata: Value for ``RunResult.fold_metadata``. Add a + ``"robustness_trial"`` key here to make an experiment report + ``has_robustness``. + randomization: Value for ``RunResult.randomization``. Set it to make an + experiment report ``has_randomization``. + seed: Seed for predictions, ground truth and default metrics. Defaults + to ``fold_index``, so folds of one model differ but the same fold of + two models does not. + + Returns: + A fully populated ``RunResult``. + """ + effective_seed = fold_index if seed is None else seed + rng = np.random.default_rng(effective_seed) + ground_truth = rng.normal(size=n_pairs) + predictions = ground_truth + rng.normal(scale=0.3, size=n_pairs) + + return RunResult( + model_name=model_name, + dataset_name=dataset_name, + fold_index=fold_index, + predictions=predictions, + ground_truth=ground_truth, + cell_line_ids=np.array([f"CL_{i % n_cell_lines}" for i in range(n_pairs)]), + drug_ids=np.array([f"D_{i % n_drugs}" for i in range(n_pairs)]), + split_mode=split_mode, + fold_id=f"fold_{fold_index}" if fold_id is None else fold_id, + best_hyperparameters=dict(best_hyperparameters or {"alpha": 0.1}), + metrics=make_metrics(seed=effective_seed) if metrics is None else dict(metrics), + fold_metadata=dict(fold_metadata or {"fold_index": fold_index}), + randomization=randomization, + ) + + +def make_model_result( + *, + model_name: str = "ElasticNet", + dataset_name: str = DEFAULT_DATASET_NAME, + n_folds: int = 3, + split_mode: str = DEFAULT_SPLIT_MODE, + n_pairs: int = 20, +) -> ModelResult: + """Build one model's folds. + + Args: + model_name: Value for ``ModelResult.model_name``. + dataset_name: Value for ``ModelResult.dataset_name``. + n_folds: Number of runs to generate. + split_mode: ``split_mode`` for every generated run. + n_pairs: Number of pairs per fold. + + Returns: + A ``ModelResult`` holding ``n_folds`` runs. + """ + return ModelResult( + model_name=model_name, + dataset_name=dataset_name, + runs=[ + make_run_result( + model_name=model_name, + dataset_name=dataset_name, + fold_index=fold_index, + split_mode=split_mode, + n_pairs=n_pairs, + ) + for fold_index in range(n_folds) + ], + ) + + +def make_experiment_result( + *, + n_models: int = 3, + n_folds: int = 3, + model_names: tuple[str, ...] | None = None, + dataset_name: str = DEFAULT_DATASET_NAME, + split_mode: str = DEFAULT_SPLIT_MODE, + n_pairs: int = 20, + with_randomization: bool = False, + with_robustness: bool = False, +) -> ExperimentResult: + """Build a complete experiment with equal fold counts across models. + + Args: + n_models: Number of models. Ignored when ``model_names`` is given. Names + beyond :data:`DEFAULT_MODEL_NAMES` are generated as ``"Model_{i}"``. + n_folds: Number of folds per model. Every model gets the same count, + which ``critical_difference`` requires. + model_names: Explicit model names. The first entry should be + :data:`REFERENCE_MODEL` if the caller intends to call + ``normalize()``. + dataset_name: Shared ``dataset_name`` for every run. + split_mode: Shared ``split_mode`` for every run. + n_pairs: Number of pairs per fold. + with_randomization: Attach randomization metadata to every run, making + the experiment report ``has_randomization``. + with_robustness: Attach a ``"robustness_trial"`` key to every run's + ``fold_metadata``, making the experiment report ``has_robustness``. + + Returns: + An ``ExperimentResult`` grouping ``n_models`` models of ``n_folds`` + folds each. + """ + names = _resolve_model_names(n_models) if model_names is None else model_names + + runs = [ + make_run_result( + model_name=name, + dataset_name=dataset_name, + fold_index=fold_index, + split_mode=split_mode, + n_pairs=n_pairs, + seed=model_index * 100 + fold_index, + fold_metadata=({"fold_index": fold_index, "robustness_trial": fold_index} if with_robustness else None), + randomization=("gene_expression", "permutation") if with_randomization else None, + ) + for model_index, name in enumerate(names) + for fold_index in range(n_folds) + ] + return ExperimentResult(runs) + + +def _resolve_model_names(n_models: int) -> tuple[str, ...]: + if n_models <= len(DEFAULT_MODEL_NAMES): + return DEFAULT_MODEL_NAMES[:n_models] + extra = tuple(f"Model_{i}" for i in range(len(DEFAULT_MODEL_NAMES), n_models)) + return DEFAULT_MODEL_NAMES + extra diff --git a/tests/synthetic/variants.py b/tests/synthetic/variants.py new file mode 100644 index 000000000..47876ecb9 --- /dev/null +++ b/tests/synthetic/variants.py @@ -0,0 +1,115 @@ +"""Degenerate variants of the synthetic dataset, plus the model support matrix. + +The main fixture in :mod:`tests.synthetic.builders` has **complete** modality +coverage, which keeps the common case fast and keeps unrelated failures out of +the model gate. :func:`build_partial_coverage_dataset` covers the other side: +it reproduces the ragged coverage the published datasets have (the smaller toy +dataset carries gene expression for 88 of its 90 cell lines), which drives the +NaN-filtering path in ``PredictorBase.fit`` and therefore +``ModelInputBatch.subset_pairs`` with a multi-drug mask. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Final + +import pytest +from _pytest.mark import ParameterSet + +from drevalpy.types.data.dataset import Dataset +from tests.synthetic.builders import N_CELL_LINES, build_synthetic_dataset + +#: Cell lines covered per public omics name in the partial-coverage variant. +#: Any omics view left out keeps full coverage. +PARTIAL_COVERAGE: Final[dict[str, int]] = { + "gene_expression": N_CELL_LINES - 2, + "proteomics": N_CELL_LINES - 3, + "methylation": N_CELL_LINES - 6, + "copy_number_variation_gistic": N_CELL_LINES - 2, +} + +#: Global models the fixture drives end to end. +SUPPORTED_GLOBAL_MODELS: Final = ( + "SRMF", + "SimpleNeuralNetwork[fingerprints]", + "SimpleNeuralNetwork[chemberta]", + "MultiViewNeuralNetwork", + "PharmaFormer", + "Precily", +) + +#: Single-drug models the fixture is expected to train, predict and round-trip. +SUPPORTED_SINGLE_DRUG_MODELS: Final = ( + "SingleDrugRandomForest[gex]", + "SingleDrugRandomForest[proteomics]", + "SingleDrugElasticNet[gex]", + "SingleDrugElasticNet[proteomics]", + "MOLIR", + "SuperFELTR", +) + +#: Models kept out of the lists above, mapped to the defect that excludes them. +#: All three are pre-existing library bugs, independent of the fixture, and are +#: investigate-only for now, so the reason travels with the exclusion instead of +#: living solely in a planning document. Each reason below was reproduced against +#: the complete-coverage fixture, so it is the observed failure, not a guess. +EXCLUDED_MODELS: Final[dict[str, str]] = { + "DIPK": ( + "bionic.py fetches human_ppi_features.tsv and gene_list_sel.txt from the artifacts bucket, " + "which raises PermissionError(403) without AWS credentials -- anonymous reads are refused -- " + "so the featurizer never reaches the ragged molgnet_features it also needs; uns['dipk'] is " + "written by the data builder but read by no library code" + ), + "DrugGNN": ( + "the drug_graph view stores plain dicts, as a .h5mu must, while " + "druggnn/predictor.py calls .num_node_features on them, raising AttributeError" + ), + "SparseGO": ( + "attach_sparsego_ontology_metadata has no production caller, so read_sparsego_ontology_metadata " + "never finds the layer_connections key it needs and SparseGOOntologyFeaturizer._fit always " + "raises 'SparseGO ontology metadata is missing'" + ), +} + +#: Models expected to fail for a named, still-open defect, mapped to that reason. +#: +#: Empty on purpose: the three copy-number models that used to live here +#: (``MultiViewNeuralNetwork``, ``MOLIR``, ``SuperFELTR``) were blocked only +#: because the library passed the public omics name straight to +#: ``Dataset.get_cell_line_features``. The read sites now resolve through +#: ``OMICS_ACCESSORS``, so all three pass and their markers are retired. The hook +#: stays so the next genuine defect gets a strict marker rather than a skip. +MODEL_DEFECTS: Final[dict[str, str]] = {} + +#: Models that train and predict but cannot be reloaded from a checkpoint, +#: mapped to the error their round-trip raises. Their set-dependent featurizers +#: (learned BPE merges, GSVA scores) are not part of the saved state. +SAVE_LOAD_DEFECTS: Final[dict[str, str]] = { + "PharmaFormer": "BpePharmaformerDrugFeaturizer must be fit before transform", + "Precily": "PathwaysCellLineFeaturizer must be fit before transform", +} + + +def build_partial_coverage_dataset() -> Dataset: + """Build a dataset whose omics modalities cover only some of the cell lines. + + :returns: Dataset that drives the predictors' NaN-filtering path. + """ + return build_synthetic_dataset(name="SYNTH_PARTIAL", omics_coverage=PARTIAL_COVERAGE) + + +def model_param(model_name: str, *, defects: Mapping[str, str] = MODEL_DEFECTS) -> ParameterSet: + """Wrap *model_name* as a ``pytest`` parameter, xfailing known defects strictly. + + Strict is the point: when the underlying defect is fixed the test starts + passing, the ``xpass`` fails the run, and whoever fixed it is told to delete + the marker instead of leaving a stale exemption behind. + + :param model_name: Model name as the test parametrizes it. + :param defects: Mapping of model name to the reason it is expected to fail. + :returns: Parameter carrying a strict xfail marker where applicable. + """ + reason = defects.get(model_name) + marks = [pytest.mark.xfail(reason=reason, strict=True)] if reason else [] + return pytest.param(model_name, marks=marks, id=model_name) diff --git a/tests/test_architecture_policy.py b/tests/test_architecture_policy.py new file mode 100644 index 000000000..23f0cea69 --- /dev/null +++ b/tests/test_architecture_policy.py @@ -0,0 +1,63 @@ +"""Architecture policy: no bridges, adapters, or deleted runtime modules.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from drevalpy.models import construct_model +from drevalpy.models._model_lookup import known_model_names +from drevalpy.models.drp_model import DRPModel + +_FORBIDDEN_MODULE_FRAGMENTS = ( + "_component_bridge", + "predictors.baselines", + "models.baselines", + "literature.public_models", + "predictors/literature/impl", + "_native_drp_model", + "composed_model", + "_factory_classes", + "_component_persistence", +) + + +def test_no_forbidden_modules_exist() -> None: + repo = Path(__file__).resolve().parents[1] / "drevalpy" + forbidden_paths = [] + for path in repo.rglob("*.py"): + text = str(path.relative_to(repo.parent)) + if any(fragment in text for fragment in _FORBIDDEN_MODULE_FRAGMENTS): + forbidden_paths.append(text) + assert not forbidden_paths, f"Forbidden modules remain: {forbidden_paths}" + + +def test_no_forbidden_runtime_imports_in_source() -> None: + repo = Path(__file__).resolve().parents[1] / "drevalpy" + hits = [] + needles = ( + "drevalpy.models._component_bridge", + "predictors.baselines", + "literature.public_models", + "restore_naive_to_components", + "restore_literature_to_components", + "drevalpy.models._native_drp_model", + "drevalpy.models.composed_model", + "drevalpy.models._factory_classes", + "drevalpy.models._component_persistence", + ) + for path in repo.rglob("*.py"): + content = path.read_text(encoding="utf-8") + for needle in needles: + if needle in content: + hits.append(f"{path.relative_to(repo.parent)}:{needle}") + assert not hits, f"Forbidden imports/references remain: {hits}" + + +@pytest.mark.parametrize("model_name", known_model_names(include_external=False)) +def test_construct_model_classes_are_drp_models(model_name: str) -> None: + cls = construct_model(model_name) + assert issubclass(cls, DRPModel) + assert cls.__module__ == "drevalpy.models" + assert cls._model_name == model_name diff --git a/tests/test_available_data.py b/tests/test_available_data.py deleted file mode 100644 index da0722624..000000000 --- a/tests/test_available_data.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Tests for the available datasets.""" - -from drevalpy.datasets import AVAILABLE_DATASETS - - -def test_factory() -> None: - """Test the dataset factory.""" - assert "GDSC1" in AVAILABLE_DATASETS - assert "GDSC2" in AVAILABLE_DATASETS - assert "CCLE" in AVAILABLE_DATASETS - assert "TOYv1" in AVAILABLE_DATASETS - assert "TOYv2" in AVAILABLE_DATASETS - assert "CTRPv1" in AVAILABLE_DATASETS - assert "CTRPv2" in AVAILABLE_DATASETS - assert "BeatAML2" in AVAILABLE_DATASETS - assert "PDX_Bruna" in AVAILABLE_DATASETS - assert len(AVAILABLE_DATASETS) == 9 diff --git a/tests/test_boundary.py b/tests/test_boundary.py new file mode 100644 index 000000000..9b0050667 --- /dev/null +++ b/tests/test_boundary.py @@ -0,0 +1,71 @@ +"""Tests for package boundary between components and models.""" + +from __future__ import annotations + +import importlib +from pathlib import Path + + +def test_native_component_registration_does_not_import_literature_models() -> None: + from drevalpy.registry.predictor import predictor_registry + + predictor_registry.clear() + try: + from drevalpy.registry._builtins import register_native_components + + register_native_components() + names = predictor_registry.list_names() + assert "elasticNet" in names + assert "naiveMean" in names + assert "dipk" not in names + assert "precily" not in names + finally: + predictor_registry.clear() + from drevalpy.registry._builtins import register_builtin_components + + register_builtin_components() + + +def test_component_featurizers_import_from_features_not_models_utils() -> None: + for module_name in ( + "drevalpy.components.featurizers.cell_line.scaled_gene_expression", + "drevalpy.components.featurizers.cell_line.normalized_proteomics", + ): + module = importlib.import_module(module_name) + source_path = module.__file__ + assert source_path is not None + text = Path(source_path).read_text(encoding="utf-8") + assert "drevalpy.models.utils" not in text + + +def test_component_predictors_avoid_models_utils() -> None: + for module_name in ( + "drevalpy.components.predictors.sklearn_models", + "drevalpy.components.predictors.naive", + "drevalpy.components.predictors.literature.dipk.predictor", + "drevalpy.components.predictors.neural_network.network", + ): + module = importlib.import_module(module_name) + source_path = module.__file__ + assert source_path is not None + text = Path(source_path).read_text(encoding="utf-8") + assert "drevalpy.models.utils" not in text + assert "drevalpy.models.lightning_metrics_mixin" not in text + + +def test_orchestration_lives_in_models_layer() -> None: + import drevalpy.components as components_pkg + import drevalpy.models.component_stack as component_stack + import drevalpy.models.config.io as models_config_io + import drevalpy.models.factory as models_factory + import drevalpy.models.zoo as models_zoo + + assert not hasattr(components_pkg, "ComposedModel") + assert not hasattr(components_pkg, "model_config_for_name") + assert not hasattr(components_pkg, "get_zoo_config") + assert models_factory.model_config_for_name.__module__ == "drevalpy.models.factory" + assert component_stack.build_component_stack.__module__ == "drevalpy.models.component_stack" + assert models_config_io.from_yaml.__module__ == "drevalpy.models.config.io" + assert models_config_io.from_spec.__module__ == "drevalpy.models.config.io" + assert models_factory.zoo_config.__module__ == "drevalpy.models.factory" + assert models_zoo.get_zoo_config.__module__ == "drevalpy.models.zoo" diff --git a/tests/test_cli_run_cv.py b/tests/test_cli_run_cv.py deleted file mode 100644 index cd3b2bef0..000000000 --- a/tests/test_cli_run_cv.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Tests for drevalpy.cli_run_cv.""" - -from __future__ import annotations - -import os -import pickle -import tempfile -from pathlib import Path - -import pandas as pd - -from drevalpy.cli_run_cv import run_load_response -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER - - -def test_run_load_response_uses_provided_path() -> None: - """Regression: ``run_load_response`` must read the given CSV path, not ``<stem>.csv`` in CWD.""" - with tempfile.TemporaryDirectory() as data_dir: - csv_path = Path(data_dir) / "custom_response.csv" - pd.DataFrame( - { - CELL_LINE_IDENTIFIER: ["CL1", "CL2"], - DRUG_IDENTIFIER: ["100", "200"], - "response": [0.1, 0.2], - } - ).to_csv(csv_path, index=False) - - with tempfile.TemporaryDirectory() as work_dir: - work_path = Path(work_dir) - before = set(work_path.iterdir()) - previous = os.getcwd() - try: - os.chdir(work_path) - run_load_response(response_dataset=str(csv_path), measure="response") - finally: - os.chdir(previous) - after = set(work_path.iterdir()) - assert after - before == {work_path / "response_dataset.pkl"} - - with open(work_path / "response_dataset.pkl", "rb") as handle: - loaded = pickle.load(handle) - - assert isinstance(loaded, DrugResponseDataset) - assert loaded.dataset_name == "custom_response" - assert list(loaded.cell_line_ids) == ["CL1", "CL2"] - assert list(loaded.drug_ids) == ["100", "200"] diff --git a/tests/test_cv_split_integrity.py b/tests/test_cv_split_integrity.py deleted file mode 100644 index 17217241f..000000000 --- a/tests/test_cv_split_integrity.py +++ /dev/null @@ -1,427 +0,0 @@ -"""Tests for CV split integrity - ensuring no data leakage and proper tissue column handling. - -These tests verify the fixes for: - -- GitHub Issue #349: Validation data accumulation bug - where validation data was - inadvertently accumulated into training data across sequential model runs. -- Tissue column preservation when loading splits from CSV files. -""" - -import tempfile -from pathlib import Path - -import numpy as np -import pytest - -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.experiment import get_datasets_from_cv_split -from drevalpy.models import MODEL_FACTORY - - -class TestTissueColumnPreservation: - """Tests for tissue column preservation when saving/loading splits.""" - - def test_tissue_column_preserved_in_csv_roundtrip(self): - """Test that tissue column is preserved when saving and loading from CSV.""" - # Create dataset with tissue information - dataset = DrugResponseDataset( - response=np.array([1.0, 2.0, 3.0, 4.0, 5.0]), - cell_line_ids=np.array(["CL1", "CL2", "CL3", "CL4", "CL5"]), - drug_ids=np.array(["D1", "D2", "D3", "D4", "D5"]), - tissues=np.array(["Breast", "Lung", "Kidney", "Brain", "Liver"]), - ) - - with tempfile.TemporaryDirectory() as temp_dir: - csv_path = Path(temp_dir) / "test_dataset.csv" - dataset.to_csv(csv_path) - - # Load the dataset back - tissue_column now defaults to "tissue" - loaded_dataset = DrugResponseDataset.from_csv(csv_path) - - assert loaded_dataset.tissue is not None, "Tissue column should be loaded" - np.testing.assert_array_equal( - loaded_dataset.tissue, - dataset.tissue, - err_msg="Tissue values should match after roundtrip", - ) - - def test_tissue_column_preserved_in_split_roundtrip(self): - """Test that tissue column is preserved when saving and loading CV splits.""" - # Create dataset with tissue information - dataset = DrugResponseDataset( - response=np.random.random(100), - cell_line_ids=np.repeat([f"CL-{i}" for i in range(10)], 10), - drug_ids=np.tile([f"Drug-{i}" for i in range(10)], 10), - tissues=np.array( - ["Breast", "Lung", "Kidney", "Brain", "Liver", "Pancreas", "Colon", "Skin", "Bone", "Blood"] * 10 - ), - ) - - # Create splits - dataset.split_dataset(n_cv_splits=3, mode="LPO", split_validation=True, validation_ratio=0.5, random_state=42) - - with tempfile.TemporaryDirectory() as temp_dir: - # Save splits - dataset.save_splits(path=temp_dir) - - # Create new dataset and load splits - new_dataset = DrugResponseDataset( - response=np.array([]), - cell_line_ids=np.array([]), - drug_ids=np.array([]), - dataset_name=dataset.dataset_name, - ) - new_dataset.load_splits(path=temp_dir) - - # Check that tissue is preserved in all splits - for i, split in enumerate(new_dataset.cv_splits): - for split_name in ["train", "test", "validation", "validation_es", "early_stopping"]: - if split_name in split: - assert ( - split[split_name].tissue is not None - ), f"Tissue column should be loaded for split {i} {split_name}" - assert len(split[split_name].tissue) == len( - split[split_name].response - ), f"Tissue array length should match response length for split {i} {split_name}" - - def test_from_csv_default_tissue_column(self): - """Test that from_csv defaults to loading tissue column when present.""" - dataset = DrugResponseDataset( - response=np.array([1.0, 2.0, 3.0]), - cell_line_ids=np.array(["CL1", "CL2", "CL3"]), - drug_ids=np.array(["D1", "D2", "D3"]), - tissues=np.array(["Breast", "Lung", "Kidney"]), - ) - - with tempfile.TemporaryDirectory() as temp_dir: - csv_path = Path(temp_dir) / "test.csv" - dataset.to_csv(csv_path) - - # Load without explicitly specifying tissue_column - should still load tissue - loaded = DrugResponseDataset.from_csv(csv_path) - assert loaded.tissue is not None - np.testing.assert_array_equal(loaded.tissue, dataset.tissue) - - def test_from_csv_missing_tissue_column(self): - """Test that from_csv handles missing tissue column gracefully.""" - # Create dataset without tissue - dataset = DrugResponseDataset( - response=np.array([1.0, 2.0, 3.0]), - cell_line_ids=np.array(["CL1", "CL2", "CL3"]), - drug_ids=np.array(["D1", "D2", "D3"]), - ) - - with tempfile.TemporaryDirectory() as temp_dir: - csv_path = Path(temp_dir) / "test.csv" - dataset.to_csv(csv_path) - - # Load - should handle missing tissue column gracefully - loaded = DrugResponseDataset.from_csv(csv_path) - assert loaded.tissue is None - - -class TestCVSplitDataLeakage: - """Tests for ensuring no data leakage between models when using CV splits.""" - - @pytest.fixture - def sample_cv_split(self): - """Create a sample CV split with all dataset types. - - :returns: dictionary with train, validation, validation_es, early_stopping, and test datasets - """ - rng = np.random.default_rng(42) - n_samples = 100 - - train = DrugResponseDataset( - response=rng.random(n_samples), - cell_line_ids=np.array([f"CL-{i}" for i in range(n_samples)]), - drug_ids=np.array([f"Drug-{i % 10}" for i in range(n_samples)]), - tissues=np.array([f"Tissue-{i % 5}" for i in range(n_samples)]), - ) - - validation = DrugResponseDataset( - response=rng.random(30), - cell_line_ids=np.array([f"CL-V{i}" for i in range(30)]), - drug_ids=np.array([f"Drug-{i % 10}" for i in range(30)]), - tissues=np.array([f"Tissue-{i % 5}" for i in range(30)]), - ) - - validation_es = DrugResponseDataset( - response=rng.random(20), - cell_line_ids=np.array([f"CL-VE{i}" for i in range(20)]), - drug_ids=np.array([f"Drug-{i % 10}" for i in range(20)]), - tissues=np.array([f"Tissue-{i % 5}" for i in range(20)]), - ) - - early_stopping = DrugResponseDataset( - response=rng.random(10), - cell_line_ids=np.array([f"CL-ES{i}" for i in range(10)]), - drug_ids=np.array([f"Drug-{i % 10}" for i in range(10)]), - tissues=np.array([f"Tissue-{i % 5}" for i in range(10)]), - ) - - test = DrugResponseDataset( - response=rng.random(25), - cell_line_ids=np.array([f"CL-T{i}" for i in range(25)]), - drug_ids=np.array([f"Drug-{i % 10}" for i in range(25)]), - tissues=np.array([f"Tissue-{i % 5}" for i in range(25)]), - ) - - return { - "train": train, - "validation": validation, - "validation_es": validation_es, - "early_stopping": early_stopping, - "test": test, - } - - def test_get_datasets_returns_copies(self, sample_cv_split): - """Test that get_datasets_from_cv_split returns copies, not references. - - :param sample_cv_split: pytest fixture providing sample CV split data - """ - model_class = MODEL_FACTORY["ElasticNet"] - - train, val, es, test = get_datasets_from_cv_split( - split=sample_cv_split, model_class=model_class, model_name="ElasticNet" - ) - - # Verify that returned datasets are copies, not references - assert train is not sample_cv_split["train"] - assert val is not sample_cv_split["validation"] - assert test is not sample_cv_split["test"] - - # Verify that modifying returned datasets doesn't affect originals - original_train_len = len(sample_cv_split["train"].response) - train.add_rows(val) - - assert ( - len(sample_cv_split["train"].response) == original_train_len - ), "Original train dataset should not be modified when adding rows to the copy" - - def test_get_datasets_returns_copies_with_early_stopping(self, sample_cv_split): - """Test that early stopping datasets are also copies. - - :param sample_cv_split: pytest fixture providing sample CV split data - """ - model_class = MODEL_FACTORY["SimpleNeuralNetwork"] # Uses early stopping - - train, val, es, test = get_datasets_from_cv_split( - split=sample_cv_split, model_class=model_class, model_name="SimpleNeuralNetwork" - ) - - # For early stopping models, validation should be validation_es - assert val is not sample_cv_split["validation_es"] - assert es is not sample_cv_split["early_stopping"] - - # Verify modifications don't affect originals - original_es_len = len(sample_cv_split["early_stopping"].response) - es.remove_rows(np.array([0, 1, 2])) - - assert ( - len(sample_cv_split["early_stopping"].response) == original_es_len - ), "Original early_stopping dataset should not be modified" - - def test_no_validation_accumulation_across_models(self, sample_cv_split): - """Test that validation data is not accumulated into training data across models. - - This is the core test for the bug fix in GitHub Issue #349. - - :param sample_cv_split: pytest fixture providing sample CV split data - """ - original_train_len = len(sample_cv_split["train"].response) - original_val_len = len(sample_cv_split["validation"].response) - - # Simulate running multiple models sequentially - models_to_test = ["ElasticNet", "RandomForest", "ElasticNet"] - - for model_name in models_to_test: - model_class = MODEL_FACTORY[model_name] - - train, val, es, test = get_datasets_from_cv_split( - split=sample_cv_split, model_class=model_class, model_name=model_name - ) - - # Simulate what happens in experiment.py: train.add_rows(val) - train.add_rows(val) - - # After each model, verify original split is unchanged - assert len(sample_cv_split["train"].response) == original_train_len, ( - f"Original train dataset should remain {original_train_len} samples " - f"after processing {model_name}, but got {len(sample_cv_split['train'].response)}" - ) - assert len(sample_cv_split["validation"].response) == original_val_len, ( - f"Original validation dataset should remain {original_val_len} samples " - f"after processing {model_name}" - ) - - def test_no_test_data_leakage(self, sample_cv_split): - """Test that test data is never added to training data. - - :param sample_cv_split: pytest fixture providing sample CV split data - """ - original_test_cell_lines = set(sample_cv_split["test"].cell_line_ids) - - for model_name in ["ElasticNet", "SimpleNeuralNetwork"]: - model_class = MODEL_FACTORY[model_name] - - train, val, es, test = get_datasets_from_cv_split( - split=sample_cv_split, model_class=model_class, model_name=model_name - ) - - # Simulate adding validation to train (as done in experiment.py) - train.add_rows(val) - - # Verify no test cell lines leaked into training - train_cell_lines = set(train.cell_line_ids) - - # Check for intersection (should be empty for our test data setup) - leaked_cell_lines = train_cell_lines & original_test_cell_lines - assert len(leaked_cell_lines) == 0, f"Test cell lines leaked into training: {leaked_cell_lines}" - - def test_datasets_have_correct_sizes(self, sample_cv_split): - """Test that returned datasets have the expected sizes. - - :param sample_cv_split: pytest fixture providing sample CV split data - """ - model_class = MODEL_FACTORY["ElasticNet"] - - train, val, es, test = get_datasets_from_cv_split( - split=sample_cv_split, model_class=model_class, model_name="ElasticNet" - ) - - # For non-early-stopping models, validation should be from "validation" key - assert len(train.response) == len(sample_cv_split["train"].response) - assert len(val.response) == len(sample_cv_split["validation"].response) - assert len(test.response) == len(sample_cv_split["test"].response) - assert es is None # ElasticNet doesn't use early stopping - - def test_datasets_have_correct_sizes_early_stopping(self, sample_cv_split): - """Test that early stopping models get correct dataset sizes. - - :param sample_cv_split: pytest fixture providing sample CV split data - """ - model_class = MODEL_FACTORY["SimpleNeuralNetwork"] - - train, val, es, test = get_datasets_from_cv_split( - split=sample_cv_split, model_class=model_class, model_name="SimpleNeuralNetwork" - ) - - # For early-stopping models, validation should be from "validation_es" key - assert len(train.response) == len(sample_cv_split["train"].response) - assert len(val.response) == len(sample_cv_split["validation_es"].response) - assert len(es.response) == len(sample_cv_split["early_stopping"].response) - assert len(test.response) == len(sample_cv_split["test"].response) - - def test_tissue_preserved_in_get_datasets(self, sample_cv_split): - """Test that tissue information is preserved when getting datasets from split. - - :param sample_cv_split: pytest fixture providing sample CV split data - """ - model_class = MODEL_FACTORY["ElasticNet"] - - train, val, es, test = get_datasets_from_cv_split( - split=sample_cv_split, model_class=model_class, model_name="ElasticNet" - ) - - # Verify tissue is preserved in all returned datasets - assert train.tissue is not None - assert val.tissue is not None - assert test.tissue is not None - - # Verify tissue values match originals - np.testing.assert_array_equal(train.tissue, sample_cv_split["train"].tissue) - np.testing.assert_array_equal(val.tissue, sample_cv_split["validation"].tissue) - np.testing.assert_array_equal(test.tissue, sample_cv_split["test"].tissue) - - -class TestSingleDrugModelSplits: - """Tests specific to single-drug model handling in CV splits.""" - - @pytest.fixture - def sample_cv_split_multi_drug(self): - """Create a sample CV split with multiple drugs for single-drug model testing. - - :returns: dictionary with train, validation, and test datasets containing multiple drugs - """ - rng = np.random.default_rng(42) - - # Create data with multiple drugs - drugs = ["DrugA", "DrugB", "DrugC"] - n_per_drug = 20 - - train = DrugResponseDataset( - response=rng.random(n_per_drug * len(drugs)), - cell_line_ids=np.array([f"CL-{i}" for i in range(n_per_drug * len(drugs))]), - drug_ids=np.array(drugs * n_per_drug), - tissues=np.array([f"Tissue-{i % 3}" for i in range(n_per_drug * len(drugs))]), - ) - - validation = DrugResponseDataset( - response=rng.random(10 * len(drugs)), - cell_line_ids=np.array([f"CL-V{i}" for i in range(10 * len(drugs))]), - drug_ids=np.array(drugs * 10), - tissues=np.array([f"Tissue-{i % 3}" for i in range(10 * len(drugs))]), - ) - - test = DrugResponseDataset( - response=rng.random(5 * len(drugs)), - cell_line_ids=np.array([f"CL-T{i}" for i in range(5 * len(drugs))]), - drug_ids=np.array(drugs * 5), - tissues=np.array([f"Tissue-{i % 3}" for i in range(5 * len(drugs))]), - ) - - return { - "train": train, - "validation": validation, - "test": test, - } - - def test_single_drug_model_masks_correctly(self, sample_cv_split_multi_drug): - """Test that single-drug models only get data for their specific drug. - - :param sample_cv_split_multi_drug: pytest fixture providing sample CV split with multiple drugs - """ - from drevalpy.models import SINGLE_DRUG_MODEL_FACTORY - - if len(SINGLE_DRUG_MODEL_FACTORY) == 0: - pytest.skip("No single-drug models available") - - # Get the first available single-drug model - model_name = list(SINGLE_DRUG_MODEL_FACTORY.keys())[0] - model_class = SINGLE_DRUG_MODEL_FACTORY[model_name] - - target_drug = "DrugA" - - train, val, es, test = get_datasets_from_cv_split( - split=sample_cv_split_multi_drug, model_class=model_class, model_name=model_name, drug_id=target_drug - ) - - # Verify only target drug data is returned - assert all(drug == target_drug for drug in train.drug_ids) - assert all(drug == target_drug for drug in val.drug_ids) - assert all(drug == target_drug for drug in test.drug_ids) - - def test_single_drug_model_doesnt_modify_original(self, sample_cv_split_multi_drug): - """Test that single-drug model masking doesn't modify original split. - - :param sample_cv_split_multi_drug: pytest fixture providing sample CV split with multiple drugs - """ - from drevalpy.models import SINGLE_DRUG_MODEL_FACTORY - - if len(SINGLE_DRUG_MODEL_FACTORY) == 0: - pytest.skip("No single-drug models available") - - model_name = list(SINGLE_DRUG_MODEL_FACTORY.keys())[0] - model_class = SINGLE_DRUG_MODEL_FACTORY[model_name] - - original_train_len = len(sample_cv_split_multi_drug["train"].response) - original_drugs = set(sample_cv_split_multi_drug["train"].drug_ids) - - train, val, es, test = get_datasets_from_cv_split( - split=sample_cv_split_multi_drug, model_class=model_class, model_name=model_name, drug_id="DrugA" - ) - - # Original should be unchanged - assert len(sample_cv_split_multi_drug["train"].response) == original_train_len - assert set(sample_cv_split_multi_drug["train"].drug_ids) == original_drugs diff --git a/tests/test_dataset.py b/tests/test_dataset.py deleted file mode 100644 index 2bc6be37e..000000000 --- a/tests/test_dataset.py +++ /dev/null @@ -1,720 +0,0 @@ -"""Tests for the DrugResponseDataset and the FeatureDataset class.""" - -import shutil -import tempfile -from pathlib import Path - -import networkx as nx -import numpy as np -import pandas as pd -import pytest -from flaky import flaky - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.datasets.loader import load_dataset -from drevalpy.utils import get_response_transformation - -# Tests for the DrugResponseDataset class - - -def test_response_dataset_load() -> None: - """Test if the dataset loads correctly from CSV files.""" - # Create a temporary CSV file with mock data - data = { - "cell_line_id": np.array(["1", "2", "3"]), - "drug_id": np.array(["A", "B", "C"]), - "response": np.array([0.1, 0.2, 0.3]), - } - dataset = DrugResponseDataset( - cell_line_ids=data["cell_line_id"], - drug_ids=data["drug_id"], - response=data["response"], - ) - dataset_path = Path("dataset.csv") - dataset.to_csv(dataset_path) - del dataset - # Load the dataset - dataset = DrugResponseDataset.from_csv(dataset_path) - - dataset_path.unlink() - - # Check if the dataset loaded correctly - assert np.array_equal(dataset.cell_line_ids, data["cell_line_id"]) - assert np.array_equal(dataset.drug_ids, data["drug_id"]) - assert np.allclose(dataset.response, data["response"]) - - -def test_fitting_and_loading_custom_dataset(sample_dataset: DrugResponseDataset, data_dir): - """ - Test CurveCurator fitting of raw viability dataset and loading it. - - :param sample_dataset: sample viability dataset - :param data_dir: path to the data directory - """ - assert sample_dataset.dataset_name == "TOYv1" - dataset_name = "CTRPv2_sample_test" - path_data = str(data_dir) - load_dataset( - dataset_name=dataset_name, - path_data=path_data, - measure="IC50", - curve_curator=True, - cores=200, - ) - for item in (data_dir / dataset_name).iterdir(): - if item.name == f"{dataset_name}_raw.csv": - continue - if item.is_dir(): - shutil.rmtree(item) - else: - item.unlink() - - -def _curve_function(x, wanted_ec50, front, back, slope): - return (front - back) / (1 + (x / wanted_ec50) ** slope) + back - - -def test_curvecurator_measures(): - """Tests if CurveCurator computes the response measures correctly.""" - temp_dir = tempfile.TemporaryDirectory() - path_to_temp_dir = Path(temp_dir.name) - Path.mkdir(path_to_temp_dir / "toy_curves", exist_ok=True) - - expected_ec50 = 6 - front = 1.0 - back = 0.3 - slope = 1.5 - xvals = 10 ** np.linspace(np.log10(0.001) - 2, np.log10(1000) + 2, 50) - yvals = _curve_function(xvals, expected_ec50, front, back, slope) - expected_ic50 = expected_ec50 * (((front - back) / (0.5 - back)) - 1) ** (1 / slope) - """ - import matplotlib.pyplot as plt - plt.scatter(xvals, yvals, s=1) - plt.xscale('log') - plt.show() - """ - df = pd.DataFrame({"dose": xvals, "response": yvals, "sample": "cell_line_1", "drug": "drug_1", "replicate": "1"}) - df.to_csv(path_to_temp_dir / "toy_curves" / "toy_curves_raw.csv", index=False) - load_dataset( - dataset_name="toy_curves", - path_data=str(path_to_temp_dir), - measure="IC50", - curve_curator=True, - cores=200, - ) - assert Path(path_to_temp_dir / "toy_curves" / "toy_curves.csv").exists() - df_processed = pd.read_csv(path_to_temp_dir / "toy_curves" / "toy_curves.csv", index_col=0) - # assert that df_processed["EC50_curvecurator"] is approximately expected_ec50 - assert np.isclose(df_processed.loc["cell_line_1|drug_1"]["EC50_curvecurator"], expected_ec50, atol=0.1) - assert np.isclose(df_processed.loc["cell_line_1|drug_1"]["IC50_curvecurator"], expected_ic50, atol=0.1) - assert round(np.log(df_processed.loc["cell_line_1|drug_1"]["IC50_curvecurator"]), 4) == round( - df_processed.loc["cell_line_1|drug_1"]["LN_IC50_curvecurator"], 4 - ) - assert round(-np.log10(df_processed.loc["cell_line_1|drug_1"]["EC50_curvecurator"] * 10**-6), 4) == round( - df_processed.loc["cell_line_1|drug_1"]["pEC50_curvecurator"], 4 - ) - - -def test_response_dataset_add_rows() -> None: - """Test if the add_rows method works correctly.""" - dataset1 = DrugResponseDataset( - response=np.array([1, 2, 3]), - cell_line_ids=np.array(["101", "102", "103"]), - drug_ids=np.array(["A", "B", "C"]), - tissues=np.array(["Tissue1", "Tissue2", "Tissue3"]), - ) - dataset2 = DrugResponseDataset( - response=np.array([4, 5, 6]), - cell_line_ids=np.array(["104", "105", "106"]), - drug_ids=np.array(["D", "E", "F"]), - tissues=np.array(["Tissue4", "Tissue5", "Tissue6"]), - ) - dataset1.add_rows(dataset2) - - assert np.array_equal(dataset1.response, np.array([1, 2, 3, 4, 5, 6])) - assert np.array_equal(dataset1.cell_line_ids, np.array(["101", "102", "103", "104", "105", "106"])) - assert np.array_equal(dataset1.drug_ids, np.array(["A", "B", "C", "D", "E", "F"])) - assert np.array_equal(dataset1.tissue, np.array(["Tissue1", "Tissue2", "Tissue3", "Tissue4", "Tissue5", "Tissue6"])) - - -def test_remove_nan_responses() -> None: - """Test if the remove_nan_responses method works correctly.""" - dataset = DrugResponseDataset( - response=np.array([1, 2, 3, np.nan, 5, 6]), - cell_line_ids=np.array(["101", "102", "103", "104", "105", "106"]), - drug_ids=np.array(["A", "B", "C", "D", "E", "F"]), - tissues=np.array(["Tissue1", "Tissue2", "Tissue3", "Tissue4", "Tissue5", "Tissue6"]), - ) - dataset.remove_nan_responses() - assert np.array_equal(dataset.response, np.array([1, 2, 3, 5, 6])) - assert np.array_equal(dataset.cell_line_ids, np.array(["101", "102", "103", "105", "106"])) - assert np.array_equal(dataset.drug_ids, np.array(["A", "B", "C", "E", "F"])) - assert np.array_equal(dataset.tissue, np.array(["Tissue1", "Tissue2", "Tissue3", "Tissue5", "Tissue6"])) - - -def test_response_dataset_shuffle(): - """Test if the shuffle method works correctly.""" - # Create a dataset with known values - dataset = DrugResponseDataset( - response=np.array([1, 2, 3, 4, 5, 6]), - cell_line_ids=np.array(["101", "102", "103", "104", "105", "106"]), - drug_ids=np.array(["A", "B", "C", "D", "E", "F"]), - tissues=np.array(["Tissue1", "Tissue2", "Tissue3", "Tissue4", "Tissue5", "Tissue6"]), - ) - - # Shuffle the dataset - dataset.shuffle(random_state=42) - - # Check if the length remains the same - assert len(dataset.response) == 6 - assert len(dataset.cell_line_ids) == 6 - assert len(dataset.drug_ids) == 6 - assert len(dataset.tissue) == 6 - - # Check if the response, cell_line_ids, and drug_ids arrays are shuffled - assert not np.array_equal(dataset.response, np.array([1, 2, 3, 4, 5, 6])) - assert not np.array_equal(dataset.cell_line_ids, np.array(["101", "102", "103", "104", "105", "106"])) - assert not np.array_equal(dataset.drug_ids, np.array(["A", "B", "C", "D", "E", "F"])) - assert not np.array_equal( - dataset.tissue, np.array(["Tissue1", "Tissue2", "Tissue3", "Tissue4", "Tissue5", "Tissue6"]) - ) - - -def test_response_data_remove_drugs_and_cell_lines(): - """Test if the remove_drugs and remove_cell_lines methods work correctly.""" - # Create a dataset with known values - dataset = DrugResponseDataset( - response=np.array([1, 2, 3, 4, 5]), - cell_line_ids=np.array(["101", "102", "103", "104", "105"]), - drug_ids=np.array(["A", "B", "C", "D", "E"]), - tissues=np.array(["Tissue1", "Tissue2", "Tissue3", "Tissue4", "Tissue5"]), - ) - - # Remove specific drugs and cell lines - dataset._remove_drugs(["A", "C"]) - dataset._remove_cell_lines(["101", "103"]) - - # Check if the removed drugs and cell lines are not present in the dataset - assert "A" not in dataset.drug_ids - assert "C" not in dataset.drug_ids - assert "101" not in dataset.cell_line_ids - assert "103" not in dataset.cell_line_ids - - # Check if the length of response, cell_line_ids, and drug_ids arrays is reduced accordingly - assert len(dataset.response) == 3 - assert len(dataset.cell_line_ids) == 3 - assert len(dataset.drug_ids) == 3 - assert len(dataset.tissue) == 3 - - -def test_remove_rows(): - """Test if the remove_rows method works correctly.""" - dataset = DrugResponseDataset( - response=np.array([1, 2, 3, 4, 5]), - cell_line_ids=np.array(["101", "102", "103", "104", "105"]), - drug_ids=np.array(["A", "B", "C", "D", "E"]), - tissues=np.array(["Tissue1", "Tissue2", "Tissue3", "Tissue4", "Tissue5"]), - ) - dataset.remove_rows(np.array([0, 2, 4])) - assert np.array_equal(dataset.response, np.array([2, 4])) - assert np.array_equal(dataset.cell_line_ids, np.array(["102", "104"])) - assert np.array_equal(dataset.drug_ids, np.array(["B", "D"])) - assert np.array_equal(dataset.tissue, np.array(["Tissue2", "Tissue4"])) - - -def test_response_dataset_reduce_to(): - """Test if the reduce_to method works correctly and handles edge cases.""" - # Case 1: Standard reduction - dataset = DrugResponseDataset( - response=np.array([1, 2, 3, 4, 5]), - cell_line_ids=np.array([101, 102, 103, 104, 105]), - drug_ids=np.array(["A", "B", "C", "D", "E"]), - tissues=np.array(["Tissue1", "Tissue2", "Tissue3", "Tissue4", "Tissue5"]), - ) - - dataset.reduce_to(cell_line_ids=np.array([102, 104]), drug_ids=np.array(["B", "D"])) - - assert all(cell_line_id in ["102", "104"] for cell_line_id in dataset.cell_line_ids) - assert all(drug_id in ["B", "D"] for drug_id in dataset.drug_ids) - assert len(dataset.response) == 2 - assert len(dataset.cell_line_ids) == 2 - assert len(dataset.drug_ids) == 2 - assert len(dataset.tissue) == 2 - - # Case 2: reduce_to(None, None) does nothing - dataset = DrugResponseDataset( - response=np.array([1, 2]), - cell_line_ids=np.array(["201", "202"]), - drug_ids=np.array(["X", "Y"]), - tissues=np.array(["T1", "T2"]), - ) - - dataset.reduce_to(cell_line_ids=None, drug_ids=None) - - assert len(dataset.response) == 2 - assert set(dataset.cell_line_ids) == {"201", "202"} - assert set(dataset.drug_ids) == {"X", "Y"} - - # Case 3: reduce_to with empty lists removes all - dataset = DrugResponseDataset( - response=np.array([1, 2]), - cell_line_ids=np.array(["301", "302"]), - drug_ids=np.array(["M", "N"]), - tissues=np.array(["T1", "T2"]), - ) - - dataset.reduce_to(cell_line_ids=np.array([]), drug_ids=np.array([])) - - assert len(dataset.response) == 0 - assert len(dataset.cell_line_ids) == 0 - assert len(dataset.drug_ids) == 0 - assert len(dataset.tissue) == 0 - - -@pytest.mark.parametrize("mode", ["LPO", "LCO", "LDO", "LTO"]) -@pytest.mark.parametrize("split_validation", [True, False]) -def test_split_response_dataset(mode: str, split_validation: bool) -> None: - """ - Test if the split_dataset method works correctly. - - :param mode: test_mode, either LPO, LCO, or LDO - :param split_validation: whether to split the dataset into validation and early stopping sets - """ - # Create a dataset with known values - dataset = DrugResponseDataset( - response=np.random.random(100), - cell_line_ids=np.repeat([f"CL-{i}" for i in range(1, 11)], 10), - drug_ids=np.tile([f"Drug-{i}" for i in range(1, 11)], 10), - tissues=np.array( - ["Breast", "Breast", "Lung", "Kidney", "Small intestine", "Brain", "Heart", "Pancreas", "Prostate", "Colon"] - * 10 - ), - ) - # 100 datapoints, 10 cell lines, 10 drugs - # LPO: With 10% validation, 5 folds -> in 1 fold: 20 samples in test, - # 80 in train+val -> 40 in train, - # 40 samples in validation -> 30 in val_es, 10 in early stopping - # LCO: With 10% validation, 5 folds -> - # in 1 fold: 2 cell lines in test, 8 in train + val -> - # 4 in train, 4 samples in validation -> 3 in val_es, 1 in early stopping - # LDO: With 10% validation, 5 folds -> - # in 1 fold: 2 drugs in test, 8 in train + val -> - # 4 in train, 4 samples in validation -> 3 in val_es, 1 in early stopping - - # Test splitting the dataset with the specified mode and validation split - cv_splits = dataset.split_dataset( - n_cv_splits=5, - mode=mode, - split_validation=split_validation, - validation_ratio=0.5, - random_state=42, - ) - assert isinstance(cv_splits, list) - assert len(cv_splits) == 5 # Check if the correct number of splits is returned - for split in cv_splits: - assert isinstance(split["train"], DrugResponseDataset) - assert isinstance(split["test"], DrugResponseDataset) - - # Check that drugs/cell lines in the training data are not present in the test data - if mode == "LCO": - train_cell_lines = set(split["train"].cell_line_ids) - test_cell_lines = set(split["test"].cell_line_ids) - - assert train_cell_lines.isdisjoint(test_cell_lines) - - if split_validation: # Only check if validation split is enabled - for val_es in [ - "validation", - "validation_es", - "early_stopping", - ]: - validation_cell_lines = set(split[val_es].cell_line_ids) - assert validation_cell_lines.isdisjoint( - test_cell_lines - ) # Check for disjointness between validation and test cell lines - - elif mode == "LDO": - train_drugs = set(split["train"].drug_ids) - test_drugs = set(split["test"].drug_ids) - - assert train_drugs.isdisjoint(test_drugs) - - if split_validation: # Only check if validation split is enabled - for val_es in [ - "validation", - "validation_es", - "early_stopping", - ]: - validation_drugs = set(split[val_es].drug_ids) - assert validation_drugs.isdisjoint( - test_drugs - ) # Check for disjointness between validation and test drugs - - elif mode == "LPO": - train_pairs = set(zip(split["train"].cell_line_ids, split["train"].drug_ids, strict=True)) - test_pairs = set(zip(split["test"].cell_line_ids, split["test"].drug_ids, strict=True)) - - assert train_pairs.isdisjoint(test_pairs) - - if split_validation: # Only check if validation split is enabled - for val_es in [ - "validation", - "validation_es", - "early_stopping", - ]: - validation_pairs = set(zip(split[val_es].cell_line_ids, split[val_es].drug_ids, strict=True)) - assert validation_pairs.isdisjoint( - test_pairs - ) # Check for disjointness between validation and test pairs - - tempdir = tempfile.TemporaryDirectory() - dataset.save_splits(path=tempdir.name) - dataset.load_splits(path=tempdir.name) - - -@pytest.mark.parametrize("resp_transform", ["standard", "minmax", "robust"]) -def test_transform(resp_transform: str): - """ - Test if the fit_transform and inverse_transform methods work correctly. - - :param resp_transform: response transformation method - :raises ValueError: if an invalid response transformation method is provided - """ - from sklearn.preprocessing import MinMaxScaler, RobustScaler, StandardScaler - - dataset = DrugResponseDataset( - response=np.array([1, 2, 3, 4, 5]), - cell_line_ids=np.array(["101", "102", "103", "104", "105"]), - drug_ids=np.array(["A", "B", "C", "D", "E"]), - tissues=np.array(["Tissue1", "Tissue2", "Tissue3", "Tissue4", "Tissue5"]), - ) - transform = get_response_transformation(resp_transform) - dataset.fit_transform(transform) - if resp_transform == "standard": - scaler = StandardScaler() - elif resp_transform == "minmax": - scaler = MinMaxScaler() - elif resp_transform == "robust": - scaler = RobustScaler() - else: - raise ValueError("Invalid response transformation method.") - vals = scaler.fit_transform(np.array([1, 2, 3, 4, 5]).reshape(-1, 1)) - assert np.allclose(dataset.response, vals.flatten()) - - dataset.inverse_transform(transform) - assert np.allclose(dataset.response, np.array([1, 2, 3, 4, 5])) - - -# Tests for the FeatureDataset class - - -@pytest.fixture -def sample_feature_dataset() -> FeatureDataset: - """ - Create a sample FeatureDataset for testing. - - :returns: a sample FeatureDataset - """ - features = { - "drug1": { - "fingerprints": np.random.rand(5), - "chemical_features": np.random.rand(5), - }, - "drug2": { - "fingerprints": np.random.rand(5), - "chemical_features": np.random.rand(5), - }, - "drug3": { - "fingerprints": np.random.rand(5), - "chemical_features": np.random.rand(5), - }, - "drug4": { - "fingerprints": np.random.rand(5), - "chemical_features": np.random.rand(5), - }, - "drug5": { - "fingerprints": np.random.rand(5), - "chemical_features": np.random.rand(5), - }, - } - meta_info = { - "fingerprints": ["Dim1", "Dim2", "Dim3", "Dim4", "Dim5"], - "chemical_features": [ - "Feature1", - "Feature2", - "Feature3", - "Feature4", - "Feature5", - ], - } - return FeatureDataset(features=features, meta_info=meta_info) - - -def random_power_law_graph(size: int = 20) -> nx.Graph: - """ - Create a random graph with power law degree distribution. - - :param size: size of the graph - :returns: a random graph with power law degree distribution - """ - # make a graph with degrees distributed as a power law - graph = nx.Graph() - degrees = np.round(nx.utils.powerlaw_sequence(size, 2.5)) - graph.add_nodes_from(range(size)) - graph = nx.expected_degree_graph(degrees, selfloops=False) - # only extract largest connected component - largest_cc = max(nx.connected_components(graph), key=len) - graph = graph.subgraph(largest_cc).copy() - # assign edge attributes - for u, v in graph.edges(): - graph[u][v]["original_edge"] = f"({u}_{v})" - return graph - - -@pytest.fixture -def graph_dataset() -> FeatureDataset: - """ - Create a sample FeatureDataset with molecular graphs for testing. - - :returns: a sample FeatureDataset with molecular graphs - """ - features = { - "drug1": { - "molecular_graph": random_power_law_graph(), - }, - "drug2": { - "molecular_graph": random_power_law_graph(), - }, - "drug3": { - "molecular_graph": random_power_law_graph(), - }, - "drug4": { - "molecular_graph": random_power_law_graph(), - }, - "drug5": { - "molecular_graph": random_power_law_graph(), - }, - } - meta_info = { - "molecular_graph": "Atom graph created with power law", - } - return FeatureDataset(features=features, meta_info=meta_info) - - -def test_feature_dataset_get_ids(sample_feature_dataset: FeatureDataset) -> None: - """ - Test if the get_ids method works correctly. - - :param sample_feature_dataset: sample FeatureDataset - """ - assert np.all(sample_feature_dataset.identifiers == ["drug1", "drug2", "drug3", "drug4", "drug5"]) - - -def test_feature_dataset_get_view_names(sample_feature_dataset: FeatureDataset) -> None: - """ - Test if the get_view_names method works correctly. - - :param sample_feature_dataset: sample FeatureDataset - """ - assert sample_feature_dataset.view_names == [ - "fingerprints", - "chemical_features", - ] - - -def test_feature_dataset_get_feature_matrix(sample_feature_dataset: FeatureDataset) -> None: - """ - Test if the get_feature_matrix method works correctly. - - :param sample_feature_dataset: sample FeatureDataset - """ - feature_matrix = sample_feature_dataset.get_feature_matrix("fingerprints", np.array(["drug1", "drug2"])) - assert feature_matrix.shape == (2, 5) - assert np.allclose( - feature_matrix, - np.array( - [ - sample_feature_dataset.features["drug1"]["fingerprints"], - sample_feature_dataset.features["drug2"]["fingerprints"], - ] - ), - ) - assert isinstance(feature_matrix, np.ndarray) - - -def test_feature_dataset_copy(sample_feature_dataset: FeatureDataset) -> None: - """ - Test if the copy method works correctly. - - :param sample_feature_dataset: sample FeatureDataset - """ - copied_dataset = sample_feature_dataset.copy() - assert ( - copied_dataset.features["drug1"]["fingerprints"] is not sample_feature_dataset.features["drug1"]["fingerprints"] - ) - assert np.allclose( - copied_dataset.features["drug1"]["fingerprints"], - sample_feature_dataset.features["drug1"]["fingerprints"], - ) - assert copied_dataset.features is not sample_feature_dataset.features - copied_dataset.features["drug1"]["fingerprints"] = np.zeros(5) - assert not np.allclose( - copied_dataset.features["drug1"]["fingerprints"], - sample_feature_dataset.features["drug1"]["fingerprints"], - ) - - -@flaky(max_runs=25) # permutation randomization might map to the same feature vector for some tries -def test_permutation_randomization(sample_feature_dataset: FeatureDataset) -> None: - """ - Test if the permutation randomization works correctly. - - :param sample_feature_dataset: sample FeatureDataset - """ - views_to_randomize, randomization_type = "fingerprints", "permutation" - start_sample_dataset = sample_feature_dataset.copy() - sample_feature_dataset.randomize_features(views_to_randomize, randomization_type) - for drug, features in sample_feature_dataset.features.items(): - assert not np.allclose( - features[views_to_randomize], - start_sample_dataset.features[drug][views_to_randomize], - ) - - -@flaky(max_runs=25) # permutation randomization might map to the same feature vector for some tries -def test_permutation_randomization_graph(graph_dataset: FeatureDataset) -> None: - """ - Test if the permutation randomization works correctly for molecular graphs. - - :param graph_dataset: sample FeatureDataset with molecular graphs - """ - views_to_randomize, randomization_type = "molecular_graph", "permutation" - start_graph_dataset = graph_dataset.copy() - graph_dataset.randomize_features(views_to_randomize, randomization_type) - for drug, features in graph_dataset.features.items(): - # assert that drugs have different molecular graphs now - assert not nx.is_isomorphic( - features[views_to_randomize], - start_graph_dataset.features[drug][views_to_randomize], - ) - - -def test_invariant_randomization_array(sample_feature_dataset: FeatureDataset) -> None: - """ - Test if the invariant randomization works correctly. - - :param sample_feature_dataset: sample FeatureDataset - """ - views_to_randomize, randomization_type = "chemical_features", "invariant" - start_sample_dataset = sample_feature_dataset.copy() - sample_feature_dataset.randomize_features(views_to_randomize, randomization_type) - for drug, features in sample_feature_dataset.features.items(): - assert not np.allclose( - features[views_to_randomize], - start_sample_dataset.features[drug][views_to_randomize], - ) - - -@flaky(max_runs=5) # expected degree randomization might produce the same graph -def test_invariant_randomization_graph(graph_dataset: FeatureDataset) -> None: - """ - Test if the invariant randomization works correctly for molecular graphs. - - :param graph_dataset: sample FeatureDataset with molecular graphs - """ - views_to_randomize, randomization_type = "molecular_graph", "invariant" - start_graph_dataset = graph_dataset.copy() - graph_dataset.randomize_features(views_to_randomize, randomization_type) - for drug, features in graph_dataset.features.items(): - assert not nx.is_isomorphic( - features[views_to_randomize], - start_graph_dataset.features[drug][views_to_randomize], - ) - - -def test_add_features(sample_feature_dataset: FeatureDataset, graph_dataset: FeatureDataset) -> None: - """ - Test if the add_features method works correctly. - - :param sample_feature_dataset: sample FeatureDataset - :param graph_dataset: sample FeatureDataset with molecular graphs - """ - sample_feature_dataset.add_features(graph_dataset) - assert sample_feature_dataset.meta_info is not None - assert "molecular_graph" in sample_feature_dataset.meta_info - assert "molecular_graph" in sample_feature_dataset.view_names - - -def test_feature_dataset_csv_meta_handling(): - """Test `from_csv` and `to_csv` methods with and without meta_info handling.""" - with tempfile.TemporaryDirectory() as temp_dir: - temp_dir = Path(temp_dir) - - # ------------------------------------ - # 0. Create initial test DataFrame/CSV - # ------------------------------------ - df_with_named_cols = pd.DataFrame( - { - "id": ["A", "B", "C"], - "feature_1": [1.0, 2.0, 3.0], - "feature_2": [4.0, 5.0, 6.0], - } - ) - csv_with_meta = temp_dir / "input_with_meta.csv" - df_with_named_cols.to_csv(csv_with_meta, index=False) - - view_name = "example_view" - - # ------------------------------------ - # 1. Load from CSV → should extract meta_info - # ------------------------------------ - dataset = FeatureDataset.from_csv( - path_to_csv=csv_with_meta, - id_column="id", - view_name=view_name, - ) - - assert dataset.meta_info == {view_name: ["feature_1", "feature_2"]} - assert set(dataset.identifiers) == {"A", "B", "C"} - assert dataset.view_names == [view_name] - - # ------------------------------------ - # 2. Save with meta_info → column names should be preserved - # ------------------------------------ - csv_out_with_meta = temp_dir / "saved_with_meta.csv" - dataset.to_csv(csv_out_with_meta, id_column="id", view_name=view_name) - - saved_df = pd.read_csv(csv_out_with_meta) - pd.testing.assert_frame_equal(saved_df, df_with_named_cols, check_dtype=False) - - # ------------------------------------ - # 3. Save without meta_info → fallback to generic feature_0, feature_1 - # ------------------------------------ - dataset._meta_info = {} # simulate no meta info - csv_out_no_meta = temp_dir / "saved_no_meta.csv" - dataset.to_csv(csv_out_no_meta, id_column="id", view_name=view_name) - - df_fallback = pd.DataFrame( - { - "id": ["A", "B", "C"], - "feature_0": [1.0, 2.0, 3.0], - "feature_1": [4.0, 5.0, 6.0], - } - ) - saved_fallback_df = pd.read_csv(csv_out_no_meta) - pd.testing.assert_frame_equal(saved_fallback_df, df_fallback, check_dtype=False) - - # ------------------------------------ - # 4. Load fallback CSV → should reconstruct generic meta_info - # ------------------------------------ - dataset_fallback = FeatureDataset.from_csv( - path_to_csv=csv_out_no_meta, - id_column="id", - view_name=view_name, - ) - - assert dataset_fallback.meta_info == {view_name: ["feature_0", "feature_1"]} - np.testing.assert_array_equal( - dataset_fallback.features["B"][view_name], - np.array([2.0, 5.0]), - ) diff --git a/tests/test_drp_model.py b/tests/test_drp_model.py deleted file mode 100644 index b44dc38f6..000000000 --- a/tests/test_drp_model.py +++ /dev/null @@ -1,427 +0,0 @@ -"""Tests for the DRPModel.""" - -import os -import tempfile -from typing import Optional - -import numpy as np -import pandas as pd -import pytest - -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.datasets.utils import CELL_LINE_IDENTIFIER, DRUG_IDENTIFIER, TISSUE_IDENTIFIER -from drevalpy.models import MODEL_FACTORY -from drevalpy.models.utils import ( - get_multiomics_feature_dataset, - iterate_features, - load_and_select_gene_features, - load_cl_ids_and_tissues_from_csv, - load_cl_ids_from_csv, - load_drug_fingerprint_features, - load_drug_ids_from_csv, - load_tissues_from_csv, - unique, -) - - -def test_factory() -> None: - """Test the model factory.""" - assert "NaivePredictor" in MODEL_FACTORY - assert "NaiveDrugMeanPredictor" in MODEL_FACTORY - assert "NaiveCellLineMeanPredictor" in MODEL_FACTORY - assert "NaiveMeanEffectsPredictor" in MODEL_FACTORY - assert "NaiveTissueDrugMeanPredictor" in MODEL_FACTORY - assert "ElasticNet" in MODEL_FACTORY - assert "RandomForest" in MODEL_FACTORY - assert "SVR" in MODEL_FACTORY - assert "SimpleNeuralNetwork" in MODEL_FACTORY - assert "MultiViewNeuralNetwork" in MODEL_FACTORY - assert "MultiViewRandomForest" in MODEL_FACTORY - assert "SingleDrugRandomForest" in MODEL_FACTORY - assert "SRMF" in MODEL_FACTORY - assert "GradientBoosting" in MODEL_FACTORY - assert "MOLIR" in MODEL_FACTORY - assert "SuperFELTR" in MODEL_FACTORY - assert "DIPK" in MODEL_FACTORY - assert "SparseGO" in MODEL_FACTORY - - -def test_load_cl_ids_from_csv() -> None: - """Test the loading of cell line identifiers from a CSV file.""" - temp = tempfile.TemporaryDirectory() - os.mkdir(os.path.join(temp.name, "GDSC1_small")) - temp_file = os.path.join(temp.name, "GDSC1_small", "cell_line_names.csv") - with open(temp_file, "w") as f: - f.write( - "cellosaurus_id,cell_line_name\nCVCL_X481,201T\nCVCL_1045,22Rv1\n" - "CVCL_1046,23132/87\nCVCL_1798,42-MG-BA\n" - ) - - cl_ids_gdsc1 = load_cl_ids_from_csv(temp.name, "GDSC1_small") - assert len(cl_ids_gdsc1.features) == 4 - assert cl_ids_gdsc1.identifiers[0] == "201T" - - -def test_load_tissues_from_csv() -> None: - """Test the loading of tissues from a CSV file.""" - with tempfile.TemporaryDirectory() as temp_dir: - os.mkdir(os.path.join(temp_dir, "GDSC1_small")) - temp_file = os.path.join(temp_dir, "GDSC1_small", "cell_line_names.csv") - with open(temp_file, "w") as f: - f.write( - "cellosaurus_id,cell_line_name,tissue\n" - "CVCL_X481,201T,lung\n" - "CVCL_1045,22Rv1,breast\n" - "CVCL_1046,23132/87,liver\n" - "CVCL_1798,42-MG-BA,kidney\n" - ) - - tissues_gdsc1 = load_tissues_from_csv(temp_dir, "GDSC1_small") - assert len(tissues_gdsc1.features) == 4 - - expected = { - "201T": "lung", - "22Rv1": "breast", - "23132/87": "liver", - "42-MG-BA": "kidney", - } - - for cl_name, expected_tissue in expected.items(): - tissue_value = tissues_gdsc1.features[cl_name][TISSUE_IDENTIFIER] - assert isinstance(tissue_value, np.ndarray) - assert tissue_value.shape == (1,) - assert tissue_value[0] == expected_tissue - - -def test_load_cl_ids_and_tissues_from_csv() -> None: - """Test loading cell line ids and tissues from a CSV file.""" - with tempfile.TemporaryDirectory() as temp_dir: - os.mkdir(os.path.join(temp_dir, "GDSC1_small")) - temp_file = os.path.join(temp_dir, "GDSC1_small", "cell_line_names.csv") - with open(temp_file, "w") as f: - f.write("cellosaurus_id,cell_line_name,tissue\n" "CVCL_X481,201T,lung\n" "CVCL_1045,22Rv1,breast\n") - - features = load_cl_ids_and_tissues_from_csv(temp_dir, "GDSC1_small") - assert len(features.features) == 2 - assert features.features["201T"][TISSUE_IDENTIFIER][0] == "lung" - assert features.features["22Rv1"][CELL_LINE_IDENTIFIER][0] == "22Rv1" - - -def test_load_cl_ids_and_tissues_from_csv_missing_tissue_column() -> None: - """Test that missing tissue column falls back to cell line ids.""" - with tempfile.TemporaryDirectory() as temp_dir: - os.mkdir(os.path.join(temp_dir, "GDSC1_small")) - temp_file = os.path.join(temp_dir, "GDSC1_small", "cell_line_names.csv") - with open(temp_file, "w") as f: - f.write("cellosaurus_id,cell_line_name\nCVCL_X481,201T\n") - - features = load_cl_ids_and_tissues_from_csv(temp_dir, "GDSC1_small") - assert len(features.features) == 1 - assert features.features["201T"][CELL_LINE_IDENTIFIER][0] == "201T" - assert TISSUE_IDENTIFIER not in features.features["201T"] - - -def _write_gene_list(temp_dir: tempfile.TemporaryDirectory, gene_list: Optional[str] = None) -> None: - """ - Write a gene list to a temporary directory. - - :param temp_dir: temporary directory - :param gene_list: either None, landmark_genes, drug_target_genes_all_drugs, or gene_list_paccmann_network_prop - """ - os.makedirs(os.path.join(temp_dir.name, "meta", "gene_lists")) - temp_file = os.path.join(temp_dir.name, "meta", "gene_lists", f"{gene_list}.csv") - if gene_list == "landmark_genes_reduced": - with open(temp_file, "w") as f: - f.write( - "Entrez ID,Symbol,Name,Gene Family,Type,RNA-Seq Correlation,RNA-Seq Correlation Self-Rank\n" - "3638,INSIG1,insulin induced gene 1,,landmark,,\n" - "2309,FOXO3,forkhead box O3,Forkhead boxes,landmark,,\n" - '672,BRCA1,"BRCA1, DNA repair associated","Ring finger proteins, Fanconi anemia complementation groups' - ',Protein phosphatase 1 regulatory subunits, BRCA1 A complex, BRCA1 B complex, BRCA1 C complex",' - "landmark,,\n57147,SCYL3,SCY1 like pseudokinase 3,SCY1 like pseudokinases,landmark,," - ) - elif gene_list == "drug_target_genes_all_drugs": - with open(temp_file, "w") as f: - f.write("Symbol\n" "TSPAN6\n" "SCYL3\n" "BRCA1\n") - elif gene_list == "gene_list_paccmann_network_prop": - with open(temp_file, "w") as f: - f.write("Symbol\n" "HDAC1\n" "ALS2CR12\n" "BFAR\n" "ZCWPW1\n" "ZP1\n" "PDZD7") - - -@pytest.mark.parametrize( - "gene_list", - [ - None, - "landmark_genes_reduced", - "drug_target_genes_all_drugs", - "gene_list_paccmann_network_prop", - ], -) -def test_load_and_select_gene_features(gene_list: Optional[str]) -> None: - """ - Test the loading and reduction of gene features. - - :param gene_list: either None, landmark_genes, drug_target_genes_all_drugs, or gene_list_paccmann_network_prop - """ - temp = tempfile.TemporaryDirectory() - os.mkdir(os.path.join(temp.name, "GDSC1_small")) - temp_file = os.path.join(temp.name, "GDSC1_small", "gene_expression.csv") - with open(temp_file, "w") as f: - f.write( - "cellosaurus_id,cell_line_name,TSPAN6,TNMD,BRCA1,SCYL3,HDAC1,INSIG1,FOXO3\n" - "CVCL_1104,CAL-120,7.632023171463389,2.9645851205892404,10.3795526353077,3.61479404843988," - "3.38068143582194,7.09344749430946,3.0222634357817597\n" - "CVCL_1174,DMS 114,7.54867116637172,2.77771614989839,11.807341248845802,4.066886747621," - "3.73248465377029,2.8016127581695,6.07851099764176\n" - "CVCL_1110,CAL-51,8.71233752103624,2.6435077554121,9.88073281995499,3.95622995046262," - "3.23662007804984,11.394340478134598,4.22471584953505\n" - "CVCL_V001,NCI-H2869,7.79714221650204,2.8179230218265,9.88347076381233,4.0637013909818505," - "3.55841402145301,8.76055372116888,4.33420904819493\n" - "CVCL_1045,22Rv1,4.8044868436701,2.84812776692645,10.3319941550002,5.14538669275316," - "3.54519297942073,3.9337949618623704,2.8629939819029904\n" - ) - if gene_list is not None: - _write_gene_list(temp, gene_list) - - if gene_list == "gene_list_paccmann_network_prop": - with pytest.raises(ValueError) as valerr: - gene_features_gdsc1 = load_and_select_gene_features("gene_expression", gene_list, temp.name, "GDSC1_small") - else: - gene_features_gdsc1 = load_and_select_gene_features("gene_expression", gene_list, temp.name, "GDSC1_small") - if gene_list is None: - assert len(gene_features_gdsc1.features) == 5 - assert gene_features_gdsc1.meta_info is not None - assert len(gene_features_gdsc1.meta_info["gene_expression"]) == 7 - gene_names = ["TSPAN6", "TNMD", "BRCA1", "SCYL3", "HDAC1", "INSIG1", "FOXO3"] - assert np.all(gene_features_gdsc1.meta_info["gene_expression"] == gene_names) - elif gene_list == "landmark_genes_reduced": - assert len(gene_features_gdsc1.features) == 5 - assert gene_features_gdsc1.meta_info is not None - assert len(gene_features_gdsc1.meta_info["gene_expression"]) == 4 - colnames = gene_features_gdsc1.meta_info["gene_expression"] - colnames.sort() - assert np.all(colnames == ["BRCA1", "FOXO3", "INSIG1", "SCYL3"]) - elif gene_list == "drug_target_genes_all_drugs": - assert len(gene_features_gdsc1.features) == 5 - assert gene_features_gdsc1.meta_info is not None - assert len(gene_features_gdsc1.meta_info["gene_expression"]) == 3 - colnames = gene_features_gdsc1.meta_info["gene_expression"] - colnames.sort() - assert np.all(colnames == ["BRCA1", "SCYL3", "TSPAN6"]) - elif gene_list == "gene_list_paccmann_network_prop": - assert "The following genes are missing from the dataset GDSC1_small" in str(valerr.value) - - -def test_order_load_and_select_gene_features( - sample_dataset: DrugResponseDataset, cross_study_dataset: DrugResponseDataset, data_dir -) -> None: - """ - Test the order of the features after loading and reducing gene features. it should be maintained. - - :param sample_dataset: TOYv1 dataset - :param cross_study_dataset: TOYv2 dataset - :param data_dir: path to the data directory - """ - assert sample_dataset.dataset_name == "TOYv1" - assert cross_study_dataset.dataset_name == "TOYv2" - gene_list = "gene_expression_intersection" - a = load_and_select_gene_features("gene_expression", gene_list, str(data_dir), "TOYv1") - b = load_and_select_gene_features("gene_expression", gene_list, str(data_dir), "TOYv2") - # assert the meta info (=gene names) are the same - assert np.all(a.meta_info["gene_expression"] == b.meta_info["gene_expression"]) - # assert the shape of the features for a random cell line is actually the same - random_cell_line_a = np.random.choice(a.identifiers) - random_cell_line_b = np.random.choice(b.identifiers) - assert ( - a.features[random_cell_line_a]["gene_expression"].shape - == b.features[random_cell_line_b]["gene_expression"].shape - ) - - -def test_iterate_features() -> None: - """Test the iteration over features.""" - df = pd.DataFrame({"GeneA": [1, 2, 3, 2], "GeneB": [4, 5, 6, 2], "GeneC": [7, 8, 9, 2]}) - df.index = ["CellLine1", "CellLine2", "CellLine3", "CellLine1"] - features = iterate_features(df, "gene_expression") - assert len(features) == 3 - assert np.all(features["CellLine1"]["gene_expression"] == [1.5, 3, 4.5]) - - -def test_load_drug_ids_from_csv() -> None: - """Test the loading of drug identifiers from a CSV file.""" - temp = tempfile.TemporaryDirectory() - os.mkdir(os.path.join(temp.name, "GDSC1_small")) - temp_file = os.path.join(temp.name, "GDSC1_small", "drug_names.csv") - with open(temp_file, "w") as f: - f.write(f"{DRUG_IDENTIFIER}\n(5Z)-7-Oxozeaenol\n5-Fluorouracil\nA-443654\nA-770041\n") - drug_ids_gdsc1 = load_drug_ids_from_csv(temp.name, "GDSC1_small") - assert len(drug_ids_gdsc1.features) == 4 - assert drug_ids_gdsc1.identifiers[0] == "(5Z)-7-Oxozeaenol" - - -def test_load_drugs_from_fingerprints() -> None: - """Test the loading of drugs from fingerprints.""" - temp = tempfile.TemporaryDirectory() - os.mkdir(os.path.join(temp.name, "GDSC1_small")) - os.mkdir(os.path.join(temp.name, "GDSC1_small", "drug_fingerprints")) - temp_file = os.path.join( - temp.name, - "GDSC1_small", - "drug_fingerprints", - "pubchem_id_to_demorgan_128_map.csv", - ) - with open(temp_file, "w") as f: - f.write( - "3827738,5311510,46883536,73707530,16720766\n" - "1,1,1,1,1\n" - "1,1,0,0,1\n" - "0,1,1,0,1\n" - "1,0,1,1,1\n" - "1,1,0,1,1\n" - ) - drug_features_gdsc1 = load_drug_fingerprint_features(temp.name, "GDSC1_small") - assert len(drug_features_gdsc1.features) == 5 - assert drug_features_gdsc1.features.keys() == { - "3827738", - "5311510", - "46883536", - "73707530", - "16720766", - } - assert np.all(drug_features_gdsc1.features["3827738"]["fingerprints"] == [1, 1, 0, 1, 1]) - - -@pytest.mark.parametrize( - "gene_list", - [ - None, - "landmark_genes_reduced", - "drug_target_genes_all_drugs", - "gene_list_paccmann_network_prop", - ], -) -def test_get_multiomics_feature_dataset(gene_list: Optional[str]) -> None: - """ - Test the loading of multiomics features. - - :param gene_list: list of genes to keep - """ - temp = tempfile.TemporaryDirectory() - os.mkdir(os.path.join(temp.name, "GDSC1_small")) - # gene expression - temp_file = os.path.join(temp.name, "GDSC1_small", "gene_expression.csv") - with open(temp_file, "w") as f: - f.write( - "cellosaurus_id,cell_line_name,TSPAN6,TNMD,BRCA1,SCYL3,HDAC1,INSIG1,FOXO3\n" - "CVCL_1104,CAL-120,7.632023171463389,2.9645851205892404,10.3795526353077,3.61479404843988," - "3.38068143582194,7.09344749430946,3.0222634357817597\n" - "CVCL_1174,DMS 114,7.54867116637172,2.77771614989839,11.807341248845802,4.066886747621," - "3.73248465377029,2.8016127581695,6.07851099764176\n" - "CVCL_1110,CAL-51,8.71233752103624,2.6435077554121,9.88073281995499,3.95622995046262," - "3.23662007804984,11.394340478134598,4.22471584953505\n" - "CVCL_V001,NCI-H2869,7.79714221650204,2.8179230218265,9.88347076381233,4.0637013909818505," - "3.55841402145301,8.76055372116888,4.33420904819493\n" - "CVCL_1045,22Rv1,4.8044868436701,2.84812776692645,10.3319941550002,5.14538669275316," - "3.54519297942073,3.9337949618623704,2.8629939819029904\n" - ) - - # methylation - temp_file = os.path.join(temp.name, "GDSC1_small", "methylation.csv") - with open(temp_file, "w") as f: - f.write( - "cellosaurus_id,cell_line_name,chr1:10003165-10003585,chr1:100315420-100316009," - "chr1:100435297-100436070,chr1:100503482-100504404,chr1:10057121-10058108," - "chr11:107728949-107729586,chr11:107798958-107799980\n" - "CVCL_1045,22Rv1,0.192212286,0.20381998,0.277913619,0.1909300789999999,0.544058696\n" - "CVCL_1642,PFSK-1,0.1876026089999999,0.2076517789999999,0.400145531,0.195871473,0.76489757\n" - "CVCL_1104,CAL-120,0.2101851619999999,0.222116189,0.264730199,0.243298011,0.415484752\n" - "CVCL_1199,ES3,0.205613728,0.227570131,0.303640813,0.250454389,0.599274902\n" - ) - # mutations - temp_file = os.path.join(temp.name, "GDSC1_small", "mutations.csv") - with open(temp_file, "w") as f: - f.write( - "cellosaurus_id,cell_line_name,TSPAN6,TNMD,BRCA1,SCYL3,HDAC1,INSIG1,FOXO3\n" - "CVCL_X481,201T,False,False,False,False,False,True,True\n" - "CVCL_1045,22Rv1,False,True,False,True,False,False,True\n" - "CVCL_1046,23132/87,False,False,True,True,False,False,False\n" - "CVCL_1104,CAL-120,False,False,False,False,False,True,False\n" - ) - - # copy number variation - temp_file = os.path.join(temp.name, "GDSC1_small", "copy_number_variation_gistic.csv") - with open(temp_file, "w") as f: - f.write( - "cellosaurus_id,cell_line_name,TSPAN6,TNMD,BRCA1,SCYL3,HDAC1,INSIG1,FOXO3\n" - "CVCL_X481,201T,0.0,0.0,-1.0,0.0,0.0,1.0,-1.0\n" - "CVCL_1762,TE-12,-1.0,-1.0,0.0,1.0,1.0,0.0,0.0\n" - "CVCL_1104,CAL-120,0.0,0.0,0.0,-1.0,-1.0,1.0,0.0\n" - "CVCL_X508,STS-0421,0.0,0.0,1.0,0.0,0.0,-1.0,0.0\n" - "CVCL_1045,22Rv1,1.0,1.0,-1.0,1.0,1.0,1.0,1.0\n" - ) - if gene_list is not None: - _write_gene_list(temp, gene_list) - omics = ["gene_expression", "methylation", "mutations", "copy_number_variation_gistic"] - gene_lists = {o: gene_list for o in omics} - gene_lists["methylation"] = None - if gene_list == "gene_list_paccmann_network_prop": - with pytest.raises(ValueError) as valerr: - dataset = get_multiomics_feature_dataset( - data_path=temp.name, - dataset_name="GDSC1_small", - gene_lists=gene_lists, - omics=omics, - ) - else: - dataset = get_multiomics_feature_dataset( - data_path=temp.name, - dataset_name="GDSC1_small", - gene_lists=gene_lists, - omics=omics, - ) - assert len(dataset.features) == 2 - common_cls = dataset.identifiers - common_cls.sort() - assert np.all(common_cls == ["22Rv1", "CAL-120"]) - assert dataset.meta_info is not None - assert len(dataset.meta_info) == 4 - if gene_list is None: - assert dataset.meta_info is not None - assert np.all( - dataset.meta_info["gene_expression"] == ["TSPAN6", "TNMD", "BRCA1", "SCYL3", "HDAC1", "INSIG1", "FOXO3"] - ) - for key in dataset.meta_info: - assert len(dataset.meta_info[key]) == 7 - else: - feature_names: list[str] = [] - if gene_list == "landmark_genes_reduced": - assert dataset.meta_info is not None - for key in dataset.meta_info: - if key == "methylation": - assert len(dataset.meta_info[key]) == 7 - else: - assert len(dataset.meta_info[key]) == 4 - if len(feature_names) == 0: - feature_names = dataset.meta_info[key] - else: - assert np.all(dataset.meta_info[key] == feature_names) - elif gene_list == "drug_target_genes_all_drugs": - assert dataset.meta_info is not None - for key in dataset.meta_info: - if key == "methylation": - assert len(dataset.meta_info[key]) == 7 - else: - assert len(dataset.meta_info[key]) == 3 - if len(feature_names) == 0: - feature_names = dataset.meta_info[key] - else: - assert np.all(dataset.meta_info[key] == feature_names) - elif gene_list == "gene_list_paccmann_network_prop": - assert "The following genes are missing from the dataset GDSC1_small" in str(valerr.value) - - -def test_unique() -> None: - """Test the unique function.""" - array = np.array([1, 9, 3, 2, 1, 4, 5, 6, 7, 8, 9, 2, 1, 2, 3, 4, 5, 6, 7, 8, 9]) - unique_array = unique(array) - assert np.all(unique_array == np.array([1, 9, 3, 2, 4, 5, 6, 7, 8])) diff --git a/tests/test_evaluation.py b/tests/test_evaluation.py index 184957fdf..405a6ced6 100644 --- a/tests/test_evaluation.py +++ b/tests/test_evaluation.py @@ -5,21 +5,13 @@ from flaky import flaky from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.evaluation import evaluate, kendall, pearson, spearman +from drevalpy.evaluation import _compute_metric_value, evaluate, kendall, pearson, spearman def test_evaluate() -> None: """Test the evaluate function.""" - # Create mock dataset predictions = np.array([1, 2, 3, 4, 5]) response = np.array([1.1, 2.2, 3.3, 4.4, 5.5]) - dataset = DrugResponseDataset( - response=response, - cell_line_ids=np.array(["A", "B", "C", "D", "E"]), - drug_ids=np.array(["a", "b", "c", "d", "e"]), - predictions=predictions, - ) # Test metrics calculation mse_expected = mean_squared_error(predictions, response) @@ -28,7 +20,7 @@ def test_evaluate() -> None: r2_expected = r2_score(y_pred=predictions, y_true=response) # Evaluate using all available metrics - results = evaluate(dataset, metric=["MSE", "RMSE", "MAE", "R^2"]) + results = evaluate(predictions, response, metric=["MSE", "RMSE", "MAE", "R^2"]) # Check if the calculated metrics match the expected values assert np.isclose(results["MSE"], mse_expected), f"Expected mse: {mse_expected}, Got: {results['MSE']}" @@ -40,8 +32,7 @@ def test_evaluate() -> None: # Mock dataset generation function @pytest.fixture def generate_mock_data_drug_mean() -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """ - Generate mock data with a mean response per drug. + """Generate mock data with a mean response per drug. :returns: response, cell_line_ids, drug_ids """ @@ -60,8 +51,7 @@ def generate_mock_data_drug_mean() -> tuple[np.ndarray, np.ndarray, np.ndarray]: @pytest.fixture def generate_mock_data_constant_prediction() -> tuple[np.ndarray, np.ndarray]: - """ - Generate mock data with constant prediction. + """Generate mock data with constant prediction. :returns: y_pred, response """ @@ -72,8 +62,7 @@ def generate_mock_data_constant_prediction() -> tuple[np.ndarray, np.ndarray]: @pytest.fixture def generate_mock_anticorrelated_data() -> tuple[np.ndarray, np.ndarray]: - """ - Generate mock data with anticorrelated prediction. + """Generate mock data with anticorrelated prediction. :returns: y_pred, response """ @@ -84,20 +73,20 @@ def generate_mock_anticorrelated_data() -> tuple[np.ndarray, np.ndarray]: @pytest.fixture def generate_mock_uncorrelated_data() -> tuple[np.ndarray, np.ndarray]: - """ - Generate mock data with uncorrelated prediction. + """Generate mock data with uncorrelated prediction. + + Uses a fixed RNG seed so correlation asserts stay deterministic across platforms. :returns: y_pred, response """ response = np.arange(2e6) - y_pred = np.random.permutation(response) + y_pred = np.random.default_rng(0).permutation(response) return y_pred, response @pytest.fixture def generate_mock_correlated_data() -> tuple[np.ndarray, np.ndarray]: - """ - Generate mock data with correlated prediction. + """Generate mock data with correlated prediction. :returns: y_pred, response """ @@ -107,8 +96,7 @@ def generate_mock_correlated_data() -> tuple[np.ndarray, np.ndarray]: def test_pearson_correlated(generate_mock_correlated_data: tuple[np.ndarray, np.ndarray]) -> None: - """ - Test the pearson correlation function. + """Test the pearson correlation function. :param generate_mock_correlated_data: mock data generator """ @@ -119,8 +107,7 @@ def test_pearson_correlated(generate_mock_correlated_data: tuple[np.ndarray, np. def test_pearson_anticorrelated(generate_mock_anticorrelated_data: tuple[np.ndarray, np.ndarray]) -> None: - """ - Test the pearson correlation function. + """Test the pearson correlation function. :param generate_mock_anticorrelated_data: mock data generator """ @@ -132,8 +119,7 @@ def test_pearson_anticorrelated(generate_mock_anticorrelated_data: tuple[np.ndar @flaky(max_runs=3) def test_pearson_uncorrelated(generate_mock_uncorrelated_data: tuple[np.ndarray, np.ndarray]) -> None: - """ - Test the pearson correlation function. + """Test the pearson correlation function. :param generate_mock_uncorrelated_data: mock data generator """ @@ -144,8 +130,7 @@ def test_pearson_uncorrelated(generate_mock_uncorrelated_data: tuple[np.ndarray, def test_spearman_correlated(generate_mock_correlated_data: tuple[np.ndarray, np.ndarray]) -> None: - """ - Test the spearman correlation function. + """Test the spearman correlation function. :param generate_mock_correlated_data: mock data generator """ @@ -156,8 +141,7 @@ def test_spearman_correlated(generate_mock_correlated_data: tuple[np.ndarray, np def test_spearman_anticorrelated(generate_mock_anticorrelated_data: tuple[np.ndarray, np.ndarray]) -> None: - """ - Test the spearman correlation function. + """Test the spearman correlation function. :param generate_mock_anticorrelated_data: mock data generator """ @@ -169,21 +153,18 @@ def test_spearman_anticorrelated(generate_mock_anticorrelated_data: tuple[np.nda @flaky(max_runs=3) def test_spearman_uncorrelated(generate_mock_uncorrelated_data: tuple[np.ndarray, np.ndarray]) -> None: - """ - Test the spearman correlation function. + """Test the spearman correlation function. :param generate_mock_uncorrelated_data: mock data generator """ y_pred, response = generate_mock_uncorrelated_data sp = spearman(y_pred, response) - print(sp) assert np.isclose(sp, 0.0, atol=1e-3) def test_kendall_correlated(generate_mock_correlated_data: tuple[np.ndarray, np.ndarray]) -> None: - """ - Test the kendall correlation function. + """Test the kendall correlation function. :param generate_mock_correlated_data: mock data generator """ @@ -194,8 +175,7 @@ def test_kendall_correlated(generate_mock_correlated_data: tuple[np.ndarray, np. def test_kendall_anticorrelated(generate_mock_anticorrelated_data: tuple[np.ndarray, np.ndarray]) -> None: - """ - Test the kendall correlation function. + """Test the kendall correlation function. :param generate_mock_anticorrelated_data: mock data generator """ @@ -207,8 +187,7 @@ def test_kendall_anticorrelated(generate_mock_anticorrelated_data: tuple[np.ndar @flaky(max_runs=3) def test_kendall_uncorrelated(generate_mock_uncorrelated_data): - """ - Test the kendall correlation function. + """Test the kendall correlation function. :param generate_mock_uncorrelated_data: mock data generator """ @@ -221,8 +200,7 @@ def test_kendall_uncorrelated(generate_mock_uncorrelated_data): def test_correlations_constant_prediction( generate_mock_data_constant_prediction: tuple[np.ndarray, np.ndarray], ) -> None: - """ - Test the correlation functions with constant prediction. + """Test the correlation functions with constant prediction. :param generate_mock_data_constant_prediction: mock data generator """ @@ -233,3 +211,9 @@ def test_correlations_constant_prediction( assert np.isclose(pc, 0.0, atol=1e-3) assert np.isclose(sp, 0.0, atol=1e-3) assert np.isclose(kd, 0.0, atol=1e-3) + + +def test_compute_metric_value_all_nan_predictions() -> None: + response = np.array([1.0, 2.0]) + predictions = np.array([np.nan, np.nan]) + assert np.isnan(_compute_metric_value("RMSE", predictions, response)) diff --git a/tests/test_featurizer_block_policy.py b/tests/test_featurizer_block_policy.py new file mode 100644 index 000000000..9896a6e5d --- /dev/null +++ b/tests/test_featurizer_block_policy.py @@ -0,0 +1,62 @@ +"""Policy guards for featurizer-driven literature inputs.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.registry._builtins import register_builtin_components +from drevalpy.registry.cell_line_featurizer import list as list_cell_line_featurizers +from drevalpy.registry.drug_featurizer import list as list_drug_featurizers +from drevalpy.registry.predictor import list as list_predictors +from drevalpy.types.data.batch.feature_block import ( + graph_feature_block, + merge_feature_blocks, + numeric_feature_block, +) + +REPO = Path(__file__).resolve().parents[1] +DREVALPY = REPO / "drevalpy" + + +@pytest.fixture(autouse=True) +def _register() -> None: + register_builtin_components() + + +def test_registry_discovery_counts() -> None: + assert len(list_cell_line_featurizers()) == 17 + assert len(list_drug_featurizers()) == 10 + assert len(list_predictors()) == 27 + + +def test_no_raw_dataset_predictor_in_executable_source() -> None: + hits: list[str] = [] + for path in DREVALPY.rglob("*.py"): + text = path.read_text(encoding="utf-8") + if "RawDatasetPredictor" in text: + hits.append(str(path.relative_to(REPO))) + assert not hits, hits + + +def test_predictors_do_not_consume_raw_batch_inputs() -> None: + hits: list[str] = [] + predictors_root = DREVALPY / "components" / "predictors" + for path in predictors_root.rglob("*.py"): + text = path.read_text(encoding="utf-8") + for needle in ("batch.cell_line_input", "batch.drug_input"): + if needle in text: + hits.append(f"{path.relative_to(REPO)}:{needle}") + assert not hits, hits + + +def test_concat_and_materialization_preserve_graph_and_ragged_payloads() -> None: + payload = object() + graph = graph_feature_block(np.array([payload], dtype=object)) + numeric = numeric_feature_block(np.ones((1, 2), dtype=np.float64)) + merged = merge_feature_blocks({"drug_graph": graph}, {"gene_expression": numeric}) + assert merged["drug_graph"].values[0] is payload + assert merged["drug_graph"].format is FeatureFormat.GRAPH diff --git a/tests/test_featurizers.py b/tests/test_featurizers.py deleted file mode 100644 index a2db9b69d..000000000 --- a/tests/test_featurizers.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for drug featurizers.""" - -import sys -from unittest.mock import patch - -import pandas as pd -import torch - - -def test_chemberta_featurizer(tmp_path): - """ - Test ChemBERTa featurizer end-to-end. - - :param tmp_path: Temporary path provided by pytest. - """ - try: - import drevalpy.datasets.featurizer.create_chemberta_drug_embeddings as chemberta - except ImportError: - print("transformers package not installed; skipping ChemBERTa featurizer test.") - return - dataset = "testset" - data_dir = tmp_path / dataset - data_dir.mkdir(parents=True) - - # fake input CSV - df = pd.DataFrame({"pubchem_id": ["X1"], "canonical_smiles": ["CCO"]}) - (data_dir / "drug_smiles.csv").write_text(df.to_csv(index=False)) - - fake_embedding = [1.0, 2.0, 3.0] - - with patch.object(chemberta, "_smiles_to_chemberta", return_value=fake_embedding), patch.object( - sys, "argv", ["prog", dataset, "--data_path", str(tmp_path)] - ): - - chemberta.main() - - out_file = data_dir / "drug_chemberta_embeddings.csv" - assert out_file.exists() - - df_out = pd.read_csv(out_file) - assert df_out.pubchem_id.tolist() == ["X1"] - assert df_out.iloc[0, 1:].tolist() == fake_embedding - - -def test_graph_featurizer(tmp_path): - """ - Test graph featurizer end-to-end. - - :param tmp_path: Temporary path provided by pytest. - """ - try: - import drevalpy.datasets.featurizer.create_drug_graphs as graphs - except ImportError: - print("rdkit package not installed; skipping graph featurizer test.") - return - dataset = "testset" - data_dir = tmp_path / dataset - data_dir.mkdir(parents=True) - - # write minimal SMILES CSV - df = pd.DataFrame({"pubchem_id": ["D1"], "canonical_smiles": ["CCO"]}) - (data_dir / "drug_smiles.csv").write_text(df.to_csv(index=False)) - - # run main exactly as the script would - sys.argv = ["prog", dataset, "--data_path", str(tmp_path)] - graphs.main() - - # expected output file - out_file = data_dir / "drug_graphs" / "D1.pt" - assert out_file.exists() - - -def test_molgnet_featurizer(tmp_path): - """ - Test MolGNet featurizer end-to-end. - - :param tmp_path: Temporary path provided by pytest. - """ - try: - import drevalpy.datasets.featurizer.create_molgnet_embeddings as molg - except ImportError: - print("rdkit package not installed; skipping molgnet featurizer test.") - return - ds = "testset" - ds_dir = tmp_path / ds - ds_dir.mkdir(parents=True) - - # minimal SMILES CSV - df = pd.DataFrame({"pubchem_id": ["D1"], "canonical_smiles": ["CCO"]}) - (ds_dir / "drug_smiles.csv").write_text(df.to_csv(index=False)) - - with ( - # we dont need real model weights for this test, takes too long to load - patch("drevalpy.datasets.featurizer.create_molgnet_embeddings.torch.load", return_value={}), - # prevent load_state_dict from complaining - patch.object(molg.MolGNet, "load_state_dict", return_value=None), - # cheap forward pass - patch.object(molg.MolGNet, "forward", return_value=torch.zeros((1, 768))), - # avoid writing pickles - patch.object(molg.pickle, "dump", return_value=None), - # simulate CLI - patch.object( - sys, - "argv", - ["prog", ds, "--data_path", str(tmp_path), "--checkpoint", "MolGNet.pt"], - ), - ): - args = molg.parse_args() - molg.run(args) - - # verify outputs - assert (ds_dir / "DIPK_features/Drugs" / "MolGNet_D1.csv").exists() - - -def test_bpe_smiles_featurizer(tmp_path): - """ - Test BPE SMILES featurizer end-to-end. - - :param tmp_path: Temporary path provided by pytest. - """ - try: - import drevalpy.datasets.featurizer.create_pharmaformer_drug_embeddings as bpe_feat - except ImportError: - print("subword-nmt package not installed; skipping BPE SMILES featurizer test.") - return - dataset = "testset" - data_dir = tmp_path / dataset - data_dir.mkdir(parents=True) - - # write minimal SMILES CSV with multiple SMILES for BPE learning - df = pd.DataFrame( - { - "pubchem_id": ["D1", "D2", "D3", "D4", "D5"], - "canonical_smiles": ["CCO", "CC(=O)O", "c1ccccc1", "CCN(CC)CC", "C1CCC(CC1)O"], - } - ) - (data_dir / "drug_smiles.csv").write_text(df.to_csv(index=False)) - - # run main exactly as the script would - sys.argv = ["prog", dataset, "--data_path", str(tmp_path), "--num-symbols", "100", "--max-length", "128"] - bpe_feat.main() - - # expected output files - out_file = data_dir / "drug_bpe_smiles.csv" - bpe_codes_file = data_dir / "bpe.codes" - assert out_file.exists() - assert bpe_codes_file.exists() - - # verify output format - df_out = pd.read_csv(out_file) - assert "pubchem_id" in df_out.columns - assert df_out.pubchem_id.tolist() == ["D1", "D2", "D3", "D4", "D5"] - # Should have 128 feature columns - feature_cols = [col for col in df_out.columns if col.startswith("feature_")] - assert len(feature_cols) == 128 - # Values should be numeric (character ordinals, may be stored as float in CSV) - assert pd.api.types.is_numeric_dtype(df_out[feature_cols[0]]) diff --git a/tests/test_hpam_tune.py b/tests/test_hpam_tune.py deleted file mode 100644 index 453929630..000000000 --- a/tests/test_hpam_tune.py +++ /dev/null @@ -1,75 +0,0 @@ -"""test hpam tune.""" - -import numpy as np - -from drevalpy import experiment -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.models import MODEL_FACTORY - - -def test_hpam_tune(tmp_path, data_dir): - """ - Test hpam_tune with a toy dataset and ElasticNet model. - - :param tmp_path: pytest temporary path fixture - :param data_dir: path to the data directory - """ - hpam_set = [ - {"alpha": 1.0, "l1_ratio": 0.2, "cell_line_views": "gene_expression", "drug_views": "fingerprints"}, - {"alpha": 2.0, "l1_ratio": 0.8, "cell_line_views": "gene_expression", "drug_views": "fingerprints"}, - ] - - model = MODEL_FACTORY["ElasticNet"]() - model.build_model(hyperparameters=hpam_set[0]) - cell_line_input = model.load_cell_line_features(data_path=str(data_dir), dataset_name="TOYv1") - drug_input = model.load_drug_features(data_path=str(data_dir), dataset_name="TOYv1") - - valid_cell_lines = list(cell_line_input.identifiers)[:2] - valid_drugs = list(drug_input.identifiers)[:2] - responses = np.array([1.0, 2.0, 3.0, 4.0], dtype=float) - cell_line_ids = np.array([valid_cell_lines[0], valid_cell_lines[0], valid_cell_lines[1], valid_cell_lines[1]]) - drug_ids = np.array([valid_drugs[0], valid_drugs[1], valid_drugs[0], valid_drugs[1]]) - train_dataset = DrugResponseDataset( - response=responses, - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - dataset_name="TOYv1", - ) - val_dataset = DrugResponseDataset( - response=responses.copy(), - cell_line_ids=cell_line_ids.copy(), - drug_ids=drug_ids.copy(), - dataset_name="TOYv1", - ) - - model = MODEL_FACTORY["ElasticNet"]() - model.build_model(hyperparameters=hpam_set[0]) - cell_line_input = model.load_cell_line_features(data_path=str(data_dir), dataset_name="TOYv1") - drug_input = model.load_drug_features(data_path=str(data_dir), dataset_name="TOYv1") - - cell_lines_to_keep = cell_line_input.identifiers - drugs_to_keep = drug_input.identifiers - - len_train_before = len(train_dataset) - len_val_before = len(val_dataset) - train_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - val_dataset.reduce_to(cell_line_ids=cell_lines_to_keep, drug_ids=drugs_to_keep) - print(f"Reduced training dataset from {len_train_before} to {len(train_dataset)}") - print(f"Reduced val dataset from {len_val_before} to {len(val_dataset)}") - - best = experiment.hpam_tune( - model=model, - train_dataset=train_dataset, - validation_dataset=val_dataset, - early_stopping_dataset=None, - hpam_set=hpam_set, - response_transformation=None, - metric="RMSE", - path_data=str(data_dir), - model_checkpoint_dir="TEMPORARY", - split_index=None, - wandb_project=None, - wandb_base_config=None, - ) - - assert best in hpam_set diff --git a/tests/test_hpam_tune_raytune.py b/tests/test_hpam_tune_raytune.py deleted file mode 100644 index fbce69c84..000000000 --- a/tests/test_hpam_tune_raytune.py +++ /dev/null @@ -1,64 +0,0 @@ -"""test hpam tune with multiprocessing (raytune).""" - -import numpy as np - -from drevalpy import experiment -from drevalpy.datasets.dataset import DrugResponseDataset -from drevalpy.models import MODEL_FACTORY - - -def test_hpam_tune_raytune(tmp_path, data_dir): - """ - Test hpam_tune_raytune with a toy dataset and ElasticNet model. - - :param tmp_path: pytest temporary path fixture - :param data_dir: path to the data directory - """ - try: - import ray # noqa: F401 - except ImportError: - print("Ray is not installed, skipping test_hpam_tune_raytune.") - return - hpam_set = [ - {"alpha": 1.0, "l1_ratio": 0.0, "cell_line_views": "gene_expression", "drug_views": "fingerprints"}, - {"alpha": 2.5, "l1_ratio": 0.5, "cell_line_views": "gene_expression", "drug_views": "fingerprints"}, - {"alpha": 5.0, "l1_ratio": 1.0, "cell_line_views": "gene_expression", "drug_views": "fingerprints"}, - ] - - model = MODEL_FACTORY["ElasticNet"]() - model.build_model(hyperparameters=hpam_set[0]) - cell_line_input = model.load_cell_line_features(data_path=str(data_dir), dataset_name="TOYv1") - drug_input = model.load_drug_features(data_path=str(data_dir), dataset_name="TOYv1") - - valid_cell_lines = list(cell_line_input.identifiers)[:2] - valid_drugs = list(drug_input.identifiers)[:2] - responses = np.array([1.0, 2.0, 3.0, 4.0], dtype=float) - cell_line_ids = np.array([valid_cell_lines[0], valid_cell_lines[0], valid_cell_lines[1], valid_cell_lines[1]]) - drug_ids = np.array([valid_drugs[0], valid_drugs[1], valid_drugs[0], valid_drugs[1]]) - train_dataset = DrugResponseDataset( - response=responses, - cell_line_ids=cell_line_ids, - drug_ids=drug_ids, - dataset_name="TOYv1", - ) - val_dataset = DrugResponseDataset( - response=responses.copy(), - cell_line_ids=cell_line_ids.copy(), - drug_ids=drug_ids.copy(), - dataset_name="TOYv1", - ) - - best = experiment.hpam_tune_raytune( - model=model, - train_dataset=train_dataset, - validation_dataset=val_dataset, - early_stopping_dataset=None, - hpam_set=hpam_set, - response_transformation=None, - metric="RMSE", - ray_path=str(tmp_path), - path_data=str(data_dir), - model_checkpoint_dir="TEMPORARY", - ) - - assert best in hpam_set diff --git a/tests/test_import_cost_policy.py b/tests/test_import_cost_policy.py new file mode 100644 index 000000000..09a092046 --- /dev/null +++ b/tests/test_import_cost_policy.py @@ -0,0 +1,227 @@ +"""Policy test: importing ``drevalpy`` must not drag in the heavy scientific stack. + +``drevalpy/registry/__init__.py`` calls ``register_builtin_components()`` at +import time, which imports every registered ``predictor.py``, featurizer and +visualization. Those modules are therefore on the critical path of +``import drevalpy`` - and of every CLI invocation - so a single module-scope +``import torch`` costs every caller a third of a second whether or not they ever +train a model. Deferring the libraries below took ``import drevalpy`` from +**3.59s to 0.21s** end to end, against a 0.02s bare interpreter - the same +figures recorded in ``AGENTS.md``. (An intermediate measurement, taken once +``pytorch_lightning`` was already deferred, still had the remaining libraries +accounting for 2.0s of a 2.2s import, which is why every entry below earns its +place rather than just the two largest.) + +The guard lives at the ``tests/`` root rather than beside any one predictor +because it is a cross-package property of the package surface, like +``test_boundary.py`` and ``test_layering_policy.py``. + +Each forbidden module below is only needed inside a ``fit``/``predict``/``compute`` +call, so the fix when this fails is a function-local import (plus +``if TYPE_CHECKING:`` for annotations), not an addition to this list. Two +shapes need more than a moved import: + +* A class that **subclasses** something from a forbidden library (a + ``torch.utils.data.Dataset``, an ``sklearn.base.BaseEstimator``) cannot defer + it - the base has to exist when the ``class`` statement runs. Either drop the + base class where it contributes nothing (``DataLoader`` accepts any object with + ``__getitem__``/``__len__``) or move the class into its own private module and + re-export it lazily, as + ``featurizers/cell_line/_proteomics_transformer.py`` does. +* A module-scope **side effect** that must happen before the library is imported + anywhere must stay at module scope. ``xgboost_pred.py`` still calls + ``_set_xgboost_thread_defaults()`` eagerly: deferring it to the import site let + a test's own ``importorskip("xgboost")`` win the race and segfaulted the suite. + +The ``ImportError`` trap: ``_import_modules`` in ``drevalpy/registry/_builtins.py`` +swallows import failures during registration and reports them via +``get_skipped_builtin_modules()``. A deferred import moves such a failure into +the training call instead, where nothing catches it - so this file also asserts +registration stayed clean and that the deferred symbols really do resolve. + +One interpreter is spawned for the whole module: all facts about a fresh +``import drevalpy`` share the same pristine entry condition, so they are +collected in a single child and asserted separately. +""" + +from __future__ import annotations + +import json +import textwrap + +import pytest + +from tests._trusted_subprocess import run_trusted_python + +#: Modules that must not be imported as a side effect of ``import drevalpy``. +#: Measured cost of each in a cold interpreter, for context: ``pytorch_lightning`` +#: ~1.2s and ``torch_geometric`` ~0.9s (both pull in ``transformers``, via +#: ``torchmetrics.functional.text`` and ``torch_geometric.llm`` respectively), +#: ``torch`` ~0.33s, ``sklearn`` ~0.39s (it reaches ``scipy.stats`` through +#: ``sklearn.utils``), ``xgboost``/``lightgbm`` ~0.4s each (both via ``sklearn``), +#: ``mudata`` ~0.30s (``anndata`` -> ``dask.array`` + ``zarr``), ``pandas`` ~0.14s, +#: ``plotly`` ~0.13s for the surface the plots use (``graph_objects``, ``colors``, +#: ``subplots`` and ``utils``, most of it ``_plotly_utils.basevalidators``), +#: ``scikit_posthocs`` ~0.21s (``seaborn`` -> ``ipywidgets`` -> ``IPython``), +#: ``matplotlib`` ~0.08s, ``wandb`` ~0.11s and ``optuna`` ~0.07s. +FORBIDDEN_STARTUP_IMPORTS = ( + "IPython", + "anndata", + "lightgbm", + "matplotlib", + "mudata", + "optuna", + "pandas", + "plotly", + "pytorch_lightning", + "scikit_posthocs", + "scipy", + "seaborn", + "sklearn", + "torch", + "torch_geometric", + "wandb", + "xgboost", +) + +#: ``module``/``symbol`` pairs the package now imports lazily inside a method. +#: Deferring an import means a typo here would no longer surface at registration +#: time, only once someone trains that model or draws that plot. +DEFERRED_TRAINING_SYMBOLS = ( + ("drevalpy.components.predictors.neural_network.network", "FeedForwardNetwork"), + ("drevalpy.components.predictors.literature.druggnn.algorithm", "DrugGNNModule"), + ("drevalpy.components.predictors.literature.molir.utils", "MOLIModel"), + ("drevalpy.components.predictors.literature.superfeltr.utils", "SuperFELTEncoder"), + ("drevalpy.components.predictors.literature.superfeltr.utils", "SuperFELTRegressor"), + ("drevalpy.components.predictors.literature.superfeltr.utils", "train_superfeltr_model"), + ("drevalpy.components.predictors.literature.dipk.model_utils", "Predictor"), + ("drevalpy.components.predictors.literature.pharmaformer.model_utils", "CombinedModel"), + ("drevalpy.components.predictors.literature.precily.model_utils", "PrecilyNetwork"), + ("drevalpy.components.predictors.literature.sparsego.algorithm", "SparseGONetwork"), + ("drevalpy.components.predictors.literature.sparsego.utils", "load_ontology"), + ("drevalpy.components.predictors.literature.dipk.gene_expression_encoder", "GeneExpressionEncoder"), + ("drevalpy.components.predictors.literature.dipk.gene_expression_encoder", "encode_gene_expression"), + ( + "drevalpy.components.predictors.literature.dipk.gene_expression_encoder", + "train_gene_expession_autoencoder", + ), + ( + "drevalpy.components.featurizers.cell_line._proteomics_transformer", + "ProteomicsMedianCenterAndImputeTransformer", + ), +) + +#: ``module``/``symbol`` pairs a module still re-exports for compatibility after the +#: symbol moved (or its import was deferred) to keep a heavy library off the startup +#: path. These resolve through a module-level ``__getattr__``, which is exactly the +#: kind of indirection a rename would silently break. +LAZY_RE_EXPORTS = ( + ( + "drevalpy.components.featurizers.cell_line.normalized_proteomics", + "ProteomicsMedianCenterAndImputeTransformer", + ), + ("drevalpy.components.featurizers.cell_line.dipk_gene_expression", "GeneExpressionEncoder"), + ("drevalpy.components.featurizers.cell_line.dipk_gene_expression", "encode_gene_expression"), + ("drevalpy.components.featurizers.cell_line.dipk_gene_expression", "train_gene_expession_autoencoder"), +) + +_CHILD_SCRIPT = textwrap.dedent(f""" + import json + import sys + + import drevalpy # noqa: F401 + from drevalpy.registry._builtins import get_skipped_builtin_modules + + print(json.dumps({{ + "leaked": sorted(name for name in {FORBIDDEN_STARTUP_IMPORTS!r} if name in sys.modules), + "skipped": sorted(get_skipped_builtin_modules()), + "n_predictors": len(drevalpy.registry.predictor.list()), + "n_cell_line_featurizers": len(drevalpy.registry.cell_line_featurizer.list()), + "n_drug_featurizers": len(drevalpy.registry.drug_featurizer.list()), + }})) + """) + + +@pytest.fixture(scope="module") +def fresh_import_facts() -> dict[str, object]: + """Import ``drevalpy`` once in a pristine interpreter and report what happened. + + :returns: Mapping with the forbidden modules that leaked into ``sys.modules``, + the built-in modules registration had to skip, and the registered counts. + """ + completed = run_trusted_python(_CHILD_SCRIPT) + assert completed.returncode == 0, completed.stdout + completed.stderr + return json.loads(completed.stdout) + + +class TestAFreshImport: + """Extended tier: the shared ``fresh_import_facts`` fixture spawns an interpreter. + + All tests read one child-process report, so the ~0.6s is only saved when the + whole class is deselected. ``test_deferred_training_symbol_resolves`` below needs + no child process and stays in the fast tier, where it is the cheap half of this + guard. + """ + + pytestmark = pytest.mark.slow + + def test_import_drevalpy_does_not_import_the_heavy_stack(self, fresh_import_facts: dict[str, object]) -> None: + assert fresh_import_facts["leaked"] == [], ( + f"import drevalpy pulled in {fresh_import_facts['leaked']}. Move the offending module-scope " + "import into the method that needs it (and under `if TYPE_CHECKING:` for annotations); " + "see the docstring of this file." + ) + + def test_import_drevalpy_registers_every_builtin_module(self, fresh_import_facts: dict[str, object]) -> None: + """A deferred import must not turn into a silently skipped component.""" + assert fresh_import_facts["skipped"] == [], ( + f"registration skipped {fresh_import_facts['skipped']}; their components are unavailable" + ) + + @pytest.mark.parametrize( + ("key", "expected"), + [ + ("n_predictors", 27), + ("n_cell_line_featurizers", 17), + ("n_drug_featurizers", 10), + ], + ) + def test_registration_stayed_eager_and_complete( + self, fresh_import_facts: dict[str, object], key: str, expected: int + ) -> None: + """Making the import cheap must not make registration lazy or partial. + + The point of every deferral in this file is that the *registered module* + gets cheaper to import, not that fewer modules are registered. Duplicated + on purpose from ``tests/registry/test_builtins.py``: asserted here it is the + counter-weight that stops a future "optimisation" from hitting the numbers + above by simply registering less. + """ + assert fresh_import_facts[key] == expected + + +@pytest.mark.parametrize(("module_name", "symbol"), DEFERRED_TRAINING_SYMBOLS) +def test_deferred_training_symbol_resolves(module_name: str, symbol: str) -> None: + module = __import__(module_name, fromlist=[symbol]) + assert hasattr(module, symbol), f"{module_name} no longer exposes {symbol}" + + +@pytest.mark.parametrize(("module_name", "symbol"), LAZY_RE_EXPORTS) +def test_lazy_re_export_resolves(module_name: str, symbol: str) -> None: + """The historical import path must keep working through the lazy re-export.""" + module = __import__(module_name, fromlist=[symbol]) + assert getattr(module, symbol) is not None + + +@pytest.mark.parametrize( + "module_name", + [ + "drevalpy.components.featurizers.cell_line.normalized_proteomics", + "drevalpy.components.featurizers.cell_line.dipk_gene_expression", + ], +) +def test_lazy_re_export_still_raises_for_unknown_names(module_name: str) -> None: + """A module-level ``__getattr__`` must not turn typos into silent ``None``.""" + module = __import__(module_name, fromlist=["__name__"]) + with pytest.raises(AttributeError): + getattr(module, "definitely_not_a_real_symbol") # noqa: B009 diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 000000000..031d410cd --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,64 @@ +"""Tests for the top-level :mod:`drevalpy` package surface. + +``import drevalpy`` is the entry point every consumer and the CLI go through, and +this barrel re-exports the handful of names that make up the advertised API. It +carries no ``__all__``: the ``import x as x`` spelling is what marks each name as +re-exported for the type checker, so the promise is pinned by the explicit table +below, driven by ``tests/_barrel_surface.py``. + +Origins are recorded against the sibling *sub-packages* rather than the private +modules behind them - that ``drevalpy.construct_model`` and +``drevalpy.models.construct_model`` are one object is the promise; which module +implements it is not. + +The import-cost property of this same barrel is guarded separately in +``tests/test_import_cost_policy.py``, so every assertion here is an attribute +lookup on the already-imported module. +""" + +from __future__ import annotations + +import importlib +import re +from types import ModuleType + +import pytest + +import drevalpy +from tests._barrel_surface import ReExportSurface + +#: ``top-level name -> sub-package it comes from``. +EXPECTED_ORIGINS: dict[str, str] = { + "construct_model": "drevalpy.models", + "load": "drevalpy.data.datasets", + "randomization": "drevalpy.experiment", + "robustness": "drevalpy.experiment", + "run": "drevalpy._run", + "single": "drevalpy._single", + "split": "drevalpy.data", +} + +#: Sub-packages re-exported eagerly so a bare ``import drevalpy`` is enough. +PROMISED_SUBMODULES = ("registry",) + + +class TestTopLevelSurface(ReExportSurface): + barrel = drevalpy + origins = EXPECTED_ORIGINS + callable_names = tuple(EXPECTED_ORIGINS) + + +@pytest.mark.parametrize("name", sorted(PROMISED_SUBMODULES)) +def test_promised_submodule_is_bound_on_the_package(name: str) -> None: + submodule = getattr(drevalpy, name) + assert isinstance(submodule, ModuleType) + assert submodule is importlib.import_module(f"drevalpy.{name}") + + +def test_version_is_a_release_string() -> None: + assert re.fullmatch(r"\d+\.\d+(\.\d+)?\S*", drevalpy.__version__), drevalpy.__version__ + + +def test_unknown_attribute_raises() -> None: + with pytest.raises(AttributeError): + getattr(drevalpy, "definitely_not_a_real_symbol") # noqa: B009 diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 000000000..875e2a863 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,96 @@ +"""Integration test: full pipeline load → split → train → predict.""" + +from __future__ import annotations + +import pytest +from upath import UPath + +from drevalpy.data import split +from drevalpy.data._paths import get_default_data_dir, resolve_h5mu_path +from drevalpy.data.quality import curve_quality_mask +from drevalpy.models import construct_model +from drevalpy.registry.dataset._registry import dataset_registry +from drevalpy.types.data.dataset import Dataset + +#: Extended tier: the two tests here train ElasticNet and split a real dataset, +#: costing ~6.3s of the suite between them - by far the largest single group. +pytestmark = pytest.mark.slow + +DATASET_NAME = "CTRPv1" + + +def _cached_h5mu() -> UPath | None: + """Return the local .h5mu for :data:`DATASET_NAME`, if it is already cached. + + Mirrors the two locations ``load`` looks in before it would download. + + :returns: Existing local path, or ``None`` when the dataset is not cached. + """ + candidates = [resolve_h5mu_path(DATASET_NAME)] + if dataset_registry.is_registered(DATASET_NAME): + candidates.append(get_default_data_dir() / dataset_registry.datasets[DATASET_NAME].file) + return next((path for path in candidates if path.is_file()), None) + + +@pytest.fixture +def mudataset(): + """Load the real dataset from the local cache, skipping when it is absent. + + Deliberately reads the cached file directly instead of calling ``load``, so + an uncached run skips without attempting a download. The only source + registered for this dataset is a credentialed ``s3://`` bucket, and a + download attempt without those credentials raises ``botocore`` errors such as + ``UnauthorizedSSOTokenError``. Those are not ``OSError`` subclasses, so they + used to surface as test *errors* rather than skips and broke any run on a + machine without the dataset. + + Bypassing ``load`` matters for a second reason now: a cache filled before the + CurveCurator refit holds a file of the same name without the curve-quality + layers, and ``load`` deliberately deletes and re-downloads such a file. That + is right in production and wrong in a test, which must not pull hundreds of + megabytes, so a stale cache is a skip here. + """ + path = _cached_h5mu() + if path is None: + pytest.skip(f"{DATASET_NAME} is not in the local cache; not downloading it here") + try: + dataset = Dataset.load(path) + except Exception as exc: # noqa: BLE001 - any failure to obtain the data is a skip, not a failure + pytest.skip(f"cached {DATASET_NAME} at {path} could not be loaded: {exc!r}") + + try: + curve_quality_mask(dataset) + except KeyError: + pytest.skip(f"cached {DATASET_NAME} at {path} predates the curve-quality layers") + return dataset + + +class TestFullPipeline: + def test_split_produces_valid_folds(self, mudataset): + folds = split(mudataset, "LCO", n_splits=2) + assert len(folds) == 2 + for fold in folds: + assert fold.train.mask.ndim == 2 + assert fold.test.mask.ndim == 2 + assert fold.train.mask.dtype == bool + assert fold.train.any() + assert fold.test.any() + + def test_elastic_net_train_predict(self, mudataset): + """ElasticNet should train and predict via the run() function without errors.""" + from drevalpy import single + from drevalpy.registry.splitter import splitter_registry + + ElasticNet = construct_model("ElasticNet") # noqa: N806 + + splitter = splitter_registry.get("LCO") + folds = splitter(mudataset, n_splits=2, validation_ratio=0.2) + + result = single( + model_class=ElasticNet, + mudataset=mudataset, + split_masks=folds[0], + hyperparameter_tuning=False, + ) + assert len(result.predictions) > 0 + assert result.metrics diff --git a/tests/test_layering_policy.py b/tests/test_layering_policy.py new file mode 100644 index 000000000..864e768b6 --- /dev/null +++ b/tests/test_layering_policy.py @@ -0,0 +1,43 @@ +"""Layering policy: ``drevalpy.data`` must never depend on ``drevalpy.components``.""" + +from __future__ import annotations + +import re +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] / "drevalpy" +DATASETS_ROOT = PACKAGE_ROOT / "datasets" + +# Absolute references to the components layer, e.g. ``from drevalpy.components.x import y``, +# ``import drevalpy.components.x`` or ``from drevalpy import components``. +_ABSOLUTE_COMPONENTS = re.compile(r"drevalpy\.components\b|from\s+drevalpy\s+import\s+[^\n]*\bcomponents\b") + +# Relative references that escape into the sibling components package, e.g. ``from ..components import x``. +# Deliberately anchored on the ``from``/``import`` keyword so that unrelated dotted names such as +# ``networkx.algorithms.components.connected`` do not match. +_RELATIVE_COMPONENTS = re.compile(r"^\s*from\s+\.+components\b", re.MULTILINE) + + +def test_datasets_layer_does_not_import_components() -> None: + """``datasets`` does the loading, ``components`` decides what to load - never the reverse.""" + hits = [] + for path in sorted(DATASETS_ROOT.rglob("*.py")): + content = path.read_text(encoding="utf-8") + relative = path.relative_to(PACKAGE_ROOT.parent) + if _ABSOLUTE_COMPONENTS.search(content): + hits.append(f"{relative}: absolute import of drevalpy.components") + if _RELATIVE_COMPONENTS.search(content): + hits.append(f"{relative}: relative import of the components package") + assert not hits, "drevalpy/datasets must not depend on drevalpy/components: " + "; ".join(hits) + + +def test_layering_check_detects_violations() -> None: + """Guard the guard: the patterns must actually fire on the forms we care about.""" + assert _ABSOLUTE_COMPONENTS.search("from drevalpy.components.registry import get_drug_featurizer") + assert _ABSOLUTE_COMPONENTS.search("import drevalpy.components") + assert _ABSOLUTE_COMPONENTS.search("from drevalpy import components") + assert _RELATIVE_COMPONENTS.search("from ..components import featurizers") + assert _RELATIVE_COMPONENTS.search("from .components.base import Featurizer") + # Unrelated third-party dotted paths must not be flagged. + assert not _ABSOLUTE_COMPONENTS.search("import networkx.algorithms.components.connected as nxacc") + assert not _RELATIVE_COMPONENTS.search("import networkx.algorithms.components.connected as nxacc") diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 000000000..8bb4fd826 --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,124 @@ +"""Tests for the centralized Rich-backed logging setup. + +``drevalpy.log`` calls :func:`~drevalpy.log.setup_logging` at import time, so the +``drevalpy`` logger is already configured before any test runs, and pytest adds +handlers of its own to the *root* logger. Every test here therefore restores the +``drevalpy`` logger it touches and asserts on that logger only, never on global +handler counts. +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterator + +import pytest +from rich.logging import RichHandler + +from drevalpy.log import get_logger, setup_logging + + +@pytest.fixture +def drevalpy_logger() -> Iterator[logging.Logger]: + """Yield the package logger, restoring its handlers and level afterwards.""" + logger = logging.getLogger("drevalpy") + handlers = list(logger.handlers) + level = logger.level + yield logger + logger.handlers[:] = handlers + logger.setLevel(level) + + +class TestGetLogger: + def test_returns_a_logger_with_the_requested_name(self) -> None: + assert get_logger("drevalpy.models.foo").name == "drevalpy.models.foo" + + def test_is_idempotent_for_one_name(self) -> None: + assert get_logger("drevalpy.models.foo") is get_logger("drevalpy.models.foo") + + def test_matches_the_standard_library_lookup(self) -> None: + assert get_logger("drevalpy.models.foo") is logging.getLogger("drevalpy.models.foo") + + def test_child_loggers_propagate_to_the_package_logger(self, caplog: pytest.LogCaptureFixture) -> None: + logger = get_logger("drevalpy.models.foo") + + with caplog.at_level(logging.INFO, logger="drevalpy"): + logger.info("hello") + + assert "hello" in caplog.text + + +class TestSetupLogging: + def test_import_time_call_already_attached_a_rich_handler(self) -> None: + logger = logging.getLogger("drevalpy") + + assert any(isinstance(h, RichHandler) for h in logger.handlers) + + def test_attaches_a_rich_handler_when_none_is_present(self, drevalpy_logger: logging.Logger) -> None: + drevalpy_logger.handlers[:] = [] + + setup_logging() + + assert [type(h) for h in drevalpy_logger.handlers] == [RichHandler] + + def test_configured_handler_enables_markup_and_rich_tracebacks(self, drevalpy_logger: logging.Logger) -> None: + drevalpy_logger.handlers[:] = [] + + setup_logging() + + handler = drevalpy_logger.handlers[0] + assert isinstance(handler, RichHandler) + assert handler.markup is True + assert handler.rich_tracebacks is True + + def test_configured_handler_formats_the_bare_message(self, drevalpy_logger: logging.Logger) -> None: + drevalpy_logger.handlers[:] = [] + + setup_logging() + + formatter = drevalpy_logger.handlers[0].formatter + assert formatter is not None + assert ( + formatter.format(logging.LogRecord("drevalpy.x", logging.INFO, "f.py", 1, "plain text", None, None)) + == "plain text" + ) + + def test_defaults_to_info(self, drevalpy_logger: logging.Logger) -> None: + drevalpy_logger.setLevel(logging.CRITICAL) + + setup_logging() + + assert drevalpy_logger.level == logging.INFO + + def test_honours_an_explicit_level(self, drevalpy_logger: logging.Logger) -> None: + setup_logging(logging.DEBUG) + + assert drevalpy_logger.level == logging.DEBUG + + def test_does_not_add_a_second_handler(self, drevalpy_logger: logging.Logger) -> None: + drevalpy_logger.handlers[:] = [] + setup_logging() + existing = list(drevalpy_logger.handlers) + + setup_logging(logging.WARNING) + + assert drevalpy_logger.handlers == existing + + def test_updates_the_level_of_an_already_configured_logger(self, drevalpy_logger: logging.Logger) -> None: + sentinel = logging.NullHandler() + drevalpy_logger.handlers[:] = [sentinel] + + setup_logging(logging.ERROR) + + assert drevalpy_logger.handlers == [sentinel] + assert drevalpy_logger.level == logging.ERROR + + def test_emitted_records_reach_handlers_at_the_configured_level( + self, drevalpy_logger: logging.Logger, caplog: pytest.LogCaptureFixture + ) -> None: + setup_logging(logging.WARNING) + + with caplog.at_level(logging.DEBUG, logger="drevalpy"): + get_logger("drevalpy.models.foo").warning("warned") + + assert [r.message for r in caplog.records] == ["warned"] diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index 5de4ad946..000000000 --- a/tests/test_main.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Test suite for the main functionality of drevalpy.""" - -import os -import pathlib -import tempfile -from argparse import Namespace - -import pytest - -from drevalpy.utils import check_arguments, main -from drevalpy.visualization.create_report import create_report -from drevalpy.visualization.utils import ( - create_output_directories, - parse_results, - prep_results, -) - - -@pytest.mark.parametrize( - "args", - [ - { - "run_id": "test_run", - "dataset_name": "TOYv1", - "models": ["ElasticNet"], - "baselines": ["NaiveMeanEffectsPredictor", "NaivePredictor"], - "test_mode": ["LPO"], - "randomization_mode": ["SVRC"], - "randomization_type": "permutation", - "n_trials_robustness": 2, - "cross_study_datasets": ["TOYv2"], - "no_refitting": False, - "curve_curator_cores": 1, - "measure": "LN_IC50", - "overwrite": False, - "optim_metric": "RMSE", - "n_cv_splits": 2, - "response_transformation": "standard", - "multiprocessing": False, - "model_checkpoint_dir": "TEMPORARY", - "no_hyperparameter_tuning": True, - "final_model_on_full_data": True, - "wandb_project": None, - } - ], -) -def test_drevalpy_main(args, data_dir): - """ - Tests drevalpy, i.e., all functionality of the main experiment and report. - - :param args: arguments for the main function - :param data_dir: path to the data directory - """ - with tempfile.TemporaryDirectory() as temp_dir: - args["path_out"] = temp_dir - args["path_data"] = str(data_dir) - args = Namespace(**args) - check_arguments(args) - - try: - main(args) - except Exception as e: - pytest.fail(f"Main function failed: {e}") - - # Check output directory contains the run_id folder - assert args.run_id in os.listdir(temp_dir) - - # Run report generation on the output of the main run - try: - create_report(args.run_id, args.dataset_name, args.path_data, temp_dir) - except Exception as e: - pytest.fail(f"Report generation failed: {e}") - - result_path = pathlib.Path(temp_dir).resolve() - path_data = pathlib.Path(args.path_data).resolve() - - # Parse and prep results - ( - evaluation_results, - evaluation_results_per_drug, - evaluation_results_per_cell_line, - true_vs_pred, - ) = parse_results(path_to_results=f"{result_path}/{args.run_id}", dataset=args.dataset_name) - - ( - evaluation_results, - evaluation_results_per_drug, - evaluation_results_per_cell_line, - true_vs_pred, - ) = prep_results( - eval_results=evaluation_results, - eval_results_per_drug=evaluation_results_per_drug, - eval_results_per_cell_line=evaluation_results_per_cell_line, - t_vs_p=true_vs_pred, - path_data=path_data, - ) - - # Basic structural assertions - expected_eval_cols = 15 - expected_tvp_cols = 11 - assert len(evaluation_results.columns) == expected_eval_cols - assert len(evaluation_results_per_drug.columns) == expected_eval_cols - assert len(evaluation_results_per_cell_line.columns) == expected_eval_cols - assert len(true_vs_pred.columns) == expected_tvp_cols - - # Check models and baselines present in evaluation results - assert all(model in evaluation_results.algorithm.unique() for model in args.models) - assert all(baseline in evaluation_results.algorithm.unique() for baseline in args.baselines) - assert "predictions" in evaluation_results.rand_setting.unique() - - # Check randomization modes in rand_setting - if args.randomization_mode: - for rand_setting in args.randomization_mode: - assert any( - setting.startswith(f"randomize-{rand_setting}") - for setting in evaluation_results.rand_setting.unique() - ) - - # Check robustness trials presence - if args.n_trials_robustness > 0: - assert any( - setting.startswith(f"robustness-{args.n_trials_robustness}") - for setting in evaluation_results.rand_setting.unique() - ) - - # Check test modes and CV splits - assert all(test_mode in evaluation_results.test_mode.unique() for test_mode in args.test_mode) - assert evaluation_results.CV_split.astype(int).max() == (args.n_cv_splits - 1) - - # Check some metric threshold - assert evaluation_results.Pearson.astype(float).max() > 0.5 - - # Verify output directories exist (from report generation) - create_output_directories(result_path, args.run_id) diff --git a/tests/test_module_mirror_policy.py b/tests/test_module_mirror_policy.py new file mode 100644 index 000000000..05f708677 --- /dev/null +++ b/tests/test_module_mirror_policy.py @@ -0,0 +1,106 @@ +"""Policy test: every module in ``drevalpy`` has a mirrored test file. + +The mirroring convention is documented in ``AGENTS.md``. This guard enforces it +so a new source module cannot land without a test file at the mirrored location. + +Naming, following ``AGENTS.md`` rules 1-4: + +* A public module ``drevalpy/a/b/c.py`` requires ``tests/a/b/test_c.py``. +* A private module ``_c.py`` is satisfied by *either* ``test__c.py`` or the + underscore-stripped ``test_c.py``. The stripped form is the house style - see + ``tests/models/config/`` and ``tests/registry/`` - but both are accepted so a + literal mirror is never wrong. +* ``__init__.py`` is not checked here. Package surfaces are tested in + ``test_init.py`` where they re-export something worth pinning, but plenty of + ``__init__.py`` files hold nothing but imports, and demanding a file for each + would push the suite towards exactly the stub mirrors ``AGENTS.md`` forbids. + +Only the package tree is walked, so the repository's other Python - ``tools/``, +``docs/`` generators, ``tests/`` itself - is out of scope by construction. The +``EXEMPT_MODULES`` list below is for modules inside the package that are +deliberately not mirrored; keep it as short as it is now. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +PACKAGE_ROOT = REPO_ROOT / "drevalpy" +TESTS_ROOT = REPO_ROOT / "tests" + +#: Modules inside the package that intentionally have no mirrored test. +#: +#: ``_make_gene_lists.py`` is a maintenance script used to regenerate the +#: packaged gene-list CSVs from an upstream annotation dump. It is not imported +#: by any shipped code path and is excluded from coverage measurement via +#: ``[tool.coverage.run].omit`` in ``pyproject.toml``; mirroring it would test +#: the tooling rather than the library. +EXEMPT_MODULES: frozenset[str] = frozenset( + { + "components/featurizers/cell_line/gene_lists/_make_gene_lists.py", + } +) + + +def _discover_modules() -> list[str]: + """Return every non-``__init__`` module path, relative to the package root. + + Returns: + Sorted POSIX-style relative paths, excluding :data:`EXEMPT_MODULES`. + """ + modules = [] + for py_file in sorted(PACKAGE_ROOT.rglob("*.py")): + if py_file.name == "__init__.py": + continue + relative = py_file.relative_to(PACKAGE_ROOT).as_posix() + if relative in EXEMPT_MODULES: + continue + modules.append(relative) + return modules + + +def _candidate_test_paths(relative_module: str) -> list[Path]: + """Return the acceptable mirrored test paths for one module. + + Args: + relative_module: Module path relative to the package root. + + Returns: + ``[test_<name>.py]`` for a public module; for a private module also the + underscore-stripped spelling, in the order they should be reported. + """ + rel = Path(relative_module) + mirrored_dir = TESTS_ROOT / rel.parent + candidates = [mirrored_dir / f"test_{rel.name}"] + stripped = rel.name.lstrip("_") + if stripped != rel.name: + candidates.append(mirrored_dir / f"test_{stripped}") + return candidates + + +ALL_MODULES = _discover_modules() + + +def test_exempt_modules_still_exist() -> None: + """Keep :data:`EXEMPT_MODULES` from silently outliving its modules.""" + stale = sorted(name for name in EXEMPT_MODULES if not (PACKAGE_ROOT / name).is_file()) + assert not stale, f"EXEMPT_MODULES lists modules that no longer exist: {stale}" + + +@pytest.mark.parametrize("relative_module", ALL_MODULES) +def test_module_has_mirrored_test(relative_module: str) -> None: + """Fail when a source module has no mirrored test file.""" + assert (PACKAGE_ROOT / relative_module).is_file(), f"missing source module: {relative_module}" + + candidates = _candidate_test_paths(relative_module) + if any(candidate.is_file() for candidate in candidates): + return + + expected = " or ".join(str(candidate.relative_to(REPO_ROOT)) for candidate in candidates) + pytest.fail( + f"Missing test mirror for drevalpy/{relative_module}: expected {expected}. " + "See the test-layout section of AGENTS.md; do not add a stub to satisfy this guard." + ) diff --git a/tests/test_response_transformation_contract.py b/tests/test_response_transformation_contract.py new file mode 100644 index 000000000..f3dbd8684 --- /dev/null +++ b/tests/test_response_transformation_contract.py @@ -0,0 +1,290 @@ +"""End-to-end contract for the ``response_transformation`` pipeline setting. + +This file is deliberately not a module mirror: the behaviour it pins is a +pipeline-wide contract that only exists once ``drevalpy/utils/response_transform.py``, +``models/component_stack.py``, ``models/drp_model.py``, +``models/tuning/hpo_runtime.py`` and ``_single.py`` agree with each other. Fitting +it into any one of those mirrors would hide the fact that it is the *seam* being +tested, not a module. + +The contract, in one line: fit the scaler on the training scope only, train on +transformed targets, inverse-transform the predictions, and score against +untouched ground truth. + +Two properties make that contract falsifiable end to end: + +* **Affine equivalence.** Squared-error trees are affine-equivariant - splits are + chosen by a gain that scales uniformly with the target and leaves are means - + so a symmetric transform/inverse round trip is a provable no-op for + ``GradientBoosting``. Any asymmetry breaks the identity: training on raw + targets and inverse-transforming anyway shifts every prediction by the training + mean, and transforming without inverting shrinks them onto unit variance. + ``GradientBoosting`` (``HistGradientBoostingRegressor``) is used rather than + ``randomForest`` because its default ``random_state`` is ``None``, so two + bootstrap-sampled fits of the forest would disagree for reasons unrelated to + the transform. +* **HPO round trip.** Nothing in ``hpam_tune`` used to fit the transformer, so a + non-``None`` value raised ``NotFittedError`` on the first trial - and the + Optuna objective swallows trial exceptions, so the only visible symptom was an + empty trial list. Both are asserted. + +Between the two sits the fit itself: the scaler's statistics must come from the +training scope alone, which is the one place the design can leak held-out +responses into training. +""" + +from __future__ import annotations + +import logging +import warnings +from typing import Any + +import numpy as np +import pytest +from sklearn.preprocessing import StandardScaler + +from drevalpy import single +from drevalpy.data import split +from drevalpy.models import construct_model +from drevalpy.models.tuning.config import build_experiment_hpo_config +from drevalpy.models.tuning.hpo import hpam_tune +from drevalpy.models.tuning.hpo_runtime import _mu_evaluate_trial_all_metrics +from drevalpy.types import SplitMask, SplitMasks +from drevalpy.types.results.run import RunResult +from drevalpy.utils import fit_response_transformation + +#: Two trials is the smallest budget that still proves the objective ran more +#: than once; the suite pays for every extra fit. +HPO_TRIALS = 2 + + +class _LogCapture(logging.Handler): + """Collect one logger's records, formatted traceback included. + + ``caplog`` cannot be used here: the tuning run is shared by several + assertions and therefore lives in a class-scoped fixture, while ``caplog`` is + function scoped. The formatted text is what matters - ``logger.exception`` + puts the exception type in the traceback rather than the message. + """ + + def __init__(self, logger_name: str) -> None: + super().__init__() + self._logger = logging.getLogger(logger_name) + self._chunks: list[str] = [] + + def emit(self, record: logging.LogRecord) -> None: + self._chunks.append(self.format(record)) + + def __enter__(self) -> _LogCapture: + self._logger.addHandler(self) + return self + + def __exit__(self, *_: object) -> None: + self._logger.removeHandler(self) + + @property + def text(self) -> str: + return "\n".join(self._chunks) + + +@pytest.fixture(scope="module") +def fold(synthetic_dataset) -> SplitMasks: + """The first of two LCO folds over the session-wide synthetic dataset.""" + return split(synthetic_dataset, "LCO", n_splits=2)[0] + + +def _train_responses(mudataset, scope) -> np.ndarray: + """Return the non-NaN raw responses inside *scope*.""" + pairs = scope.pairs + responses = mudataset.response_matrix[pairs[:, 0], pairs[:, 1]] + return responses[~np.isnan(responses)].astype(np.float64) + + +class TestAffineEquivalence: + """A squared-error tree must be indifferent to a symmetric affine transform.""" + + @pytest.fixture(scope="class") + def runs(self, synthetic_dataset, fold: SplitMasks) -> dict[str, RunResult]: + """Three ``single`` runs: raw, raw again, and standardized. + + The second raw run is the determinism control. Without it a broken + equivalence assertion is indistinguishable from a predictor that simply + does not reproduce its own predictions. + """ + model_class = construct_model("GradientBoosting") + return { + "raw": single(model_class, synthetic_dataset, fold, hyperparameter_tuning=False), + "raw_again": single(model_class, synthetic_dataset, fold, hyperparameter_tuning=False), + "standard": single( + model_class, + synthetic_dataset, + fold, + hyperparameter_tuning=False, + response_transformation=StandardScaler(), + ), + } + + def test_the_predictor_is_deterministic(self, runs: dict[str, RunResult]) -> None: + np.testing.assert_array_equal(runs["raw_again"].predictions, runs["raw"].predictions) + + def test_standardizing_the_target_leaves_predictions_unchanged(self, runs: dict[str, RunResult]) -> None: + np.testing.assert_allclose(runs["standard"].predictions, runs["raw"].predictions, rtol=1e-5, atol=1e-6) + + def test_standardizing_the_target_leaves_rmse_unchanged(self, runs: dict[str, RunResult]) -> None: + assert runs["standard"].metrics["RMSE"] == pytest.approx(runs["raw"].metrics["RMSE"], rel=1e-5) + + def test_ground_truth_is_never_transformed( + self, runs: dict[str, RunResult], synthetic_dataset, fold: SplitMasks + ) -> None: + pairs = fold.test.pairs + expected = synthetic_dataset.response_matrix[pairs[:, 0], pairs[:, 1]] + + np.testing.assert_allclose(runs["standard"].ground_truth, expected, equal_nan=True) + + def test_predictions_are_not_inverse_transformed_twice( + self, runs: dict[str, RunResult], synthetic_dataset, fold: SplitMasks + ) -> None: + """Pin the original bug: raw training plus an inverse transform anyway. + + That combination reproduces ``raw`` predictions pushed through + ``inverse_transform``, which on this fixture is a shift of roughly the + training mean, so it is comfortably distinguishable from equality. + """ + reference = StandardScaler().fit(_train_responses(synthetic_dataset, fold.train_val).reshape(-1, 1)) + double_scaled = reference.inverse_transform(runs["raw"].predictions.reshape(-1, 1)).ravel() + + assert not np.allclose(runs["standard"].predictions, double_scaled, rtol=1e-3) + + +class TestFittedOnTheTrainingScopeOnly: + """``fit_response_transformation`` must not see held-out responses. + + The scaler's statistics are the leakage channel: fitted on the whole matrix + they carry the test fold's mean and variance into training, which is a real + if mild leak and makes folds incomparable. + """ + + def test_no_prototype_means_no_transformation(self, synthetic_dataset, fold: SplitMasks) -> None: + assert fit_response_transformation(None, synthetic_dataset, fold.train) is None + + def test_the_mean_is_the_training_scope_mean(self, synthetic_dataset, fold: SplitMasks) -> None: + fitted = fit_response_transformation(StandardScaler(), synthetic_dataset, fold.train) + + expected = _train_responses(synthetic_dataset, fold.train).mean() + assert float(fitted.mean_[0]) == pytest.approx(expected) + + def test_the_scale_is_the_training_scope_deviation(self, synthetic_dataset, fold: SplitMasks) -> None: + fitted = fit_response_transformation(StandardScaler(), synthetic_dataset, fold.train) + + expected = _train_responses(synthetic_dataset, fold.train).std() + assert float(fitted.scale_[0]) == pytest.approx(expected) + + def test_the_mean_is_not_the_full_matrix_mean(self, synthetic_dataset, fold: SplitMasks) -> None: + train_mean = _train_responses(synthetic_dataset, fold.train).mean() + full_mean = float(np.nanmean(synthetic_dataset.response_matrix)) + # Guard the test's own premise: on this fixture the two differ by ~0.11, + # so an equality below is evidence of leakage and not of a tied fixture. + assert abs(train_mean - full_mean) > 1e-2 + + fitted = fit_response_transformation(StandardScaler(), synthetic_dataset, fold.train) + + assert float(fitted.mean_[0]) != pytest.approx(full_mean, abs=1e-3) + + def test_a_different_scope_yields_different_statistics(self, synthetic_dataset, fold: SplitMasks) -> None: + on_train = fit_response_transformation(StandardScaler(), synthetic_dataset, fold.train) + on_test = fit_response_transformation(StandardScaler(), synthetic_dataset, fold.test) + + assert float(on_train.mean_[0]) != pytest.approx(float(on_test.mean_[0]), abs=1e-3) + + def test_unmeasured_pairs_do_not_poison_the_fit(self, synthetic_dataset) -> None: + everything = SplitMask(np.ones(synthetic_dataset.response_matrix.shape, dtype=bool)) + + fitted = fit_response_transformation(StandardScaler(), synthetic_dataset, everything) + + assert np.isfinite(fitted.mean_).all() + assert float(fitted.mean_[0]) == pytest.approx(float(np.nanmean(synthetic_dataset.response_matrix))) + + def test_the_prototype_is_cloned_rather_than_fitted(self, synthetic_dataset, fold: SplitMasks) -> None: + prototype = StandardScaler() + + fitted = fit_response_transformation(prototype, synthetic_dataset, fold.train) + + assert fitted is not prototype + assert not hasattr(prototype, "mean_") + + +class TestHpoRoundTrip: + """Tuning with a transform must complete instead of raising ``NotFittedError``.""" + + def test_a_single_trial_evaluation_completes(self, synthetic_dataset, fold: SplitMasks) -> None: + """The trial helpers own the fit, so they take an *unfitted* prototype. + + This is the narrowest reproduction of the crash: unlike the Optuna + objective, ``_mu_evaluate_trial_all_metrics`` does not swallow + exceptions, so a missing fit surfaces as ``NotFittedError`` here. + """ + trial_model = construct_model("ElasticNet")({"alpha": 0.1, "l1_ratio": 0.5}) + + metrics, predictions = _mu_evaluate_trial_all_metrics( + trial_model, + mudataset=synthetic_dataset, + train_scope=fold.train, + val_scope=fold.val, + early_stopping_scope=None, + response_transformation=StandardScaler(), + model_checkpoint_dir=None, + ) + + assert np.isfinite(metrics["RMSE"]) + assert len(predictions) > 0 + + @pytest.fixture(scope="class") + def tuning(self, synthetic_dataset, fold: SplitMasks) -> dict[str, Any]: + """One ``hpam_tune`` call with a standard transform on a regularized linear model. + + Trial exceptions are logged rather than raised, and a study with no valid + trial falls back to the defaults with a warning, so both channels are + captured here and asserted separately below. + """ + prototype = StandardScaler() + records = _LogCapture("drevalpy.models.tuning.hpo") + with records, warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + best_params, trials = hpam_tune( + model_class=construct_model("ElasticNet"), + mudataset=synthetic_dataset, + train_scope=fold.train, + val_scope=fold.val, + early_stopping_scope=None, + response_transformation=prototype, + metric="RMSE", + hpo_config=build_experiment_hpo_config("RMSE", n_trials=HPO_TRIALS, random_state=0), + ) + return { + "prototype": prototype, + "best_params": best_params, + "trials": trials, + "log_text": records.text, + "warnings": [str(entry.message) for entry in caught], + } + + def test_no_trial_raises_not_fitted_error(self, tuning: dict[str, Any]) -> None: + assert "NotFittedError" not in tuning["log_text"] + + def test_no_trial_fails_at_all(self, tuning: dict[str, Any]) -> None: + assert "Optuna trial" not in tuning["log_text"] + + def test_every_trial_is_recorded(self, tuning: dict[str, Any]) -> None: + """An empty list is how the swallowed ``NotFittedError`` used to present.""" + assert len(tuning["trials"]) == HPO_TRIALS + + def test_every_trial_scores_a_finite_metric(self, tuning: dict[str, Any]) -> None: + assert all(np.isfinite(metrics["RMSE"]) for _, metrics, _ in tuning["trials"]) + + def test_tuning_does_not_fall_back_to_the_defaults(self, tuning: dict[str, Any]) -> None: + assert not [message for message in tuning["warnings"] if "did not find a valid configuration" in message] + assert tuning["best_params"] + + def test_the_callers_prototype_is_left_unfitted(self, tuning: dict[str, Any]) -> None: + """Only ``fit_response_transformation`` fits, and it fits a clone.""" + assert not hasattr(tuning["prototype"], "mean_") diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 000000000..7844f007b --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,386 @@ +"""Tests for the top-level ``run`` orchestrator. + +``drevalpy/run.py`` contains no numerical logic of its own: it loads or accepts a +dataset, expands it into folds, optionally multiplies those folds by robustness +trials, optionally adds randomized copies of the dataset, and hands every +combination to :func:`drevalpy.single`. These tests therefore replace +``single`` with a recorder and assert the fan-out arithmetic and the kwargs it +forwards, so nothing here trains a model. + +``robustness`` is *not* stubbed: it is four statements over real ``SplitMask`` +objects, so exercising it for real is cheaper than faking it and lets the +``fold_metadata["robustness_trial"]`` assertion cover the whole path. +""" + +from __future__ import annotations + +import importlib +from typing import Any + +import numpy as np +import pytest + +from drevalpy import run +from drevalpy.types import SplitMask, SplitMasks +from drevalpy.types.results import ExperimentResult +from tests.synthetic.results import DEFAULT_DATASET_NAME, make_run_result + +#: The orchestrator lives in the private ``drevalpy._run`` module so that the +#: public ``drevalpy.run`` name can be the re-exported function. Patching the +#: private module is what ``run``'s own top-level imports actually read. +RUN_MODULE = importlib.import_module("drevalpy._run") + + +class _FakeDataset: + """Stand-in for ``Dataset`` exposing only what ``run`` and the stubs read.""" + + def __init__(self, name: str = DEFAULT_DATASET_NAME, tag: str = "original") -> None: + self.name = name + self.tag = tag + self.randomization: tuple[str, str] | None = None + + +def _stub_model(name: str) -> type: + """Build a minimal ``DRPModel`` stand-in identified by ``name``.""" + + class _StubModel: + @classmethod + def get_model_name(cls) -> str: + return name + + _StubModel.__name__ = name + return _StubModel + + +def _make_masks(*, fold_index: int, shape: tuple[int, int] = (4, 3)) -> SplitMasks: + """Build one fold of real masks with a disjoint train/test/val partition.""" + train = np.zeros(shape, dtype=bool) + test = np.zeros(shape, dtype=bool) + val = np.zeros(shape, dtype=bool) + train[:2, :] = True + test[2, :] = True + val[3, :] = True + return SplitMasks( + train=SplitMask(train), + test=SplitMask(test), + val=SplitMask(val), + metadata={"fold_index": fold_index, "split_mode": "LCO", "fold_id": f"fold_{fold_index}"}, + ) + + +class _SingleRecorder: + """Records every ``single`` call and returns a matching ``RunResult``.""" + + def __init__(self) -> None: + self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = [] + + def __call__(self, model_class: type, mudataset: Any, split_masks: SplitMasks, **kwargs: Any): + self.calls.append(((model_class, mudataset, split_masks), kwargs)) + return make_run_result( + model_name=model_class.get_model_name(), + dataset_name=mudataset.name, + fold_index=split_masks.metadata["fold_index"], + fold_metadata=dict(split_masks.metadata), + randomization=mudataset.randomization, + ) + + @property + def datasets(self) -> list[Any]: + return [args[1] for args, _ in self.calls] + + @property + def masks(self) -> list[SplitMasks]: + return [args[2] for args, _ in self.calls] + + @property + def model_names(self) -> list[str]: + return [args[0].get_model_name() for args, _ in self.calls] + + +@pytest.fixture +def dataset() -> _FakeDataset: + return _FakeDataset() + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> _SingleRecorder: + """Replace ``single`` with a recorder and neutralise ``split``/``load``. + + ``split`` returns two folds by default; individual tests override it. + """ + stub = _SingleRecorder() + monkeypatch.setattr(RUN_MODULE, "single", stub) + monkeypatch.setattr(RUN_MODULE, "split", lambda ds, mode: [_make_masks(fold_index=i) for i in range(2)]) + monkeypatch.setattr(RUN_MODULE, "load", lambda name: _FakeDataset(name=name)) + return stub + + +def _set_folds(monkeypatch: pytest.MonkeyPatch, n_folds: int) -> None: + monkeypatch.setattr(RUN_MODULE, "split", lambda ds, mode: [_make_masks(fold_index=i) for i in range(n_folds)]) + + +def _set_randomizations(monkeypatch: pytest.MonkeyPatch, n: int) -> None: + def fake_randomization(model_class, ds, modes, randomization_type="permutation"): + copies = [] + for index in range(n): + copy = _FakeDataset(name=ds.name, tag=f"random_{index}") + copy.randomization = (modes[0], f"view_{index}") + copies.append(copy) + return copies + + monkeypatch.setattr(RUN_MODULE, "randomization", fake_randomization) + + +class TestDatasetResolution: + def test_a_dataset_name_is_loaded(self, recorder: _SingleRecorder, monkeypatch: pytest.MonkeyPatch) -> None: + loaded = [] + monkeypatch.setattr(RUN_MODULE, "load", lambda name: loaded.append(name) or _FakeDataset(name=name)) + + run([_stub_model("A")], "CTRPv1", "LCO", hyperparameter_tuning=False) + + assert loaded == ["CTRPv1"] + + def test_the_loaded_dataset_is_the_one_handed_to_single( + self, recorder: _SingleRecorder, monkeypatch: pytest.MonkeyPatch + ) -> None: + sentinel = _FakeDataset(name="CTRPv1", tag="loaded") + monkeypatch.setattr(RUN_MODULE, "load", lambda name: sentinel) + + run([_stub_model("A")], "CTRPv1", "LCO", hyperparameter_tuning=False) + + assert recorder.datasets == [sentinel, sentinel] + + def test_a_dataset_object_is_used_without_loading( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(RUN_MODULE, "load", lambda name: pytest.fail("load must not be called")) + + run([_stub_model("A")], dataset, "LCO", hyperparameter_tuning=False) + + assert recorder.datasets == [dataset, dataset] + + +class TestFanOut: + @pytest.mark.parametrize( + ("n_models", "n_folds"), + [ + pytest.param(1, 1, id="1x1"), + pytest.param(2, 3, id="2x3"), + pytest.param(3, 2, id="3x2"), + ], + ) + def test_one_run_per_model_and_fold( + self, + recorder: _SingleRecorder, + dataset: _FakeDataset, + monkeypatch: pytest.MonkeyPatch, + n_models: int, + n_folds: int, + ) -> None: + _set_folds(monkeypatch, n_folds) + models = [_stub_model(f"Model{i}") for i in range(n_models)] + + result = run(models, dataset, "LCO", hyperparameter_tuning=False) + + assert len(recorder.calls) == n_models * n_folds + assert sum(m.n_folds for m in result.models) == n_models * n_folds + + @pytest.mark.parametrize("n_randomizations", [1, 3]) + def test_randomization_adds_one_run_per_randomized_dataset( + self, + recorder: _SingleRecorder, + dataset: _FakeDataset, + monkeypatch: pytest.MonkeyPatch, + n_randomizations: int, + ) -> None: + n_models, n_folds = 2, 3 + _set_folds(monkeypatch, n_folds) + _set_randomizations(monkeypatch, n_randomizations) + models = [_stub_model(f"Model{i}") for i in range(n_models)] + + result = run(models, dataset, "LCO", randomization_modes=["SVRC"], hyperparameter_tuning=False) + + expected = n_models * n_folds * (1 + n_randomizations) + assert len(recorder.calls) == expected + assert sum(m.n_folds for m in result.models) == expected + + def test_the_original_dataset_runs_before_its_randomized_copies( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 1) + _set_randomizations(monkeypatch, 2) + + run([_stub_model("A")], dataset, "LCO", randomization_modes=["SVRC"], hyperparameter_tuning=False) + + assert [ds.tag for ds in recorder.datasets] == ["original", "random_0", "random_1"] + + def test_an_empty_randomization_mode_list_adds_nothing( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 2) + monkeypatch.setattr(RUN_MODULE, "randomization", lambda *a, **k: pytest.fail("must not randomize")) + + run([_stub_model("A")], dataset, "LCO", randomization_modes=[], hyperparameter_tuning=False) + + assert len(recorder.calls) == 2 + + def test_no_models_yields_no_runs_and_an_empty_experiment_is_rejected( + self, recorder: _SingleRecorder, dataset: _FakeDataset + ) -> None: + with pytest.raises(ValueError, match="must not be empty"): + run([], dataset, "LCO", hyperparameter_tuning=False) + + +class TestRobustness: + @pytest.mark.parametrize("trials", [1, 2, 4]) + def test_trials_multiply_the_fold_count( + self, + recorder: _SingleRecorder, + dataset: _FakeDataset, + monkeypatch: pytest.MonkeyPatch, + trials: int, + ) -> None: + n_folds = 3 + _set_folds(monkeypatch, n_folds) + + run([_stub_model("A")], dataset, "LCO", robustness_trials=trials, hyperparameter_tuning=False) + + assert len(recorder.calls) == n_folds * trials + + def test_zero_trials_leaves_the_folds_untouched( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 3) + + run([_stub_model("A")], dataset, "LCO", robustness_trials=0, hyperparameter_tuning=False) + + assert len(recorder.calls) == 3 + assert all("robustness_trial" not in masks.metadata for masks in recorder.masks) + + def test_each_fold_records_its_trial_index( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 2) + + run([_stub_model("A")], dataset, "LCO", robustness_trials=3, hyperparameter_tuning=False) + + assert [masks.metadata["robustness_trial"] for masks in recorder.masks] == [0, 1, 2, 0, 1, 2] + + def test_the_trial_index_reaches_the_result_metadata( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 1) + + result = run([_stub_model("A")], dataset, "LCO", robustness_trials=2, hyperparameter_tuning=False) + + assert result.has_robustness + assert [r.fold_metadata["robustness_trial"] for r in result.models[0].runs] == [0, 1] + + def test_the_original_fold_index_survives_the_multiplication( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 2) + + run([_stub_model("A")], dataset, "LCO", robustness_trials=2, hyperparameter_tuning=False) + + assert [masks.metadata["fold_index"] for masks in recorder.masks] == [0, 0, 1, 1] + + +class TestForwardedArguments: + def test_hpo_settings_reach_single( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 1) + + run( + [_stub_model("A")], + dataset, + "LCO", + hyperparameter_tuning=True, + hpo_metric="MSE", + hpo_num_samples=4, + hpo_random_state=7, + precomputed_only=True, + ) + + _, kwargs = recorder.calls[0] + assert kwargs == { + "hyperparameter_tuning": True, + "hpo_metric": "MSE", + "hpo_num_samples": 4, + "hpo_random_state": 7, + "precomputed_only": True, + } + + def test_the_split_mode_reaches_split( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + seen: list[str] = [] + + def fake_split(ds: Any, mode: str) -> list[SplitMasks]: + seen.append(mode) + return [_make_masks(fold_index=0)] + + monkeypatch.setattr(RUN_MODULE, "split", fake_split) + + run([_stub_model("A")], dataset, "LDO", hyperparameter_tuning=False) + + assert seen == ["LDO"] + + def test_the_randomization_type_reaches_randomization( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 1) + seen: list[dict[str, Any]] = [] + + def fake_randomization(model_class, ds, modes, **kwargs): + seen.append({"modes": modes, **kwargs}) + return [] + + monkeypatch.setattr(RUN_MODULE, "randomization", fake_randomization) + + run( + [_stub_model("A")], + dataset, + "LCO", + randomization_modes=["SVRC", "SVRD"], + randomization_type="invariant", + hyperparameter_tuning=False, + ) + + assert seen == [{"modes": ["SVRC", "SVRD"], "randomization_type": "invariant"}] + + +class TestGrouping: + def test_results_are_grouped_by_model_name( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 3) + models = [_stub_model("Alpha"), _stub_model("Beta")] + + result = run(models, dataset, "LCO", hyperparameter_tuning=False) + + assert isinstance(result, ExperimentResult) + assert sorted(result.model_names) == ["Alpha", "Beta"] + assert [m.n_folds for m in result.models] == [3, 3] + + def test_models_are_iterated_before_folds( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 2) + models = [_stub_model("Alpha"), _stub_model("Beta")] + + run(models, dataset, "LCO", hyperparameter_tuning=False) + + assert recorder.model_names == ["Alpha", "Alpha", "Beta", "Beta"] + + def test_randomized_runs_are_grouped_with_their_model( + self, recorder: _SingleRecorder, dataset: _FakeDataset, monkeypatch: pytest.MonkeyPatch + ) -> None: + _set_folds(monkeypatch, 1) + _set_randomizations(monkeypatch, 2) + + result = run([_stub_model("Alpha")], dataset, "LCO", randomization_modes=["SVRC"], hyperparameter_tuning=False) + + assert result.model_names == ["Alpha"] + assert result.has_randomization diff --git a/tests/test_single.py b/tests/test_single.py new file mode 100644 index 000000000..7c37f6369 --- /dev/null +++ b/tests/test_single.py @@ -0,0 +1,481 @@ +"""Tests for the single model + single fold execution unit. + +:func:`drevalpy.single` is the seam every entry point funnels through, but +it was effectively untested on CI: ``tests/test_integration.py`` only exercises it +when a real ``CTRPv1`` ``.h5mu`` happens to be cached, so a clean checkout skipped +it entirely. This module replaces that path with the in-memory +``synthetic_dataset`` fixture. + +Two levels are used deliberately: + +* :class:`TestEndToEnd` trains a real ``ElasticNet`` on real split masks, so the + happy path is honest rather than mocked. +* The remaining classes drive a stub model. ``single``'s own contract with a + model is only ``get_model_name`` / ``supports_early_stopping`` / + ``get_default_hyperparameters`` / ``train`` / ``predict``, and a stub is the + only way to pin down exactly which ``early_stopping_scope`` it was handed or to + assert the response-transformation arithmetic against known predictions. +""" + +from __future__ import annotations + +import importlib +from typing import Any + +import numpy as np +import pytest +from sklearn.preprocessing import StandardScaler + +from drevalpy import single +from drevalpy.data import split +from drevalpy.evaluation import AVAILABLE_METRICS +from drevalpy.models import construct_model +from drevalpy.types import SplitMask, SplitMasks +from drevalpy.types.results.run import RunResult + +#: ``single`` lives in the private ``drevalpy._single`` module so that the public +#: ``drevalpy.single`` name can be the re-exported function. +SINGLE_MODULE = importlib.import_module("drevalpy._single") + + +class _StubModel: + """Minimal stand-in for a ``DRPModel`` subclass. + + Class attributes configure the branches ``single`` selects on; instance + attributes record what ``train`` was handed. Instances are collected on the + class so a test can reach the one ``single`` created internally. + """ + + early_stopping: bool = False + default_hyperparameters: dict[str, Any] = {"alpha": 0.5} # noqa: RUF012 - plain class-level config + instances: list[_StubModel] = [] # noqa: RUF012 - plain class-level config + prediction_value: float | None = None + + def __init__(self, hyperparameters: dict[str, Any]) -> None: + self.hyperparameters = hyperparameters + self.train_calls: list[dict[str, Any]] = [] + self.predict_calls: list[SplitMask] = [] + type(self).instances.append(self) + + @classmethod + def get_model_name(cls) -> str: + return "StubModel" + + @classmethod + def supports_early_stopping(cls) -> bool: + return cls.early_stopping + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, Any]: + return dict(cls.default_hyperparameters) + + def train( + self, + *, + mudataset: Any, + scope: SplitMask, + early_stopping_scope: SplitMask | None, + model_checkpoint_dir: str, + response_transformation: Any = None, + ) -> None: + self.train_calls.append( + { + "mudataset": mudataset, + "scope": scope, + "early_stopping_scope": early_stopping_scope, + "model_checkpoint_dir": model_checkpoint_dir, + "response_transformation": response_transformation, + } + ) + + def predict(self, *, mudataset: Any, scope: SplitMask) -> np.ndarray: + self.predict_calls.append(scope) + n_pairs = len(scope.pairs) + if self.prediction_value is None: + return np.arange(n_pairs, dtype=float) + return np.full(n_pairs, self.prediction_value, dtype=float) + + +@pytest.fixture +def stub_model() -> type[_StubModel]: + """Return a fresh ``_StubModel`` subclass so class-level state is per-test.""" + return type("_FreshStubModel", (_StubModel,), {"instances": []}) + + +@pytest.fixture +def folds(synthetic_dataset) -> list[SplitMasks]: + """Two real LCO folds over the synthetic dataset.""" + return split(synthetic_dataset, "LCO", n_splits=2) + + +def _masks_with_val_pairs(shape: tuple[int, int], n_val: int) -> SplitMasks: + """Build masks whose ``val`` holds exactly ``n_val`` pairs.""" + train = np.zeros(shape, dtype=bool) + test = np.zeros(shape, dtype=bool) + val = np.zeros(shape, dtype=bool) + train[:2, :] = True + test[2, :] = True + flat_val = val.reshape(-1) + flat_val[shape[1] * 3 : shape[1] * 3 + n_val] = True + return SplitMasks( + train=SplitMask(train), + test=SplitMask(test), + val=SplitMask(val), + metadata={"split_mode": "LCO", "fold_index": 0, "fold_id": "abc123"}, + ) + + +class TestEndToEnd: + """A real model on real folds, so the happy path is not mocked.""" + + @pytest.fixture(scope="class") + def result(self, synthetic_dataset) -> RunResult: + elastic_net = construct_model("ElasticNet") + fold = split(synthetic_dataset, "LCO", n_splits=2)[0] + return single(elastic_net, synthetic_dataset, fold, hyperparameter_tuning=False) + + def test_returns_a_run_result(self, result: RunResult) -> None: + assert isinstance(result, RunResult) + + def test_identifies_the_model_and_dataset(self, result: RunResult, synthetic_dataset) -> None: + assert result.model_name == "ElasticNet" + assert result.dataset_name == synthetic_dataset.name + + def test_predicts_one_value_per_test_pair(self, result: RunResult, synthetic_dataset) -> None: + fold = split(synthetic_dataset, "LCO", n_splits=2)[0] + + assert len(result.predictions) == len(fold.test.pairs) + assert len(result.ground_truth) == len(result.predictions) + + def test_predictions_are_finite(self, result: RunResult) -> None: + assert np.isfinite(result.predictions).all() + + def test_reports_every_available_metric(self, result: RunResult) -> None: + assert set(result.metrics) == set(AVAILABLE_METRICS) + + def test_records_the_default_hyperparameters(self, result: RunResult) -> None: + assert result.best_hyperparameters == construct_model("ElasticNet").get_default_hyperparameters() + + def test_skipping_hpo_records_no_trials(self, result: RunResult) -> None: + assert result.trials is None + + def test_ids_line_up_with_the_test_pairs(self, result: RunResult, synthetic_dataset) -> None: + fold = split(synthetic_dataset, "LCO", n_splits=2)[0] + pairs = fold.test.pairs + + np.testing.assert_array_equal(result.cell_line_ids, synthetic_dataset.cell_line_ids[pairs[:, 0]]) + np.testing.assert_array_equal(result.drug_ids, synthetic_dataset.drug_ids[pairs[:, 1]]) + + def test_ground_truth_is_read_from_the_response_matrix(self, result: RunResult, synthetic_dataset) -> None: + fold = split(synthetic_dataset, "LCO", n_splits=2)[0] + pairs = fold.test.pairs + expected = synthetic_dataset.response_matrix[pairs[:, 0], pairs[:, 1]] + + np.testing.assert_allclose(result.ground_truth, expected, equal_nan=True) + + +class TestFoldMetadata: + def test_split_metadata_is_copied_onto_the_result( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + result = single(stub_model, synthetic_dataset, folds[1], hyperparameter_tuning=False) + + assert result.split_mode == folds[1].metadata["split_mode"] + assert result.fold_index == folds[1].metadata["fold_index"] + assert result.fold_id == folds[1].metadata["fold_id"] + assert result.fold_metadata == folds[1].metadata + + def test_fold_metadata_does_not_alias_the_split_masks( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + result = single(stub_model, synthetic_dataset, folds[1], hyperparameter_tuning=False) + + result.fold_metadata["injected"] = "value" + + assert "injected" not in folds[1].metadata + + def test_absent_metadata_falls_back_to_defaults(self, synthetic_dataset, stub_model: type[_StubModel]) -> None: + shape = synthetic_dataset.response_matrix.shape + masks = SplitMasks( + train=SplitMask(np.eye(*shape, dtype=bool)), + test=SplitMask(np.flipud(np.eye(*shape, dtype=bool))), + val=SplitMask(np.zeros(shape, dtype=bool)), + ) + + result = single(stub_model, synthetic_dataset, masks, hyperparameter_tuning=False) + + assert result.split_mode == "" + assert result.fold_index == 0 + assert result.fold_id == "" + + def test_the_dataset_randomization_tag_is_propagated( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + randomized = synthetic_dataset.with_randomized_views( + ["gene_expression"], random_state=0, randomization=("SVRC", "gene_expression") + ) + + result = single(stub_model, randomized, folds[0], hyperparameter_tuning=False) + + assert result.randomization == ("SVRC", "gene_expression") + + +class TestTraining: + def test_trains_on_the_merged_train_and_val_mask( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + scope = stub_model.instances[0].train_calls[0]["scope"] + np.testing.assert_array_equal(scope.mask, folds[0].train_val.mask) + + def test_predicts_on_the_test_mask( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + np.testing.assert_array_equal(stub_model.instances[0].predict_calls[0].mask, folds[0].test.mask) + + def test_the_model_is_built_from_the_default_hyperparameters( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + assert stub_model.instances[0].hyperparameters == {"alpha": 0.5} + + def test_the_checkpoint_directory_exists_during_training( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + assert stub_model.instances[0].train_calls[0]["model_checkpoint_dir"] + + def test_metrics_are_empty_when_no_prediction_is_valid( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + stub_model.prediction_value = float("nan") + + result = single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + assert result.metrics == {} + + +class TestEarlyStopping: + def test_a_model_without_support_gets_no_early_stopping_scope( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + stub_model.early_stopping = False + + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + assert stub_model.instances[0].train_calls[0]["early_stopping_scope"] is None + + def test_a_supporting_model_gets_a_carved_out_scope( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + stub_model.early_stopping = True + + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + scope = stub_model.instances[0].train_calls[0]["early_stopping_scope"] + expected, _ = folds[0].early_stopping_mask() + assert scope is not None + np.testing.assert_array_equal(scope.mask, expected.mask) + + def test_the_early_stopping_scope_is_a_subset_of_val( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + stub_model.early_stopping = True + + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + scope = stub_model.instances[0].train_calls[0]["early_stopping_scope"] + assert scope is not None + assert scope.any() + assert (scope.mask & ~folds[0].val.mask).sum() == 0 + + @pytest.mark.parametrize( + ("n_val", "expects_scope"), + [ + pytest.param(1, False, id="a-single-val-pair-is-not-enough"), + pytest.param(0, False, id="no-val-pairs-at-all"), + pytest.param(4, True, id="several-val-pairs-carve-a-scope"), + ], + ) + def test_the_val_mask_must_hold_more_than_one_pair( + self, + synthetic_dataset, + stub_model: type[_StubModel], + n_val: int, + expects_scope: bool, + ) -> None: + stub_model.early_stopping = True + masks = _masks_with_val_pairs(synthetic_dataset.response_matrix.shape, n_val) + + single(stub_model, synthetic_dataset, masks, hyperparameter_tuning=False) + + scope = stub_model.instances[0].train_calls[0]["early_stopping_scope"] + assert (scope is not None) is expects_scope + + +class TestResponseTransformation: + def _train_responses(self, synthetic_dataset, masks: SplitMasks) -> np.ndarray: + pairs = masks.train_val.pairs + responses = synthetic_dataset.response_matrix[pairs[:, 0], pairs[:, 1]] + return responses[~np.isnan(responses)] + + def test_the_caller_s_transformer_is_left_unfitted( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + scaler = StandardScaler() + + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False, response_transformation=scaler) + + assert not hasattr(scaler, "mean_") + + def test_the_model_is_trained_on_the_transformed_target_space( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + """The original bug: predictions were inverse-transformed but training stayed raw. + + ``single`` therefore has to hand the model a *fitted* transformer, whose + statistics come from the train_val scope it also inverts with. + """ + single( + stub_model, + synthetic_dataset, + folds[0], + hyperparameter_tuning=False, + response_transformation=StandardScaler(), + ) + + handed = stub_model.instances[0].train_calls[0]["response_transformation"] + expected_mean = float(self._train_responses(synthetic_dataset, folds[0]).mean()) + assert float(handed.mean_[0]) == pytest.approx(expected_mean) + + def test_no_transformation_hands_the_model_nothing( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + assert stub_model.instances[0].train_calls[0]["response_transformation"] is None + + def test_predictions_are_mapped_back_to_the_response_scale( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + result = single( + stub_model, + synthetic_dataset, + folds[0], + hyperparameter_tuning=False, + response_transformation=StandardScaler(), + ) + + raw = np.arange(len(folds[0].test.pairs), dtype=float) + reference = StandardScaler().fit(self._train_responses(synthetic_dataset, folds[0]).reshape(-1, 1)) + expected = reference.inverse_transform(raw.reshape(-1, 1)).ravel() + np.testing.assert_allclose(result.predictions, expected) + + def test_the_transform_is_fitted_on_non_nan_training_responses_only( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + stub_model.prediction_value = 0.0 + + result = single( + stub_model, + synthetic_dataset, + folds[0], + hyperparameter_tuning=False, + response_transformation=StandardScaler(), + ) + + # A zero prediction inverse-transforms to the fitted mean. + expected_mean = float(self._train_responses(synthetic_dataset, folds[0]).mean()) + np.testing.assert_allclose(result.predictions, expected_mean, rtol=1e-6) + + def test_no_transformation_leaves_predictions_untouched( + self, synthetic_dataset, folds: list[SplitMasks], stub_model: type[_StubModel] + ) -> None: + result = single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=False) + + np.testing.assert_array_equal(result.predictions, np.arange(len(folds[0].test.pairs), dtype=float)) + + +class TestHyperparameterTuning: + def test_tuning_records_one_trial_per_sampled_configuration( + self, + synthetic_dataset, + folds: list[SplitMasks], + stub_model: type[_StubModel], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + raw_trials = [ + ({"alpha": 0.1}, {"RMSE": 1.0}, np.array([1.0, 2.0])), + ({"alpha": 0.9}, {"RMSE": 2.0}, np.array([3.0, 4.0])), + ] + monkeypatch.setattr(SINGLE_MODULE, "hpam_tune", lambda **kwargs: ({"alpha": 0.1}, raw_trials)) + + result = single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=True, hpo_metric="RMSE") + + assert result.trials is not None + assert [t.hyperparameters for t in result.trials] == [{"alpha": 0.1}, {"alpha": 0.9}] + assert [t.optimization_metric for t in result.trials] == ["RMSE", "RMSE"] + assert result.best_hyperparameters == {"alpha": 0.1} + + def test_the_tuner_receives_the_folds_scopes_and_hpo_settings( + self, + synthetic_dataset, + folds: list[SplitMasks], + stub_model: type[_StubModel], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + seen: dict[str, Any] = {} + + def fake_tune(**kwargs: Any): + seen.update(kwargs) + return {"alpha": 0.2}, [] + + monkeypatch.setattr(SINGLE_MODULE, "hpam_tune", fake_tune) + + single( + stub_model, + synthetic_dataset, + folds[0], + hyperparameter_tuning=True, + hpo_metric="MSE", + hpo_num_samples=3, + hpo_random_state=11, + precomputed_only=True, + ) + + assert seen["model_class"] is stub_model + assert seen["mudataset"] is synthetic_dataset + assert seen["metric"] == "MSE" + assert seen["precomputed_only"] is True + assert seen["early_stopping_scope"] is None + np.testing.assert_array_equal(seen["train_scope"].mask, folds[0].train.mask) + np.testing.assert_array_equal(seen["val_scope"].mask, folds[0].val.mask) + assert seen["hpo_config"].n_trials == 3 + assert seen["hpo_config"].random_state == 11 + + def test_early_stopping_narrows_the_validation_scope_handed_to_the_tuner( + self, + synthetic_dataset, + folds: list[SplitMasks], + stub_model: type[_StubModel], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + stub_model.early_stopping = True + seen: dict[str, Any] = {} + + def fake_tune(**kwargs: Any): + seen.update(kwargs) + return {"alpha": 0.2}, [] + + monkeypatch.setattr(SINGLE_MODULE, "hpam_tune", fake_tune) + + single(stub_model, synthetic_dataset, folds[0], hyperparameter_tuning=True) + + es_expected, val_expected = folds[0].early_stopping_mask() + np.testing.assert_array_equal(seen["early_stopping_scope"].mask, es_expected.mask) + np.testing.assert_array_equal(seen["val_scope"].mask, val_expected.mask) diff --git a/tests/test_tissue_mapping.py b/tests/test_tissue_mapping.py deleted file mode 100644 index 3f5d7752c..000000000 --- a/tests/test_tissue_mapping.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Test suite for tissue mapping functionality in the drevalpy package.""" - -import pandas as pd -import pytest - -from drevalpy.datasets.map_tissues import main - - -@pytest.fixture -def test_data(tmp_path): - """Create a temporary directory with dummy data for testing. - - :param tmp_path: pytest fixture for creating temporary directories. - :returns: Tuple containing the root directory and dataset name. - """ - # Setup directory - root = tmp_path / "data" - root.mkdir() - meta = root / "meta" - meta.mkdir() - - # Create dummy dataset - ds_name = "TESTSET" - ds_path = root / ds_name - ds_path.mkdir() - df = pd.DataFrame({"cellosaurus_id": ["CVCL_TEST1", "CVCL_TEST2"]}) - df.to_csv(ds_path / f"{ds_name}.csv", index=False) - - # Create dummy depmap metadata - depmap = pd.DataFrame( - { - "DepMap_ID": ["ACH-000001", "ACH-000002"], - "stripped_cell_line_name": ["testcl1", "testcl2"], - "disease": ["lung", ""], - "disease_sutype": ["lung cancer", "leukemia"], - "disease_sub_subtype": ["adenocarcinoma", "AML"], - "culture_type": ["Adherent", "Suspension"], - "culture_medium": ["RPMI", "DMEM"], - "gender": ["Male", "Female"], - "source": ["Broad", "Broad"], - } - ) - - depmap.to_csv(meta / "DepMap_sample_info.csv", index=False) - - # Create dummy Cellosaurus file - cellosaurus_text = ( - "ID TestCL1\n" - "AC CVCL_TEST1;\n" - "CC Derived from site: lung; lung\n" - "DI ; NCIt; Lung adenocarcinoma\n" - "//\n" - "ID TestCL2\n" - "AC CVCL_TEST2;\n" - "CC Derived from site: blood; blood\n" - "DI ; NCIt; Acute myeloid leukemia\n" - "//\n" - ) - (meta / "cellosaurus.txt").write_text(cellosaurus_text, encoding="utf-8") - - return root, ds_name - - -def test_map_tissues(monkeypatch, test_data): - """Test the map_tissues function. - - :param monkeypatch: pytest fixture to modify the environment for testing. - :param test_data: fixture providing a temporary directory with dummy data. - """ - root, ds_name = test_data - - monkeypatch.setattr("sys.argv", ["script.py", str(root), ds_name, "--save_tissue_mapping"]) - main() - - # Check output file exists - output_path = root / "meta" / "tissue_mapping.csv" - assert output_path.exists() - - # Load and check contents - df_out = pd.read_csv(output_path) - print("\n=== tissue_mapping.csv ===") - print(df_out) - - assert "tissue" in df_out.columns - print("\n=== tissue for CVCL_TEST1 ===") - print(df_out.loc[df_out["cellosaurus_id"] == "CVCL_TEST1", "tissue"]) - - print("\n=== tissue for CVCL_TEST2 ===") - print(df_out.loc[df_out["cellosaurus_id"] == "CVCL_TEST2", "tissue"]) - - assert df_out.loc[df_out["cellosaurus_id"] == "CVCL_TEST1", "tissue"].values[0] == "Lung" - assert df_out.loc[df_out["cellosaurus_id"] == "CVCL_TEST2", "tissue"].values[0] == "Blood" - - # Check the updated dataset has a tissue column - updated = pd.read_csv(root / ds_name / f"{ds_name}.csv") - print("\n=== Updated dataset ===") - print(updated) - - assert "tissue" in updated.columns - assert set(updated["tissue"]) == {"Lung", "Blood"} diff --git a/tests/test_train_mutability.py b/tests/test_train_mutability.py deleted file mode 100644 index 9acc42c99..000000000 --- a/tests/test_train_mutability.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Test for train_and_predict dataset mutation fix.""" - -import numpy as np - -from drevalpy.datasets.dataset import DrugResponseDataset, FeatureDataset -from drevalpy.experiment import train_and_predict -from drevalpy.models import MODEL_FACTORY - - -def test_train_and_predict_does_not_mutate_with_reduce_to(data_dir): - """Test that reduce_to etc. doesn't mutate the original datasets. - - Before the fix: reduce_to was called directly on input datasets. - After the fix: train_and_predict copies datasets first. - - :param data_dir: path to the data directory - """ - train = DrugResponseDataset( - response=np.array([1.0, 2.0, 3.0, 4.0, 5.0]), - cell_line_ids=np.array(["CL-1", "CL-2", "CL-3", "CL-4", "CL-5"]), - drug_ids=np.array(["Drug-1", "Drug-1", "Drug-1", "Drug-1", "Drug-1"]), - dataset_name="Toy_Data", - ) - - test = DrugResponseDataset( - response=np.array([1.5, 2.5, 3.5]), - cell_line_ids=np.array(["CL-1", "CL-2", "CL-3"]), - drug_ids=np.array(["Drug-1", "Drug-1", "Drug-1"]), - dataset_name="Toy_Data", - ) - - original_train_len = len(train) - original_test_len = len(test) - - model = MODEL_FACTORY["NaivePredictor"]() - - # Create FeatureDataset with only some cell lines, forces reduce_to to remove rows - cl_features = FeatureDataset( - features={ - "CL-1": {"cell_line_id": np.array(["CL-1"])}, - "CL-2": {"cell_line_id": np.array(["CL-2"])}, - } - ) - drug_features = FeatureDataset(features={"Drug-1": {"drug_id": np.array(["Drug-1"])}}) - - train_and_predict( - model=model, - hpams={}, - path_data=str(data_dir), - train_dataset=train, - prediction_dataset=test, - cl_features=cl_features, - drug_features=drug_features, - ) - - # Before fix: these fail because reduce_to removes rows from original datasets - # After fix: these pass because train_and_predict copies datasets first - assert ( - len(train) == original_train_len - ), f"train_dataset was mutated by reduce_to: {original_train_len} -> {len(train)}" - assert len(test) == original_test_len, f"test_dataset was mutated by reduce_to: {original_test_len} -> {len(test)}" diff --git a/tests/test_visualization_payload_policy.py b/tests/test_visualization_payload_policy.py new file mode 100644 index 000000000..e4d5576cb --- /dev/null +++ b/tests/test_visualization_payload_policy.py @@ -0,0 +1,243 @@ +"""Policy test: visualization payloads must not scale with the prediction count. + +The OOM this guard exists to prevent came from +``ComparisonScatterVisualization.compute`` retaining one ``{"x": …, "y": …}`` +dict per shared prediction *per model pair*: at 96 models that is +``C(96,2) x 231,080`` dicts, roughly 250 GB, and the process was killed inside the +first plot. ``RegressionScatterVisualization`` had the milder version of the same +shape, 5.33 GB of point dicts across 96 models. + +Both are now bounded by ``models x groups`` and by the rendered image +respectively. The tests below assert that empirically rather than by inspecting +the source: they grow the number of *predictions* while holding models and groups +fixed, and require the retained bytes and the report payload not to follow. A +reintroduced per-sample payload fails here even if it is spelled differently. + +This lives at the ``tests/`` root alongside the other cross-cutting guards +(``test_architecture_policy.py``, ``test_layering_policy.py``) because it pins a +property of the visualization layer as a whole rather than one module's +behaviour. +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterable + +import numpy as np +import pytest + +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.types.results.model import ModelResult +from drevalpy.visualization.plots.comparison_scatter import ComparisonScatterVisualization +from drevalpy.visualization.plots.regression_scatter import RegressionScatterVisualization +from tests.synthetic import DEFAULT_DATASET_NAME, make_run_result + +#: Held fixed across the two sizes so only the prediction count varies. +N_MODELS = 4 +N_DRUGS = 5 +N_CELL_LINES = 4 + +#: An 8x growth in predictions. Anything retaining per-sample state grows with +#: this; the plots under test must not. +SMALL_ROWS = 100 +LARGE_ROWS = 800 + +#: The per-sample implementations cost ~240 B per point, so an 8x row increase +#: moved ~1.3 MB in this fixture. Allowing 2x plus a fixed 64 kB is far below +#: that and far above the noise of a float32 matrix or a PNG re-render. +GROWTH_TOLERANCE = 2.0 +FIXED_ALLOWANCE_BYTES = 64_000 + + +def _experiment(n_rows: int) -> ExperimentResult: + """Build an experiment whose only varying dimension is the row count.""" + return ExperimentResult( + [ + make_run_result( + model_name=name, + fold_index=fold, + n_pairs=n_rows, + n_cell_lines=N_CELL_LINES, + n_drugs=N_DRUGS, + seed=index * 100 + fold, + ) + for index, name in enumerate(f"Model_{i}" for i in range(N_MODELS)) + for fold in range(2) + ] + ) + + +def _model_result(n_rows: int) -> ModelResult: + return ModelResult( + model_name="Model_0", + dataset_name=DEFAULT_DATASET_NAME, + runs=[ + make_run_result( + model_name="Model_0", + fold_index=fold, + n_pairs=n_rows, + n_cell_lines=N_CELL_LINES, + n_drugs=N_DRUGS, + ) + for fold in range(2) + ], + ) + + +def _deep_size(obj: object, seen: set[int] | None = None) -> int: + """Recursively size a Python object graph, counting each object once. + + ``sys.getsizeof`` alone reports a container's own overhead and misses the + per-element dicts that caused the original blow-up, so the walk is the point. + + :param obj: Object to size. + :param seen: Ids already counted, used for the recursive calls. + :returns: Total bytes reachable from ``obj``. + """ + seen = set() if seen is None else seen + if id(obj) in seen: + return 0 + seen.add(id(obj)) + + if isinstance(obj, np.ndarray): + return obj.nbytes + sys.getsizeof(obj) - obj.nbytes if obj.base is None else obj.nbytes + + total = sys.getsizeof(obj) + if isinstance(obj, dict): + for key, value in obj.items(): + total += _deep_size(key, seen) + _deep_size(value, seen) + elif isinstance(obj, str | bytes | bytearray): + return total + elif isinstance(obj, Iterable): + for item in obj: + total += _deep_size(item, seen) + return total + + +def _retained_bytes(plot: object) -> int: + """Size everything a computed visualization holds, excluding the input.""" + excluded = {"_result"} + return sum(_deep_size(value) for name, value in vars(plot).items() if name not in excluded) + + +def _payload_bytes(plot) -> int: + """Size the report payload a computed visualization emits.""" + return sum(len(section.content or "") for section in plot.to_multiqc()) + + +@pytest.fixture(scope="module") +def comparison_scatter_sizes() -> dict[int, tuple[int, int]]: + sizes = {} + for n_rows in (SMALL_ROWS, LARGE_ROWS): + plot = ComparisonScatterVisualization() + plot.compute(_experiment(n_rows)) + sizes[n_rows] = (_retained_bytes(plot), _payload_bytes(plot)) + return sizes + + +@pytest.fixture(scope="module") +def regression_scatter_payloads() -> dict[int, int]: + payloads = {} + for n_rows in (SMALL_ROWS, LARGE_ROWS): + plot = RegressionScatterVisualization() + plot.compute(_model_result(n_rows)) + payloads[n_rows] = _payload_bytes(plot) + return payloads + + +def _assert_bounded(small: int, large: int, label: str) -> None: + limit = small * GROWTH_TOLERANCE + FIXED_ALLOWANCE_BYTES + ratio = LARGE_ROWS / SMALL_ROWS + assert large <= limit, ( + f"{label} grew from {small:,} to {large:,} bytes for a {ratio:.0f}x increase in predictions " + f"({SMALL_ROWS} -> {LARGE_ROWS} rows per fold) with models and groups held fixed. " + "Visualization payloads must scale with models x groups, not with the number of samples - " + "see the docstring of this module." + ) + + +class TestComparisonScatter: + def test_retained_state_does_not_grow_with_predictions(self, comparison_scatter_sizes): + small, _ = comparison_scatter_sizes[SMALL_ROWS] + large, _ = comparison_scatter_sizes[LARGE_ROWS] + + _assert_bounded(small, large, "ComparisonScatterVisualization retained state") + + def test_report_payload_does_not_grow_with_predictions(self, comparison_scatter_sizes): + _, small = comparison_scatter_sizes[SMALL_ROWS] + _, large = comparison_scatter_sizes[LARGE_ROWS] + + _assert_bounded(small, large, "ComparisonScatterVisualization report payload") + + def test_retained_state_is_a_models_by_groups_matrix(self): + plot = ComparisonScatterVisualization() + + plot.compute(_experiment(SMALL_ROWS)) + + assert plot._matrices["drug"].values.shape == (N_MODELS, N_DRUGS) + assert plot._matrices["cell_line"].values.shape == (N_MODELS, N_CELL_LINES) + + def test_nothing_is_retained_per_model_pair(self): + """The original bug stored ``C(n_models, 2)`` point clouds.""" + plot = ComparisonScatterVisualization() + + plot.compute(_experiment(SMALL_ROWS)) + + assert not hasattr(plot, "_pair_data") + + def test_the_matrix_is_float32(self): + plot = ComparisonScatterVisualization() + + plot.compute(_experiment(SMALL_ROWS)) + + assert all(matrix.values.dtype == np.float32 for matrix in plot._matrices.values()) + + +class TestRegressionScatter: + def test_report_payload_does_not_grow_with_predictions(self, regression_scatter_payloads): + _assert_bounded( + regression_scatter_payloads[SMALL_ROWS], + regression_scatter_payloads[LARGE_ROWS], + "RegressionScatterVisualization report payload", + ) + + def test_retained_arrays_are_flat_floats_not_objects(self): + plot = RegressionScatterVisualization() + + plot.compute(_model_result(SMALL_ROWS)) + + for array in (plot._ground_truth, plot._predictions): + assert array.dtype == np.float64 + assert array.ndim == 1 + + def test_retained_arrays_cost_two_floats_per_prediction(self): + """A per-row dict cost ~240 B; two float64 arrays cost 16 B.""" + plot = RegressionScatterVisualization() + + plot.compute(_model_result(SMALL_ROWS)) + + assert plot._ground_truth.nbytes + plot._predictions.nbytes == 16 * 2 * SMALL_ROWS + + +class TestGuardIsMeaningful: + def test_the_bound_would_reject_a_per_sample_payload(self): + """Guard the guard: 240 B per point at both sizes must fail the check.""" + per_point = 240 + small = per_point * SMALL_ROWS * 2 + large = per_point * LARGE_ROWS * 2 + + with pytest.raises(AssertionError, match="must scale with models x groups"): + _assert_bounded(small, large, "hypothetical per-sample payload") + + def test_the_bound_accepts_a_constant_payload(self): + _assert_bounded(10_000, 10_000, "hypothetical constant payload") + + def test_deep_size_counts_per_element_dicts(self): + """The walk must see through a list, or it cannot detect the old shape.""" + points = [{"x": float(i), "y": float(i)} for i in range(50)] + + assert _deep_size(points) > 50 * sys.getsizeof({"x": 1.0, "y": 1.0}) * 0.5 + + def test_deep_size_counts_numpy_buffers(self): + assert _deep_size(np.zeros(1000, dtype=np.float64)) >= 8000 diff --git a/tests/testing/test_batch.py b/tests/testing/test_batch.py new file mode 100644 index 000000000..9620ce28b --- /dev/null +++ b/tests/testing/test_batch.py @@ -0,0 +1,157 @@ +"""Tests for :mod:`drevalpy.testing.batch`.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.testing.batch import ( + N_CELL_LINE_FEATURES, + N_DRUG_FEATURES, + build_synthetic_batch, + observed_pairs, +) +from drevalpy.testing.synthetic import build_synthetic_dataset +from drevalpy.types.data.batch.model_input_batch import ModelInputBatch + + +@pytest.fixture(scope="module") +def dataset(): + return build_synthetic_dataset() + + +@pytest.fixture(scope="module") +def batch(dataset) -> ModelInputBatch: + return build_synthetic_batch(dataset) + + +class TestObservedPairs: + def test_one_triple_per_measured_entry(self, dataset): + pairs = observed_pairs(dataset) + + assert len(pairs) == int((~np.isnan(dataset.response_matrix)).sum()) + + def test_no_unmeasured_pair_is_included(self, dataset): + pairs = observed_pairs(dataset) + + assert np.isfinite(pairs.response).all() + + def test_ids_are_drawn_from_the_dataset(self, dataset): + pairs = observed_pairs(dataset) + + assert set(pairs.cell_line_ids) <= set(dataset.cell_line_ids) + assert set(pairs.drug_ids) <= set(dataset.drug_ids) + + def test_all_three_arrays_are_the_same_length(self, dataset): + pairs = observed_pairs(dataset) + + assert len(pairs.cell_line_ids) == len(pairs.drug_ids) == len(pairs.response) + + +class TestBatchShape: + def test_it_returns_a_model_input_batch(self, batch): + assert isinstance(batch, ModelInputBatch) + + def test_there_is_one_pair_per_measured_entry(self, batch, dataset): + assert batch.n_pairs == int((~np.isnan(dataset.response_matrix)).sum()) + + def test_feature_matrices_have_the_default_widths(self, batch): + assert batch.cell_line_features.shape[1] == N_CELL_LINE_FEATURES + assert batch.drug_features.shape[1] == N_DRUG_FEATURES + + def test_feature_widths_are_configurable(self, dataset): + narrow = build_synthetic_batch(dataset, n_cell_line_features=3, n_drug_features=2) + + assert narrow.cell_line_features.shape[1] == 3 + assert narrow.drug_features.shape[1] == 2 + + def test_feature_rows_are_per_entity_not_per_pair(self, batch, dataset): + assert batch.cell_line_features.shape[0] == len(dataset.cell_line_ids) + assert batch.drug_features.shape[0] == len(dataset.drug_ids) + + def test_pair_indices_address_the_feature_rows(self, batch): + assert batch.cell_line_pair_idx.max() < batch.cell_line_features.shape[0] + assert batch.drug_pair_idx.max() < batch.drug_features.shape[0] + + def test_the_design_matrix_is_pair_aligned(self, batch): + matrix = batch.to_feature_matrix() + + assert matrix.shape == (batch.n_pairs, N_CELL_LINE_FEATURES + N_DRUG_FEATURES) + + def test_features_are_finite(self, batch): + assert np.isfinite(batch.cell_line_features).all() + assert np.isfinite(batch.drug_features).all() + + +class TestDrugFreeBatch: + def test_no_drug_features_are_produced(self, dataset): + cell_line_only = build_synthetic_batch(dataset, n_drug_features=None) + + assert cell_line_only.drug_features is None + assert cell_line_only.drug_pair_idx is None + + def test_the_design_matrix_covers_the_cell_line_side_only(self, dataset): + cell_line_only = build_synthetic_batch(dataset, n_drug_features=None) + + assert cell_line_only.to_feature_matrix().shape[1] == N_CELL_LINE_FEATURES + + +class TestNamedBlocks: + def test_no_blocks_are_exposed_by_default(self, batch): + assert batch.cell_line_blocks == {} + assert batch.drug_blocks == {} + + def test_requested_blocks_hold_the_feature_matrix(self, dataset): + with_blocks = build_synthetic_batch( + dataset, + cell_line_block_names=["identity"], + drug_block_names=["fingerprints"], + ) + + np.testing.assert_array_equal( + with_blocks.cell_line_blocks["identity"].values, + with_blocks.cell_line_features, + ) + np.testing.assert_array_equal( + with_blocks.drug_blocks["fingerprints"].values, + with_blocks.drug_features, + ) + + def test_drug_blocks_are_skipped_when_there_are_no_drug_features(self, dataset): + with_blocks = build_synthetic_batch(dataset, drug_block_names=["x"], n_drug_features=None) + + assert with_blocks.drug_blocks == {} + + +class TestLearnableResponse: + def test_the_response_is_finite(self, batch): + assert np.isfinite(batch.response).all() + + def test_it_carries_signal_a_linear_model_can_recover(self, batch): + """Otherwise ``check_predictor_fit_predict`` could only assert that it ran.""" + from sklearn.linear_model import Ridge + + matrix = batch.to_feature_matrix() + model = Ridge().fit(matrix, batch.response) + + residual = np.mean((model.predict(matrix) - batch.response) ** 2) + assert residual < np.var(batch.response) / 10 + + def test_it_replaces_the_dataset_response(self, batch, dataset): + """The drawn features carry the signal, so the raw responses cannot.""" + assert not np.allclose(batch.response, observed_pairs(dataset).response) + + +class TestDeterminism: + def test_the_same_seed_gives_the_same_features(self, dataset): + first = build_synthetic_batch(dataset) + second = build_synthetic_batch(dataset) + + np.testing.assert_array_equal(first.cell_line_features, second.cell_line_features) + np.testing.assert_array_equal(first.response, second.response) + + def test_a_different_seed_gives_different_features(self, dataset): + first = build_synthetic_batch(dataset, seed=1) + second = build_synthetic_batch(dataset, seed=2) + + assert not np.array_equal(first.cell_line_features, second.cell_line_features) diff --git a/tests/testing/test_conformance.py b/tests/testing/test_conformance.py new file mode 100644 index 000000000..d0521525b --- /dev/null +++ b/tests/testing/test_conformance.py @@ -0,0 +1,324 @@ +"""Tests for :mod:`drevalpy.testing.conformance`. + +Each check is asserted twice: once against a conforming component, and once +against one with exactly the defect the check exists to find. A check that only +ever passes is worthless, so the negative cases carry the weight here. + +The fixture components are declared locally rather than taken from the +registries. Registration is not needed to run a check, and a locally declared +class is what lets a single defect be introduced in isolation. +""" + +from __future__ import annotations + +from typing import ClassVar + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.components.featurizers.drug.base import DrugFeaturizer +from drevalpy.components.predictors.abstract.matrix import MatrixPredictor +from drevalpy.testing.batch import build_synthetic_batch +from drevalpy.testing.conformance import ( + FEATURIZER_CHECKS, + PREDICTOR_CHECKS, + ConformanceError, + check_featurizer_fit_transform, + check_featurizer_instantiates, + check_featurizer_state_round_trip, + check_predictor_fit_predict, + check_predictor_instantiates, + check_predictor_state_round_trip, + feature_source_for, +) +from drevalpy.testing.synthetic import build_synthetic_dataset +from drevalpy.types.data.batch.feature_block import BlockSpec, numeric_feature_block +from drevalpy.types.data.feature_source import CellLineFeatureSource, DrugFeatureSource + +BLOCK = "probe" + + +class GoodFeaturizer(CellLineFeaturizer): + """Conforming featurizer: hashed features, no view, honest state.""" + + entity_id_only: ClassVar[bool] = True + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec(BLOCK, FeatureFormat.NUMERIC_MATRIX),) + + def __init__(self, *, n_features: int = 4) -> None: + """Store the feature width and reset the fitted offset.""" + self._n_features = n_features + self._offset = 0.0 + + def _fit(self, source, **kwargs): + entity_ids = kwargs.get("entity_ids") + self._offset = float(len(entity_ids)) if entity_ids is not None else 0.0 + return self + + def _transform_blocks(self, source, entity_ids): + values = np.arange(len(entity_ids) * self._n_features, dtype=np.float32) + values = values.reshape(len(entity_ids), self._n_features) + self._offset + return {BLOCK: numeric_feature_block(values)} + + @property + def output_dim(self) -> int: + return self._n_features + + def get_state(self) -> dict[str, object]: + return {"n_features": self._n_features, "offset": self._offset} + + def set_state(self, state: dict[str, object]) -> None: + self._n_features = int(state["n_features"]) + self._offset = float(state["offset"]) + + +class GoodDrugFeaturizer(GoodFeaturizer, DrugFeaturizer): + """Same logic on the drug side, so ``side`` dispatch can be exercised.""" + + side: ClassVar[str] = "drug" + + +class RequiresAnArgumentFeaturizer(GoodFeaturizer): + """Defect: not constructible with defaults.""" + + def __init__(self, n_features: int) -> None: + """Require *n_features* positionally, which the check must reject.""" + super().__init__(n_features=n_features) + + +class MisreportsOutputDimFeaturizer(GoodFeaturizer): + """Defect: ``output_dim`` disagrees with the produced width.""" + + @property + def output_dim(self) -> int: + return self._n_features + 1 + + +class MisalignedBlockFeaturizer(GoodFeaturizer): + """Defect: emits fewer rows than there are entities.""" + + def _transform_blocks(self, source, entity_ids): + blocks = super()._transform_blocks(source, entity_ids) + return {BLOCK: numeric_feature_block(blocks[BLOCK].values[:-1])} + + +class EmptyBlocksFeaturizer(GoodFeaturizer): + """Defect: produces no blocks at all.""" + + def _transform_blocks(self, source, entity_ids): + return {} + + +class ForgetfulStateFeaturizer(GoodFeaturizer): + """Defect: ``get_state`` omits an attribute ``_transform_blocks`` reads.""" + + def get_state(self) -> dict[str, object]: + return {"n_features": self._n_features, "offset": 0.0} + + +class GoodPredictor(MatrixPredictor): + """Conforming predictor: ordinary least squares with honest state.""" + + def __init__(self, hyperparameters: dict[str, object] | None = None) -> None: + """Reset the fitted coefficients.""" + super().__init__(hyperparameters) + self._coefficients: np.ndarray | None = None + + def _fit_matrix(self, x: np.ndarray, y: np.ndarray) -> None: + self._coefficients = np.linalg.lstsq(_with_intercept(x), y, rcond=None)[0] + + def _predict_matrix(self, x: np.ndarray) -> np.ndarray: + if self._coefficients is None: + return np.full(len(x), np.nan) + return _with_intercept(x) @ self._coefficients + + def get_state(self) -> dict[str, object]: + return {"coefficients": None if self._coefficients is None else self._coefficients.tolist()} + + def set_state(self, state: dict[str, object]) -> None: + coefficients = state.get("coefficients") + self._coefficients = None if coefficients is None else np.asarray(coefficients, dtype=np.float64) + + +def _with_intercept(x: np.ndarray) -> np.ndarray: + return np.hstack([x, np.ones((len(x), 1), dtype=x.dtype)]) + + +class NeverFitsPredictor(GoodPredictor): + """Defect: ``_fit`` does nothing, so predictions stay NaN.""" + + def _fit_matrix(self, x: np.ndarray, y: np.ndarray) -> None: + return None + + +class ForgetfulStatePredictor(GoodPredictor): + """Defect: ``get_state`` drops the fitted coefficients.""" + + def get_state(self) -> dict[str, object]: + return {"trained": True} + + def set_state(self, state: dict[str, object]) -> None: + return None + + +class RequiresAnArgumentPredictor(GoodPredictor): + """Defect: not constructible with defaults.""" + + def __init__(self, alpha: float) -> None: + """Require *alpha* positionally, which the check must reject.""" + super().__init__({"alpha": alpha}) + + +@pytest.fixture(scope="module") +def dataset(): + return build_synthetic_dataset() + + +@pytest.fixture(scope="module") +def batch(dataset): + return build_synthetic_batch(dataset) + + +class TestFeatureSourceFor: + def test_a_cell_line_featurizer_gets_a_cell_line_source(self, dataset): + assert isinstance(feature_source_for(GoodFeaturizer, dataset), CellLineFeatureSource) + + def test_a_drug_featurizer_gets_a_drug_source(self, dataset): + assert isinstance(feature_source_for(GoodDrugFeaturizer, dataset), DrugFeatureSource) + + def test_the_source_exposes_the_matching_identifiers(self, dataset): + source = feature_source_for(GoodDrugFeaturizer, dataset) + + assert len(source.identifiers) == len(dataset.drug_ids) + + +class TestFeaturizerChecksAcceptConformingComponents: + @pytest.mark.parametrize("check", FEATURIZER_CHECKS) + @pytest.mark.parametrize("cls", [GoodFeaturizer, GoodDrugFeaturizer]) + def test_a_conforming_featurizer_passes_every_check(self, check, cls, dataset): + check(cls, dataset) + + def test_the_checks_default_to_the_synthetic_dataset(self): + """A plugin with ``entity_id_only`` featurizers needs no dataset of its own.""" + check_featurizer_fit_transform(GoodFeaturizer) + + def test_constructor_kwargs_are_forwarded(self, dataset): + check_featurizer_fit_transform(GoodFeaturizer, dataset, n_features=7) + + def test_instantiation_returns_the_instance(self): + assert isinstance(check_featurizer_instantiates(GoodFeaturizer), GoodFeaturizer) + + +class TestFeaturizerChecksRejectDefects: + def test_a_non_default_constructible_featurizer_is_rejected(self): + with pytest.raises(ConformanceError, match="could not be constructed"): + check_featurizer_instantiates(RequiresAnArgumentFeaturizer) + + def test_a_non_featurizer_class_is_rejected(self): + with pytest.raises(ConformanceError, match="not a Featurizer"): + check_featurizer_instantiates(dict) # type: ignore[arg-type] + + def test_a_wrong_output_dim_is_rejected(self, dataset): + with pytest.raises(ConformanceError, match="output_dim"): + check_featurizer_fit_transform(MisreportsOutputDimFeaturizer, dataset) + + def test_a_misaligned_block_is_rejected(self, dataset): + with pytest.raises(ConformanceError, match="rows for"): + check_featurizer_fit_transform(MisalignedBlockFeaturizer, dataset) + + def test_producing_no_blocks_is_rejected(self, dataset): + with pytest.raises(ConformanceError, match="returned no blocks"): + check_featurizer_fit_transform(EmptyBlocksFeaturizer, dataset) + + def test_an_incomplete_get_state_is_rejected(self, dataset): + with pytest.raises(ConformanceError, match="different features"): + check_featurizer_state_round_trip(ForgetfulStateFeaturizer, dataset) + + +class TestPredictorChecksAcceptConformingComponents: + @pytest.mark.parametrize("check", PREDICTOR_CHECKS) + def test_a_conforming_predictor_passes_every_check(self, check, batch): + check(GoodPredictor, batch) + + def test_the_checks_default_to_the_synthetic_batch(self): + check_predictor_fit_predict(GoodPredictor) + + def test_instantiation_returns_the_instance(self): + assert isinstance(check_predictor_instantiates(GoodPredictor), GoodPredictor) + + +class TestPredictorChecksRejectDefects: + def test_a_non_default_constructible_predictor_is_rejected(self): + with pytest.raises(ConformanceError, match="could not be constructed"): + check_predictor_instantiates(RequiresAnArgumentPredictor) + + def test_a_non_predictor_class_is_rejected(self): + with pytest.raises(ConformanceError, match="not a Predictor"): + check_predictor_instantiates(dict) # type: ignore[arg-type] + + def test_a_predictor_that_never_trains_is_rejected(self, batch): + with pytest.raises(ConformanceError, match="non-finite"): + check_predictor_fit_predict(NeverFitsPredictor, batch) + + def test_an_incomplete_get_state_is_rejected(self, batch): + with pytest.raises(ConformanceError, match="predicted differently"): + check_predictor_state_round_trip(ForgetfulStatePredictor, batch) + + def test_a_predictor_that_never_trains_fails_the_round_trip_early(self, batch): + """A predictor that cannot predict finitely cannot be compared at all.""" + with pytest.raises(ConformanceError, match="before the round trip"): + check_predictor_state_round_trip(NeverFitsPredictor, batch) + + +class TestChecksCoverEveryPublicCheck: + """The tuples exist so a plugin's suite cannot silently miss a new check.""" + + def test_featurizer_checks_names_every_featurizer_check(self): + assert set(FEATURIZER_CHECKS) == { + check_featurizer_instantiates, + check_featurizer_fit_transform, + check_featurizer_state_round_trip, + } + + def test_predictor_checks_names_every_predictor_check(self): + assert set(PREDICTOR_CHECKS) == { + check_predictor_instantiates, + check_predictor_fit_predict, + check_predictor_state_round_trip, + } + + @pytest.mark.parametrize("check", FEATURIZER_CHECKS + PREDICTOR_CHECKS) + def test_every_check_takes_the_same_two_positional_arguments(self, check): + """What makes ``parametrize("check", FEATURIZER_CHECKS)`` work at all.""" + import inspect + + positional = [ + name + for name, parameter in inspect.signature(check).parameters.items() + if parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + ] + + assert len(positional) == 2 + + +class TestChecksAgainstShippedComponents: + """The shipped components are the checks' own regression test. + + A check that rejects ``identity`` or ``ridge`` is wrong about the contract + rather than right about the component. + """ + + @pytest.mark.parametrize("check", FEATURIZER_CHECKS) + @pytest.mark.parametrize("name", ["identity", "tissue", "constant"]) + def test_builtin_cell_line_featurizers_conform(self, check, name, dataset): + from drevalpy.registry import cell_line_featurizer + + check(cell_line_featurizer.get(name), dataset) + + @pytest.mark.parametrize("check", PREDICTOR_CHECKS) + @pytest.mark.parametrize("name", ["ridge", "lasso", "elasticNet", "knn"]) + def test_builtin_predictors_conform(self, check, name, batch): + from drevalpy.registry import predictor + + check(predictor.get(name), batch) diff --git a/tests/testing/test_init.py b/tests/testing/test_init.py new file mode 100644 index 000000000..2d82cd26c --- /dev/null +++ b/tests/testing/test_init.py @@ -0,0 +1,121 @@ +"""Tests for the :mod:`drevalpy.testing` package surface. + +``drevalpy.testing`` is shipped in the wheel so third-party plugins can import +it, which makes its ``__all__`` a compatibility promise in the same way +:mod:`drevalpy.plugin`'s is. The re-exports are pinned here accordingly. +""" + +from __future__ import annotations + +import importlib +import shutil + +import pytest + +from drevalpy import testing +from tests._trusted_subprocess import run_trusted_python + +#: ``alias -> (submodule, attribute)`` for every re-export. +EXPECTED_ORIGINS: dict[str, tuple[str, str]] = { + "ENTRY_POINT_GROUP": ("drevalpy.testing.plugins", "ENTRY_POINT_GROUP"), + "FEATURIZER_CHECKS": ("drevalpy.testing.conformance", "FEATURIZER_CHECKS"), + "PREDICTOR_CHECKS": ("drevalpy.testing.conformance", "PREDICTOR_CHECKS"), + "ConformanceError": ("drevalpy.testing.conformance", "ConformanceError"), + "PluginCheckError": ("drevalpy.testing.plugins", "PluginCheckError"), + "PluginReport": ("drevalpy.testing.plugins", "PluginReport"), + "build_synthetic_batch": ("drevalpy.testing.batch", "build_synthetic_batch"), + "build_synthetic_dataset": ("drevalpy.testing.synthetic", "build_synthetic_dataset"), + "check_featurizer_fit_transform": ("drevalpy.testing.conformance", "check_featurizer_fit_transform"), + "check_featurizer_instantiates": ("drevalpy.testing.conformance", "check_featurizer_instantiates"), + "check_featurizer_state_round_trip": ("drevalpy.testing.conformance", "check_featurizer_state_round_trip"), + "check_plugin": ("drevalpy.testing.plugins", "check_plugin"), + "check_predictor_fit_predict": ("drevalpy.testing.conformance", "check_predictor_fit_predict"), + "check_predictor_instantiates": ("drevalpy.testing.conformance", "check_predictor_instantiates"), + "check_predictor_state_round_trip": ("drevalpy.testing.conformance", "check_predictor_state_round_trip"), + "feature_source_for": ("drevalpy.testing.conformance", "feature_source_for"), + "observed_pairs": ("drevalpy.testing.batch", "observed_pairs"), +} + + +class TestPublicSurface: + @pytest.mark.parametrize("name", sorted(testing.__all__)) + def test_every_exported_name_resolves(self, name): + assert hasattr(testing, name) + + def test_all_is_sorted_and_unique(self): + assert list(testing.__all__) == sorted(set(testing.__all__)) + + def test_every_export_has_a_recorded_origin(self): + assert sorted(EXPECTED_ORIGINS) == sorted(testing.__all__) + + @pytest.mark.parametrize(("alias", "origin"), sorted(EXPECTED_ORIGINS.items())) + def test_alias_is_the_same_object(self, alias, origin): + module_name, attribute = origin + module = importlib.import_module(module_name) + + assert getattr(testing, alias) is getattr(module, attribute) + + +class TestItShipsInTheWheel: + """The whole point of ``drevalpy.testing`` over drevalpy's own ``tests/``. + + A plugin author cannot import an unshipped module, which is why every + consumer so far had to hand-roll a synthetic dataset. + + Extended tier: ``test_it_is_importable_without_pytest`` spawns an interpreter, + measured at 0.67s, and that is the *only* cost this marker saves. Its sibling is + free: the ``uv build --wheel`` behind ``built_wheel_contents`` is already paid by + ``tests/plugin/test_init.py::TestPyTypedMarker``, which is in the fast tier, so + the build happens on every commit either way - warm or on a cold CI cache. The + marker stays at class level because both facts are about the *built artifact* + rather than the code under test, and the fast tier keeps equivalent wheel + assertions in ``tests/plugin/test_init.py``. + """ + + pytestmark = pytest.mark.slow + + @pytest.mark.skipif(shutil.which("uv") is None, reason="needs uv to build a wheel") + def test_every_submodule_is_in_the_built_wheel(self, built_wheel_contents): + """Asserted against the session-shared wheel built in ``tests/conftest.py``.""" + expected = { + "drevalpy/testing/__init__.py", + "drevalpy/testing/batch.py", + "drevalpy/testing/conformance.py", + "drevalpy/testing/plugins.py", + "drevalpy/testing/synthetic.py", + } + assert expected <= built_wheel_contents + + def test_it_is_importable_without_pytest(self): + """A plugin may run the checks from a plain script, so nothing here needs pytest.""" + script = ( + "import sys\n" + "sys.modules['pytest'] = None\n" + "import drevalpy.testing as t\n" + "t.check_featurizer_instantiates\n" + "print('ok')\n" + ) + + result = run_trusted_python(script) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip().endswith("ok") + + +class TestEndToEndUsage: + """The kit's own advertised workflow, in the order a plugin author meets it.""" + + def test_a_dataset_and_batch_compose_into_a_trained_predictor(self): + from drevalpy.registry import predictor + + dataset = testing.build_synthetic_dataset() + batch = testing.build_synthetic_batch(dataset) + + for check in testing.PREDICTOR_CHECKS: + check(predictor.get("ridge"), batch) + + def test_a_featurizer_can_be_checked_with_no_arguments_at_all(self): + from drevalpy.registry import cell_line_featurizer + + for check in testing.FEATURIZER_CHECKS: + check(cell_line_featurizer.get("identity")) diff --git a/tests/testing/test_plugins.py b/tests/testing/test_plugins.py new file mode 100644 index 000000000..ca1f26747 --- /dev/null +++ b/tests/testing/test_plugins.py @@ -0,0 +1,247 @@ +"""Tests for :mod:`drevalpy.testing.plugins`. + +``check_plugin`` inspects installed distribution metadata, and no plugin is +installed in drevalpy's own environment. The entry-point lookup is therefore +driven through a stub ``EntryPoint`` and a patched +``importlib.metadata.entry_points``, which is the same surface the real function +reads - the substitution is at the boundary, not inside the code under test. +""" + +from __future__ import annotations + +import importlib.metadata +import itertools +from typing import ClassVar + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.components.featurizers.cell_line.base import CellLineFeaturizer +from drevalpy.testing import plugins as plugins_module +from drevalpy.testing.plugins import ( + ENTRY_POINT_GROUP, + PluginCheckError, + PluginReport, + check_plugin, +) +from drevalpy.types.data.batch.feature_block import BlockSpec, numeric_feature_block + +PLUGIN_NAME = "fake_plugin" + +_REGISTRY_NAME_COUNTER = itertools.count() + + +@pytest.fixture +def fake_plugin_package(monkeypatch): + """Install a throwaway package holding one registered featurizer. + + The package is a real ``sys.modules`` entry rather than a file on disk: the + entry point only needs an importable target, and ``check_plugin`` reads + ``__module__`` to attribute components, so the root package name is all that + actually has to be real. + + The registry name is unique per test because registration raises on a + duplicate, and the entry is removed afterwards so the registry counts the + policy tests assert stay intact. + """ + import sys + import types + + from drevalpy.registry import cell_line_featurizer + from drevalpy.registry.cell_line_featurizer._registry import cell_line_featurizer_registry + + registry_name = f"fakePluginFeaturizer{next(_REGISTRY_NAME_COUNTER)}" + package = types.ModuleType(PLUGIN_NAME) + components = types.ModuleType(f"{PLUGIN_NAME}.components") + monkeypatch.setitem(sys.modules, PLUGIN_NAME, package) + monkeypatch.setitem(sys.modules, f"{PLUGIN_NAME}.components", components) + + @cell_line_featurizer.register( + registry_name, + description="Registered by the fake plugin package.", + contract=FeatureFormat.NUMERIC_MATRIX, + tags=("fake",), + ) + class FakeFeaturizer(CellLineFeaturizer): + """Fake featurizer contributed by the fake plugin.""" + + entity_id_only: ClassVar[bool] = True + output_block_specs: ClassVar[tuple[BlockSpec, ...]] = (BlockSpec("fake", FeatureFormat.NUMERIC_MATRIX),) + + def _fit(self, source, **kwargs): + return self + + def _transform_blocks(self, source, entity_ids): + return {"fake": numeric_feature_block(np.zeros((len(entity_ids), 1), dtype=np.float32))} + + @property + def output_dim(self) -> int: + return 1 + + FakeFeaturizer.__module__ = f"{PLUGIN_NAME}.components" + components.FakeFeaturizer = FakeFeaturizer + try: + yield registry_name + finally: + cell_line_featurizer_registry._store.pop(registry_name, None) + + +def _stub_entry_points(monkeypatch, *entry_points: importlib.metadata.EntryPoint) -> None: + """Make ``entry_points(group=...)`` return exactly *entry_points*.""" + + def fake_entry_points(*, group: str): + return list(entry_points) if group == ENTRY_POINT_GROUP else [] + + monkeypatch.setattr(importlib.metadata, "entry_points", fake_entry_points) + + +def _entry_point(value: str, name: str = PLUGIN_NAME) -> importlib.metadata.EntryPoint: + return importlib.metadata.EntryPoint(name=name, value=value, group=ENTRY_POINT_GROUP) + + +class TestUndeclaredEntryPoint: + def test_a_missing_entry_point_is_reported(self, monkeypatch): + _stub_entry_points(monkeypatch) + + with pytest.raises(PluginCheckError, match="No drevalpy.plugins entry point"): + check_plugin(PLUGIN_NAME) + + def test_the_message_lists_what_is_declared(self, monkeypatch): + _stub_entry_points(monkeypatch, _entry_point("other.components", name="other")) + + with pytest.raises(PluginCheckError, match=r"Declared: \['other'\]"): + check_plugin(PLUGIN_NAME) + + def test_the_message_says_none_when_nothing_is_declared(self, monkeypatch): + _stub_entry_points(monkeypatch) + + with pytest.raises(PluginCheckError, match="Declared: none"): + check_plugin(PLUGIN_NAME) + + +class TestImportFailure: + def test_an_unimportable_target_is_reported(self, monkeypatch): + _stub_entry_points(monkeypatch, _entry_point("no_such_package_at_all.components")) + + with pytest.raises(PluginCheckError, match="failed to import"): + check_plugin(PLUGIN_NAME) + + def test_a_failure_recorded_by_discovery_is_surfaced(self, monkeypatch, fake_plugin_package): + """The loader's recorded traceback beats a re-import, which may now succeed.""" + _stub_entry_points(monkeypatch, _entry_point(f"{PLUGIN_NAME}.components")) + monkeypatch.setattr( + plugins_module, + "get_failed_plugins", + lambda: {PLUGIN_NAME: "Traceback (most recent call last): boom"}, + ) + + with pytest.raises(PluginCheckError, match="failed to load during discovery"): + check_plugin(PLUGIN_NAME) + + def test_a_clean_load_is_accepted(self, monkeypatch, fake_plugin_package): + """Nothing recorded against the plugin means the check proceeds.""" + _stub_entry_points(monkeypatch, _entry_point(f"{PLUGIN_NAME}.components")) + monkeypatch.setattr(plugins_module, "get_failed_plugins", dict) + + assert check_plugin(PLUGIN_NAME).name == PLUGIN_NAME + + +class TestNoComponentsRegistered: + def test_a_plugin_registering_nothing_is_reported(self, monkeypatch): + import sys + import types + + monkeypatch.setitem(sys.modules, "empty_fake_plugin", types.ModuleType("empty_fake_plugin")) + _stub_entry_points(monkeypatch, _entry_point("empty_fake_plugin", name="empty_fake_plugin")) + + with pytest.raises(PluginCheckError, match="registered nothing"): + check_plugin("empty_fake_plugin") + + def test_the_message_names_the_registries_it_checked(self, monkeypatch): + import sys + import types + + monkeypatch.setitem(sys.modules, "empty_fake_plugin", types.ModuleType("empty_fake_plugin")) + _stub_entry_points(monkeypatch, _entry_point("empty_fake_plugin", name="empty_fake_plugin")) + + with pytest.raises(PluginCheckError, match="cell_line_featurizer"): + check_plugin("empty_fake_plugin") + + +class TestSuccessfulCheck: + @pytest.fixture + def report(self, monkeypatch, fake_plugin_package) -> PluginReport: + _stub_entry_points(monkeypatch, _entry_point(f"{PLUGIN_NAME}.components")) + return check_plugin(PLUGIN_NAME) + + def test_it_returns_a_report(self, report): + assert isinstance(report, PluginReport) + + def test_the_report_names_the_plugin(self, report): + assert report.name == PLUGIN_NAME + + def test_the_report_records_the_declared_value(self, report): + assert report.value == f"{PLUGIN_NAME}.components" + + def test_the_report_records_the_resolved_module(self, report): + assert report.module == f"{PLUGIN_NAME}.components" + + def test_the_contributed_component_is_attributed_to_its_registry(self, report, fake_plugin_package): + assert report.components["cell_line_featurizer"] == (fake_plugin_package,) + + def test_registries_the_plugin_did_not_touch_are_omitted(self, report): + assert "predictor" not in report.components + assert "splitter" not in report.components + + def test_component_names_flattens_every_registry(self, report, fake_plugin_package): + assert report.component_names == (fake_plugin_package,) + + def test_builtin_components_are_not_attributed_to_the_plugin(self, report): + """Attribution is by root package, so ``identity`` must not appear.""" + assert "identity" not in report.component_names + + def test_the_report_is_immutable(self, report): + with pytest.raises(AttributeError): + report.name = "other" # type: ignore[misc] + + +class TestErrorType: + def test_plugin_check_error_is_an_assertion_error(self): + """So a failure reads as a test failure, not an error, inside a test.""" + assert issubclass(PluginCheckError, AssertionError) + + +class TestAttributionAcrossRegistries: + """Attribution reads ``__module__`` off whatever ``get`` returns. + + That is a class for four registries but a *function* for the splitter + registry, and one wrapped in validation at that - so the wrapper must + preserve ``__module__`` or a plugin's splitter would go unattributed. + """ + + @pytest.mark.parametrize( + ("registry_name", "component_name", "expected_root"), + [ + ("cell_line_featurizer", "identity", "drevalpy"), + ("drug_featurizer", "fingerprints", "drevalpy"), + ("predictor", "ridge", "drevalpy"), + ("splitter", "LPO", "drevalpy"), + ("visualization", "critical_difference", "drevalpy"), + ], + ) + def test_a_builtin_is_attributed_to_the_drevalpy_package(self, registry_name, component_name, expected_root): + module = plugins_module._REGISTRIES[registry_name] + + resolved = module.get(component_name) + + assert resolved.__module__.split(".")[0] == expected_root + + def test_all_five_registries_are_inspected(self): + assert set(plugins_module._REGISTRIES) == { + "cell_line_featurizer", + "drug_featurizer", + "predictor", + "splitter", + "visualization", + } diff --git a/tests/testing/test_synthetic.py b/tests/testing/test_synthetic.py new file mode 100644 index 000000000..1fb595468 --- /dev/null +++ b/tests/testing/test_synthetic.py @@ -0,0 +1,201 @@ +"""Tests for :mod:`drevalpy.testing.synthetic`.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.data.utils import CELL_LINE_IDENTIFIER, TISSUE_IDENTIFIER +from drevalpy.testing.synthetic import ( + DATASET_NAME, + MEASURE, + N_CELL_LINES, + N_DRUGS, + N_FEATURES, + _punch_holes, + build_synthetic_dataset, +) +from drevalpy.types.data.dataset import Dataset + + +@pytest.fixture(scope="module") +def dataset() -> Dataset: + return build_synthetic_dataset() + + +class TestShapeAndNaming: + def test_it_returns_a_dataset(self, dataset): + assert isinstance(dataset, Dataset) + + def test_the_default_name_is_recorded(self, dataset): + assert dataset.name == DATASET_NAME + + def test_the_name_is_configurable(self): + assert build_synthetic_dataset(name="MYDATA").name == "MYDATA" + + def test_the_response_matrix_has_the_default_shape(self, dataset): + assert dataset.response_matrix.shape == (N_CELL_LINES, N_DRUGS) + + def test_the_shape_is_configurable(self): + small = build_synthetic_dataset(n_cell_lines=6, n_drugs=3, n_tissues=3) + + assert small.response_matrix.shape == (6, 3) + + def test_only_the_response_modality_is_present_by_default(self, dataset): + assert list(dataset.mdata.mod) == ["response"] + + def test_identifiers_are_unique(self, dataset): + assert len(set(dataset.cell_line_ids)) == N_CELL_LINES + assert len(set(dataset.drug_ids)) == N_DRUGS + + +class TestMetadata: + def test_the_cell_line_name_column_is_populated(self, dataset): + assert dataset.response.obs[CELL_LINE_IDENTIFIER].notna().all() + + def test_tissue_labels_are_resolvable_through_the_dataset(self, dataset): + tissues = dataset.get_tissue(np.asarray(dataset.cell_line_ids)) + + assert len(tissues) == N_CELL_LINES + assert all(isinstance(tissue, str) for tissue in tissues) + + def test_the_requested_number_of_tissues_is_produced(self): + small = build_synthetic_dataset(n_tissues=3) + + assert len(set(small.response.obs[TISSUE_IDENTIFIER])) == 3 + + def test_the_published_measure_is_stored_as_a_layer(self, dataset): + layer = dataset.get_response_layer(MEASURE) + + np.testing.assert_array_equal(np.isnan(layer), np.isnan(dataset.response_matrix)) + + def test_drug_names_are_recorded(self, dataset): + assert dataset.response.var["drug_name"].notna().all() + + +class TestMissingResponses: + def test_some_pairs_are_unmeasured_by_default(self, dataset): + assert np.isnan(dataset.response_matrix).any() + + def test_every_cell_line_keeps_a_measurement(self, dataset): + assert (~np.isnan(dataset.response_matrix)).any(axis=1).all() + + def test_every_drug_keeps_a_measurement(self, dataset): + assert (~np.isnan(dataset.response_matrix)).any(axis=0).all() + + def test_zero_fraction_leaves_a_complete_matrix(self): + complete = build_synthetic_dataset(missing_fraction=0.0) + + assert not np.isnan(complete.response_matrix).any() + + def test_a_larger_fraction_removes_more_pairs(self): + sparse = build_synthetic_dataset(missing_fraction=0.4) + dense = build_synthetic_dataset(missing_fraction=0.05) + + assert np.isnan(sparse.response_matrix).sum() > np.isnan(dense.response_matrix).sum() + + def test_an_extreme_fraction_still_honours_the_row_guarantee(self): + """Clamped rather than obeyed: an empty row breaks the LCO splitters.""" + extreme = build_synthetic_dataset(missing_fraction=1.0) + + assert (~np.isnan(extreme.response_matrix)).any(axis=1).all() + assert (~np.isnan(extreme.response_matrix)).any(axis=0).all() + + +class TestPunchHoles: + def test_a_degenerate_matrix_is_left_alone(self): + matrix = np.ones((1, 4), dtype=np.float32) + + _punch_holes(matrix, np.random.default_rng(0), 0.5) + + assert not np.isnan(matrix).any() + + def test_it_modifies_in_place(self): + matrix = np.ones((6, 6), dtype=np.float32) + + _punch_holes(matrix, np.random.default_rng(0), 0.2) + + assert np.isnan(matrix).any() + + +class TestOmics: + def test_a_sequence_adds_full_coverage_modalities(self): + with_omics = build_synthetic_dataset(omics=["gene_expression", "proteomics"]) + + assert set(with_omics.mdata.mod) == {"response", "gene_expression", "proteomics"} + + def test_features_are_retrievable_through_the_public_accessor(self): + with_omics = build_synthetic_dataset(omics=["gene_expression"]) + + matrix = with_omics.get_cell_line_features("gene_expression", np.asarray(with_omics.cell_line_ids)) + + assert matrix.shape == (N_CELL_LINES, N_FEATURES) + + def test_feature_names_are_exposed(self): + with_omics = build_synthetic_dataset(omics=["gene_expression"], feature_names=["A", "B"]) + + assert with_omics.get_cell_line_feature_names("gene_expression") == ("A", "B") + + def test_the_feature_width_is_configurable(self): + with_omics = build_synthetic_dataset(omics=["gene_expression"], n_features=3) + + assert with_omics.mdata.mod["gene_expression"].shape[1] == 3 + + def test_a_mapping_sets_per_modality_coverage(self): + """Partial coverage is what makes the NaN-filtering path fire.""" + partial = build_synthetic_dataset(omics={"gene_expression": 10}) + + assert partial.mdata.mod["gene_expression"].shape[0] == 10 + + def test_uncovered_cell_lines_come_back_as_nan(self): + partial = build_synthetic_dataset(omics={"gene_expression": 10}) + + matrix = partial.get_cell_line_features("gene_expression", np.asarray(partial.cell_line_ids)) + + assert np.isnan(matrix[10:]).all() + assert not np.isnan(matrix[:10]).any() + + def test_the_gistic_alias_resolves_to_the_stored_modality(self): + """The public name is suffixed; the published files are not.""" + with_cnv = build_synthetic_dataset(omics=["copy_number_variation_gistic"]) + + assert "copy_number_variation" in with_cnv.mdata.mod + assert with_cnv.get_cell_line_features("copy_number_variation_gistic", np.asarray([])).size == 0 + + +class TestDeterminism: + def test_the_same_seed_gives_the_same_matrix(self): + first = build_synthetic_dataset() + second = build_synthetic_dataset() + + np.testing.assert_array_equal(first.response_matrix, second.response_matrix) + + def test_a_different_seed_gives_a_different_matrix(self): + first = build_synthetic_dataset(seed=1) + second = build_synthetic_dataset(seed=2) + + assert not np.array_equal(first.response_matrix, second.response_matrix) + + +class TestSplitterCompatibility: + """The builder exists so plugin CI can split and train; prove it can.""" + + @pytest.mark.parametrize("mode", ["LPO", "LCO", "LDO", "LTO"]) + def test_every_builtin_splitter_accepts_it(self, dataset, mode): + from drevalpy.registry import splitter + + folds = splitter.get(mode)(dataset, n_splits=2, validation_ratio=0.1) + + assert len(folds) == 2 + for fold in folds: + assert fold.train.any() + assert fold.test.any() + + def test_folds_never_select_unmeasured_pairs(self, dataset): + from drevalpy.registry import splitter + + observed = ~np.isnan(dataset.response_matrix) + + for fold in splitter.get("LPO")(dataset, n_splits=2, validation_ratio=0.1): + assert not (fold.train.mask & ~observed).any() + assert not (fold.test.mask & ~observed).any() diff --git a/tests/tools/test_coverage_gate.py b/tests/tools/test_coverage_gate.py new file mode 100644 index 000000000..6eb257480 --- /dev/null +++ b/tests/tools/test_coverage_gate.py @@ -0,0 +1,152 @@ +"""Tests for the per-module coverage floor gate in ``tools/coverage_gate.py``.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from upath import UPath + +from tools.coverage_gate import main + + +def _write_coverage_json(path: UPath, percentages: dict[str, float]) -> UPath: + """Write a minimal ``coverage.json`` holding the given per-file percentages.""" + report: dict[str, Any] = { + "files": {path_: {"summary": {"percent_covered": percent}} for path_, percent in percentages.items()} + } + target = path / "coverage.json" + target.write_text(json.dumps(report)) + return target + + +def _write_pyproject(path: UPath, min_file_coverage: int, exemptions: dict[str, int]) -> UPath: + """Write a minimal ``pyproject.toml`` holding a coverage-gate config table.""" + lines = ["[tool.drevalpy.coverage_gate]", f"min_file_coverage = {min_file_coverage}"] + if exemptions: + lines.append("[tool.drevalpy.coverage_gate.exemptions]") + lines.extend(f'"{key}" = {value}' for key, value in exemptions.items()) + target = path / "pyproject.toml" + target.write_text("\n".join(lines)) + return target + + +def _run(coverage_json: UPath, pyproject: UPath) -> int: + return main(["--coverage-json", str(coverage_json), "--pyproject", str(pyproject)]) + + +def test_passes_when_every_module_meets_the_floor(tmp_path: UPath) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/a.py": 80.0, "drevalpy/b.py": 50.0}) + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={}) + + assert _run(coverage_json, pyproject) == 0 + + +def test_fails_when_a_module_is_below_the_floor(tmp_path: UPath) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/a.py": 80.0, "drevalpy/b.py": 49.9}) + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={}) + + assert _run(coverage_json, pyproject) == 1 + + +def test_names_the_offending_module_in_the_report(tmp_path: UPath, capsys: pytest.CaptureFixture[str]) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/a.py": 80.0, "drevalpy/b.py": 10.0}) + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={}) + + _run(coverage_json, pyproject) + + out = capsys.readouterr().out + assert "drevalpy/b.py" in out + assert "drevalpy/a.py" not in out + + +def test_exempted_module_below_global_floor_but_above_own_floor_passes(tmp_path: UPath) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/low.py": 20.0}) + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={"drevalpy/low.py": 20}) + + assert _run(coverage_json, pyproject) == 0 + + +def test_exempted_module_below_its_own_floor_fails(tmp_path: UPath) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/low.py": 15.0}) + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={"drevalpy/low.py": 20}) + + assert _run(coverage_json, pyproject) == 1 + + +def test_reports_exemptions_that_can_be_ratcheted_down(tmp_path: UPath, capsys: pytest.CaptureFixture[str]) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/improved.py": 70.0}) + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={"drevalpy/improved.py": 20}) + + exit_code = _run(coverage_json, pyproject) + + assert exit_code == 0 + assert "can be lowered or deleted" in capsys.readouterr().out + + +def test_reports_exemptions_for_modules_absent_from_the_report( + tmp_path: UPath, capsys: pytest.CaptureFixture[str] +) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/a.py": 80.0}) + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={"drevalpy/gone.py": 20}) + + exit_code = _run(coverage_json, pyproject) + + assert exit_code == 0 + assert "drevalpy/gone.py" in capsys.readouterr().out + + +def test_normalizes_windows_separators_in_report_paths(tmp_path: UPath) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy\\low.py": 20.0}) + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={"drevalpy/low.py": 20}) + + assert _run(coverage_json, pyproject) == 0 + + +def test_exits_with_a_clear_message_when_the_coverage_json_is_missing( + tmp_path: UPath, capsys: pytest.CaptureFixture[str] +) -> None: + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={}) + missing = tmp_path / "coverage.json" + + with pytest.raises(SystemExit) as excinfo: + _run(missing, pyproject) + + assert excinfo.value.code == 1 + assert "not found" in capsys.readouterr().err + + +def test_exits_when_the_pyproject_is_missing(tmp_path: UPath, capsys: pytest.CaptureFixture[str]) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/a.py": 80.0}) + missing = tmp_path / "absent.toml" + + with pytest.raises(SystemExit) as excinfo: + _run(coverage_json, missing) + + assert excinfo.value.code == 1 + assert "not found" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("percent", "expected_exit_code"), + [ + pytest.param(50.0, 0, id="exactly-at-floor-passes"), + pytest.param(49.999, 1, id="just-below-floor-fails"), + pytest.param(0.0, 1, id="never-imported-module-fails"), + pytest.param(100.0, 0, id="fully-covered-passes"), + ], +) +def test_floor_is_inclusive(tmp_path: UPath, percent: float, expected_exit_code: int) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/a.py": percent}) + pyproject = _write_pyproject(tmp_path, min_file_coverage=50, exemptions={}) + + assert _run(coverage_json, pyproject) == expected_exit_code + + +def test_defaults_the_floor_when_the_config_table_is_absent(tmp_path: UPath) -> None: + coverage_json = _write_coverage_json(tmp_path, {"drevalpy/a.py": 49.0}) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "x"\n') + + assert _run(coverage_json, pyproject) == 1 diff --git a/tests/tools/test_size_gate.py b/tests/tools/test_size_gate.py new file mode 100644 index 000000000..d61a268fa --- /dev/null +++ b/tests/tools/test_size_gate.py @@ -0,0 +1,204 @@ +"""Tests for the per-module statement ceiling gate in ``tools/size_gate.py``.""" + +from __future__ import annotations + +import pytest +from upath import UPath + +from tools.size_gate import count_statements, main + + +def _write_package(path: UPath, modules: dict[str, str]) -> UPath: + """Write a package tree whose modules hold the given source text.""" + package = path / "drevalpy" + for relative, source in modules.items(): + target = package / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(source) + return package + + +def _statements(count: int) -> str: + """Build a module source with exactly ``count`` top-level statements.""" + return "\n".join(f"x{index} = {index}" for index in range(count)) + "\n" + + +def _write_pyproject(path: UPath, max_module_statements: int, exemptions: dict[str, int]) -> UPath: + """Write a minimal ``pyproject.toml`` holding a size-gate config table. + + Exemption keys are written package-relative, the form the gate reports. + """ + lines = ["[tool.drevalpy.size_gate]", f"max_module_statements = {max_module_statements}"] + if exemptions: + lines.append("[tool.drevalpy.size_gate.exemptions]") + lines.extend(f'"drevalpy/{key}" = {value}' for key, value in exemptions.items()) + target = path / "pyproject.toml" + target.write_text("\n".join(lines)) + return target + + +def _run(package: UPath, pyproject: UPath) -> int: + return main(["--package", str(package), "--pyproject", str(pyproject)]) + + +def test_passes_when_every_module_meets_the_ceiling(tmp_path: UPath) -> None: + package = _write_package(tmp_path, {"a.py": _statements(5), "b.py": _statements(10)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={}) + + assert _run(package, pyproject) == 0 + + +def test_fails_when_a_module_is_above_the_ceiling(tmp_path: UPath) -> None: + package = _write_package(tmp_path, {"a.py": _statements(5), "b.py": _statements(11)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={}) + + assert _run(package, pyproject) == 1 + + +def test_names_the_offending_module_in_the_report(tmp_path: UPath, capsys: pytest.CaptureFixture[str]) -> None: + package = _write_package(tmp_path, {"small.py": _statements(2), "big.py": _statements(50)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={}) + + _run(package, pyproject) + + out = capsys.readouterr().out + assert "big.py" in out + assert "small.py" not in out + + +def test_exempted_module_above_global_ceiling_but_within_its_own_passes(tmp_path: UPath) -> None: + package = _write_package(tmp_path, {"big.py": _statements(40)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={"big.py": 40}) + + assert _run(package, pyproject) == 0 + + +def test_exempted_module_above_its_own_ceiling_fails(tmp_path: UPath) -> None: + package = _write_package(tmp_path, {"big.py": _statements(41)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={"big.py": 40}) + + assert _run(package, pyproject) == 1 + + +def test_reports_a_shrunk_module_as_ratchetable(tmp_path: UPath, capsys: pytest.CaptureFixture[str]) -> None: + package = _write_package(tmp_path, {"shrunk.py": _statements(25)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={"shrunk.py": 40}) + + exit_code = _run(package, pyproject) + + assert exit_code == 0 + out = capsys.readouterr().out + assert "can be lowered or deleted" in out + assert "shrunk.py" in out + + +def test_reports_a_module_that_fell_under_the_global_ceiling_as_ratchetable( + tmp_path: UPath, capsys: pytest.CaptureFixture[str] +) -> None: + package = _write_package(tmp_path, {"split.py": _statements(8)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={"split.py": 12}) + + exit_code = _run(package, pyproject) + + assert exit_code == 0 + assert "can be lowered or deleted" in capsys.readouterr().out + + +def test_a_module_just_under_its_own_ceiling_is_not_yet_ratchetable( + tmp_path: UPath, capsys: pytest.CaptureFixture[str] +) -> None: + package = _write_package(tmp_path, {"big.py": _statements(39)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={"big.py": 40}) + + exit_code = _run(package, pyproject) + + assert exit_code == 0 + assert "can be lowered or deleted" not in capsys.readouterr().out + + +def test_reports_exemptions_for_modules_that_no_longer_exist( + tmp_path: UPath, capsys: pytest.CaptureFixture[str] +) -> None: + package = _write_package(tmp_path, {"a.py": _statements(2)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={"gone.py": 40}) + + exit_code = _run(package, pyproject) + + assert exit_code == 0 + assert "gone.py" in capsys.readouterr().out + + +def test_exemption_paths_are_package_relative_not_filesystem_absolute(tmp_path: UPath) -> None: + package = _write_package(tmp_path, {"nested/deep.py": _statements(40)}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={"nested/deep.py": 40}) + + assert _run(package, pyproject) == 0 + + +def test_exits_with_a_clear_message_when_the_package_is_missing( + tmp_path: UPath, capsys: pytest.CaptureFixture[str] +) -> None: + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={}) + + with pytest.raises(SystemExit) as excinfo: + _run(tmp_path / "absent", pyproject) + + assert excinfo.value.code == 1 + assert "not a directory" in capsys.readouterr().err + + +def test_exits_when_the_pyproject_is_missing(tmp_path: UPath, capsys: pytest.CaptureFixture[str]) -> None: + package = _write_package(tmp_path, {"a.py": _statements(2)}) + + with pytest.raises(SystemExit) as excinfo: + _run(package, tmp_path / "absent.toml") + + assert excinfo.value.code == 1 + assert "not found" in capsys.readouterr().err + + +def test_exits_when_a_module_does_not_parse(tmp_path: UPath, capsys: pytest.CaptureFixture[str]) -> None: + package = _write_package(tmp_path, {"broken.py": "def (\n"}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={}) + + with pytest.raises(SystemExit) as excinfo: + _run(package, pyproject) + + assert excinfo.value.code == 1 + assert "does not parse" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("count", "expected_exit_code"), + [ + pytest.param(10, 0, id="exactly-at-ceiling-passes"), + pytest.param(11, 1, id="one-over-ceiling-fails"), + pytest.param(0, 0, id="empty-module-passes"), + ], +) +def test_ceiling_is_inclusive(tmp_path: UPath, count: int, expected_exit_code: int) -> None: + package = _write_package(tmp_path, {"a.py": _statements(count) if count else ""}) + pyproject = _write_pyproject(tmp_path, max_module_statements=10, exemptions={}) + + assert _run(package, pyproject) == expected_exit_code + + +def test_defaults_the_ceiling_when_the_config_table_is_absent(tmp_path: UPath) -> None: + package = _write_package(tmp_path, {"a.py": _statements(200)}) + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "x"\n') + + assert _run(package, pyproject) == 1 + + +def test_counts_nested_statements_not_just_top_level() -> None: + source = "def f():\n if True:\n return 1\n return 2\n" + + assert count_statements(source) == 4 + + +def test_a_long_docstring_counts_as_one_statement() -> None: + one_line = '"""Short."""\nx = 1\n' + many_lines = '"""Long.\n\n' + "\n".join(f"line {index}" for index in range(30)) + '\n"""\nx = 1\n' + + assert count_statements(one_line) == count_statements(many_lines) == 2 diff --git a/tests/types/data/_helpers.py b/tests/types/data/_helpers.py new file mode 100644 index 000000000..3f10255a7 --- /dev/null +++ b/tests/types/data/_helpers.py @@ -0,0 +1,107 @@ +"""Throwaway featurizer stand-ins for the ``Dataset`` precompute tests. + +``Dataset.precompute`` and ``Dataset._precompute_single`` only read class-level +attributes, a hyperparameter space and ``store``. Deriving from the real +:class:`~drevalpy.components.featurizers.base.Featurizer` ABC would additionally +require a contract and a registry entry, neither of which says anything about the +branches under test, so these stubs implement the duck type instead. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +#: Defaults describing an eligible cell-line featurizer with no hyperparameters. +_DEFAULT_ATTRS: dict[str, Any] = { + "storage_key": "stub", + "side": "cell_line", + "precompute": True, + "entity_id_only": False, + "requires_view": False, + "input_views": ("gene_expression",), + "source_views": None, +} + + +class _StubBase: + """Shared duck-typed surface every precompute stub needs.""" + + def __init__(self, **hyperparameters: Any) -> None: + """Record the hyperparameters the dataset constructed this variant with.""" + self.hyperparameters = hyperparameters + + @classmethod + def get_default_hyperparameters(cls) -> dict[str, Any]: + return {} + + @classmethod + def get_hyperparameter_space(cls) -> dict[str, Any]: + return {} + + +def _stub_class(name: str, namespace: dict[str, Any], overrides: dict[str, Any]) -> type: + """Create a fresh stub class so class-level state cannot leak between tests.""" + return type(name, (_StubBase,), {**_DEFAULT_ATTRS, **namespace, **overrides}) + + +def _recording_store(records: list[dict[str, Any]]) -> Any: + def store( + self: Any, + mdata: Any, + entity_ids: np.ndarray, + data: np.ndarray, + hyperparameters: dict[str, Any] | None = None, + ) -> None: + records.append({"n_entities": len(entity_ids), "shape": data.shape, "hyperparameters": hyperparameters}) + + return store + + +def stub_featurizer(records: list[dict[str, Any]], **overrides: Any) -> type: + """Build an independent featurizer class whose ``store`` appends to *records*. + + Args: + records: List that receives one dict per ``store`` call. + overrides: Class attributes to replace, e.g. ``side="drug"`` or + ``precompute=False``. ``compute`` may be set to a callable taking + ``(source, entity_ids)`` to control what ``_compute_from_source`` + returns or raises. + + Returns: + A class exposing ``_compute_from_source``, so ``precompute`` takes the + independent-featurizer shortcut. + """ + compute = overrides.pop("compute", None) + + def _compute_from_source(self: Any, source: Any, entity_ids: np.ndarray) -> np.ndarray: + if compute is not None: + return compute(source, entity_ids) + return np.zeros((len(entity_ids), 2), dtype=np.float32) + + namespace = {"_compute_from_source": _compute_from_source, "store": _recording_store(records)} + return _stub_class("StubIndependentFeaturizer", namespace, overrides) + + +def stub_fit_transform_featurizer(records: list[dict[str, Any]], **overrides: Any) -> type: + """Build a stub without ``_compute_from_source``, forcing the fit/transform path. + + Args: + records: List that receives ``{"call": "fit"}`` when ``fit`` runs and one + dict per ``store`` call. + overrides: Class attributes to replace, as in :func:`stub_featurizer`. + + Returns: + A class exposing ``fit``/``transform`` but no ``_compute_from_source``. + """ + + def fit(self: Any, source: Any, *, entity_ids: np.ndarray | None = None) -> Any: + records.append({"call": "fit"}) + return self + + def transform(self: Any, source: Any, entity_ids: np.ndarray) -> np.ndarray: + return np.ones((len(entity_ids), 3), dtype=np.float32) + + namespace = {"fit": fit, "transform": transform, "store": _recording_store(records)} + return _stub_class("StubFitTransformFeaturizer", namespace, overrides) diff --git a/tests/types/data/batch/test_feature_block.py b/tests/types/data/batch/test_feature_block.py new file mode 100644 index 000000000..c415f3367 --- /dev/null +++ b/tests/types/data/batch/test_feature_block.py @@ -0,0 +1,66 @@ +"""Tests for typed featurizer block payloads.""" + +from __future__ import annotations + +from types import MappingProxyType +from typing import Any, cast + +import numpy as np +import pytest + +from drevalpy.components.contracts.contracts import FeatureFormat +from drevalpy.types.data.batch.feature_block import ( + FeatureBlock, + graph_feature_block, + merge_feature_blocks, + metadata_feature_block, + numeric_feature_block, + ragged_feature_block, +) + + +def test_numeric_feature_block_stores_values_and_format() -> None: + values = np.array([[1.0, 2.0]], dtype=np.float64) + block = numeric_feature_block(values, feature_names=("a", "b")) + assert block.format is FeatureFormat.NUMERIC_MATRIX + assert block.feature_names == ("a", "b") + assert block.entity_aligned is True + np.testing.assert_array_equal(block.values, values) + + +def test_metadata_feature_block_is_not_entity_aligned() -> None: + block = metadata_feature_block(np.array(["d1", "d2"], dtype=str)) + assert block.entity_aligned is False + + +def test_feature_block_metadata_is_immutable() -> None: + block = numeric_feature_block(np.ones((1, 1)), metadata={"dim": 4}) + assert isinstance(block.metadata, MappingProxyType) + with pytest.raises(TypeError): + cast(Any, block.metadata)["dim"] = 8 + + +def test_graph_and_ragged_blocks_preserve_object_dtype() -> None: + graph_payload = object() + graph = graph_feature_block(np.array([graph_payload], dtype=object)) + ragged = ragged_feature_block(np.array([np.array([1, 2, 3])], dtype=object)) + assert graph.format is FeatureFormat.GRAPH + assert ragged.format is FeatureFormat.RAGGED_SEQUENCE + assert graph.values.dtype == object + assert ragged.values.dtype == object + + +def test_merge_feature_blocks_rejects_duplicate_names() -> None: + left = {"gene_expression": numeric_feature_block(np.ones((2, 1)))} + right = {"gene_expression": numeric_feature_block(np.zeros((2, 1)))} + with pytest.raises(ValueError, match="Duplicate featurizer block name 'gene_expression'"): + merge_feature_blocks(left, right) + + +def test_merge_feature_blocks_combines_distinct_names() -> None: + merged = merge_feature_blocks( + {"gene_expression": numeric_feature_block(np.ones((2, 1)))}, + {"fingerprints": numeric_feature_block(np.zeros((2, 3)))}, + ) + assert set(merged) == {"gene_expression", "fingerprints"} + assert all(isinstance(block, FeatureBlock) for block in merged.values()) diff --git a/tests/types/data/batch/test_model_input_batch.py b/tests/types/data/batch/test_model_input_batch.py new file mode 100644 index 000000000..c9399820d --- /dev/null +++ b/tests/types/data/batch/test_model_input_batch.py @@ -0,0 +1,181 @@ +"""Tests for ModelInputBatch construction and matrix views. + +The ``build_model_input_batch`` entry point is covered in +``test_model_input_build.py``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.types.data.batch.model_input_batch import ( + ModelInputBatch, + pair_cell_line_indices, + pair_drug_indices, +) +from drevalpy.types.data.batch.response_batch import ResponseBatch + + +def test_to_feature_matrix_combined_cell_line_and_drug() -> None: + batch = ModelInputBatch( + cell_line_ids=np.array(["cl1", "cl2"]), + drug_ids=np.array(["d1", "d2"]), + response=np.array([1.0, 2.0]), + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32), + drug_features=np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32), + cell_line_pair_idx=np.array([0, 1]), + drug_pair_idx=np.array([0, 1]), + ) + matrix = batch.to_feature_matrix() + assert matrix.shape == (2, 4) + np.testing.assert_allclose(matrix[0], np.array([0.1, 0.2, 1.0, 0.0], dtype=np.float32)) + np.testing.assert_allclose(matrix[1], np.array([0.3, 0.4, 0.0, 1.0], dtype=np.float32)) + + +def test_to_feature_matrix_cell_line_only() -> None: + batch = ModelInputBatch( + cell_line_ids=np.array(["cl1", "cl2"]), + drug_ids=np.array(["d1", "d2"]), + response=np.array([1.0, 2.0]), + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=None, + cell_line_features=np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32), + drug_features=None, + cell_line_pair_idx=np.array([0, 1]), + drug_pair_idx=None, + ) + matrix = batch.to_feature_matrix() + assert matrix.shape == (2, 2) + + +def test_to_feature_matrix_drug_only() -> None: + batch = ModelInputBatch( + cell_line_ids=np.array(["cl1", "cl2"]), + drug_ids=np.array(["d1", "d2"]), + response=np.array([1.0, 2.0]), + cell_line_entity_ids=np.array([]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=np.empty((0, 0), dtype=np.float32), + drug_features=np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32), + cell_line_pair_idx=np.array([0, 1]), + drug_pair_idx=np.array([0, 1]), + ) + matrix = batch.to_feature_matrix() + assert matrix.shape == (2, 2) + + +def test_to_feature_matrix_empty_baseline() -> None: + batch = ModelInputBatch( + cell_line_ids=np.array(["cl1", "cl2"]), + drug_ids=np.array(["d1", "d2"]), + response=np.array([1.0, 2.0]), + cell_line_entity_ids=np.array([]), + drug_entity_ids=None, + cell_line_features=np.empty((0, 0), dtype=np.float32), + drug_features=None, + cell_line_pair_idx=np.array([0, 0]), + drug_pair_idx=None, + ) + matrix = batch.to_feature_matrix() + assert matrix.shape == (2, 0) + + +def test_to_feature_matrix_builds_drug_indices_from_entity_maps() -> None: + batch = ModelInputBatch( + cell_line_ids=np.array(["cl1"]), + drug_ids=np.array(["d1"]), + response=np.array([1.0]), + cell_line_entity_ids=np.array(["cl1"]), + drug_entity_ids=np.array(["d1"]), + cell_line_features=np.array([[0.1]], dtype=np.float32), + drug_features=np.array([[1.0]], dtype=np.float32), + cell_line_pair_idx=np.array([0]), + drug_pair_idx=None, + ) + matrix = batch.to_feature_matrix() + assert matrix.shape == (1, 2) + np.testing.assert_allclose(matrix, np.array([[0.1, 1.0]], dtype=np.float32)) + + +def test_subset_pairs_filters_pairs_and_early_stopping_by_drug() -> None: + response = ResponseBatch( + response=np.array([1.0, 2.0, 3.0, 4.0]), + cell_line_ids=np.array(["cl1", "cl2", "cl1", "cl2"]), + drug_ids=np.array(["d1", "d1", "d2", "d2"]), + ) + early_stopping = ResponseBatch( + response=np.array([0.5, 0.6, 0.7]), + cell_line_ids=np.array(["cl1", "cl2", "cl1"]), + drug_ids=np.array(["d1", "d1", "d2"]), + ) + batch = ModelInputBatch( + cell_line_ids=response.cell_line_ids, + drug_ids=response.drug_ids, + response=np.asarray(response.response, dtype=np.float64), + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=np.array([[0.1], [0.2]], dtype=np.float32), + drug_features=None, + cell_line_pair_idx=np.array([0, 1, 0, 1]), + drug_pair_idx=np.array([0, 0, 1, 1]), + early_stopping_response=early_stopping, + ) + subset = batch.subset_pairs(np.array([True, True, False, False])) + assert subset.n_pairs == 2 + assert subset.drug_ids.tolist() == ["d1", "d1"] + assert subset.early_stopping_response is not None + assert subset.early_stopping_response.drug_ids.tolist() == ["d1", "d1"] + + +def test_subset_pairs_keeps_early_stopping_for_every_surviving_drug() -> None: + """A multi-drug subset keeps validation pairs for each surviving drug. + + ``PredictorBase.fit`` filters NaN pairs across the whole batch, so the mask it + passes routinely spans several drugs. Narrowing early stopping to one drug -- or + rejecting the mask outright -- would break every early-stopping predictor. + """ + response = ResponseBatch( + response=np.array([1.0, 2.0, 3.0, 4.0]), + cell_line_ids=np.array(["cl1", "cl2", "cl1", "cl2"]), + drug_ids=np.array(["d1", "d1", "d2", "d2"]), + ) + early_stopping = ResponseBatch( + response=np.array([0.5, 0.6, 0.7]), + cell_line_ids=np.array(["cl1", "cl2", "cl1"]), + drug_ids=np.array(["d1", "d2", "d3"]), + ) + batch = ModelInputBatch( + cell_line_ids=response.cell_line_ids, + drug_ids=response.drug_ids, + response=np.asarray(response.response, dtype=np.float64), + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=np.array([[0.1], [0.2]], dtype=np.float32), + drug_features=None, + cell_line_pair_idx=np.array([0, 1, 0, 1]), + drug_pair_idx=np.array([0, 0, 1, 1]), + early_stopping_response=early_stopping, + ) + # Drops one pair per drug, so the surviving mask still spans d1 and d2. + subset = batch.subset_pairs(np.array([True, False, True, False])) + assert subset.n_pairs == 2 + assert subset.drug_ids.tolist() == ["d1", "d2"] + assert subset.early_stopping_response is not None + # d1 and d2 are retained; d3 has no surviving response pair and is dropped. + assert subset.early_stopping_response.drug_ids.tolist() == ["d1", "d2"] + + +def test_pair_cell_line_indices_maps_ids() -> None: + indices = pair_cell_line_indices( + np.array(["cl2", "cl1", "cl2"]), + {"cl1": 0, "cl2": 1}, + ) + assert indices.tolist() == [1, 0, 1] + + +def test_pair_drug_indices_raises_for_missing_ids() -> None: + with pytest.raises(ValueError, match="Missing drug identifiers"): + pair_drug_indices(np.array(["d1", "missing"]), {"d1": 0}) diff --git a/tests/types/data/batch/test_model_input_build.py b/tests/types/data/batch/test_model_input_build.py new file mode 100644 index 000000000..9a85c7216 --- /dev/null +++ b/tests/types/data/batch/test_model_input_build.py @@ -0,0 +1,231 @@ +"""Tests for :func:`build_model_input_batch`. + +Carved out of ``test_model_input_batch.py``, which now covers only the +``ModelInputBatch`` dataclass and its pair-index helpers. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.components.contracts.training_context import TrainingContext +from drevalpy.types.data.batch.model_input_build import build_model_input_batch +from drevalpy.types.data.batch.response_batch import ResponseBatch + + +def _pairs(n: int = 2) -> ResponseBatch: + """Build *n* pairs named ``cl1..cln`` / ``d1..dn``.""" + return ResponseBatch( + response=np.arange(1.0, n + 1.0), + cell_line_ids=np.array([f"cl{i}" for i in range(1, n + 1)]), + drug_ids=np.array([f"d{i}" for i in range(1, n + 1)]), + ) + + +class TestIndexing: + def test_build_model_input_batch_indexes_entities(self) -> None: + response = _pairs() + early_stopping = _pairs() + + batch = build_model_input_batch( + response, + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32), + drug_features=np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32), + early_stopping_response=early_stopping, + ) + + assert batch.cell_line_pair_idx.tolist() == [0, 1] + assert batch.drug_pair_idx is not None + assert batch.drug_pair_idx.tolist() == [0, 1] + assert batch.early_stopping_response is early_stopping + + def test_pair_indices_follow_entity_row_order_not_pair_order(self) -> None: + response = ResponseBatch( + response=np.array([1.0, 2.0]), + cell_line_ids=np.array(["cl2", "cl1"]), + drug_ids=np.array(["d2", "d1"]), + ) + + batch = build_model_input_batch( + response, + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=np.array([[0.1], [0.2]], dtype=np.float32), + drug_features=np.array([[1.0], [2.0]], dtype=np.float32), + ) + + assert batch.cell_line_pair_idx.tolist() == [1, 0] + assert batch.drug_pair_idx is not None + assert batch.drug_pair_idx.tolist() == [1, 0] + + def test_drug_pair_idx_is_none_without_drug_entity_ids(self) -> None: + batch = build_model_input_batch( + _pairs(), + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=None, + cell_line_features=np.array([[0.1], [0.2]], dtype=np.float32), + drug_features=None, + ) + + assert batch.drug_pair_idx is None + + def test_drug_pair_idx_is_none_without_drug_features(self) -> None: + batch = build_model_input_batch( + _pairs(), + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array(["d1", "d2"]), + cell_line_features=np.array([[0.1], [0.2]], dtype=np.float32), + drug_features=None, + ) + + assert batch.drug_pair_idx is None + + def test_blocks_and_training_context_are_forwarded(self) -> None: + context = TrainingContext() + + batch = build_model_input_batch( + _pairs(), + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=None, + cell_line_features=np.array([[0.1], [0.2]], dtype=np.float32), + drug_features=None, + training_context=context, + ) + + assert batch.training_context is context + assert batch.cell_line_blocks == {} + assert batch.drug_blocks == {} + + +class TestBaselineWithoutEntityFeatures: + """Naive baselines pass empty entity ids, which must index to row zero.""" + + def test_empty_cell_line_entity_ids_index_to_row_zero(self) -> None: + batch = build_model_input_batch( + _pairs(), + cell_line_entity_ids=np.array([]), + drug_entity_ids=None, + cell_line_features=np.empty((0, 0), dtype=np.float32), + drug_features=None, + ) + + assert batch.cell_line_pair_idx.tolist() == [0, 0] + + def test_empty_drug_entity_ids_index_to_row_zero(self) -> None: + batch = build_model_input_batch( + _pairs(), + cell_line_entity_ids=np.array(["cl1", "cl2"]), + drug_entity_ids=np.array([]), + cell_line_features=np.array([[0.1], [0.2]], dtype=np.float32), + drug_features=np.empty((0, 0), dtype=np.float32), + ) + + assert batch.drug_pair_idx is not None + assert batch.drug_pair_idx.tolist() == [0, 0] + + def test_zero_pairs_yield_empty_index_arrays(self) -> None: + response = ResponseBatch( + response=np.array([]), + cell_line_ids=np.array([]), + drug_ids=np.array([]), + ) + + batch = build_model_input_batch( + response, + cell_line_entity_ids=np.array([]), + drug_entity_ids=np.array([]), + cell_line_features=np.empty((0, 0), dtype=np.float32), + drug_features=np.empty((0, 0), dtype=np.float32), + ) + + assert batch.n_pairs == 0 + assert batch.cell_line_pair_idx.tolist() == [] + + +class TestValidation: + def test_build_model_input_batch_rejects_mismatched_entity_rows(self) -> None: + response = _pairs(1) + + with pytest.raises(ValueError, match="cell_line_entity_ids length"): + build_model_input_batch( + response, + cell_line_entity_ids=np.array(["cl1"]), + drug_entity_ids=np.array(["d1"]), + cell_line_features=np.array([[0.1], [0.2]], dtype=np.float32), + drug_features=np.array([[1.0]], dtype=np.float32), + ) + + def test_build_model_input_batch_rejects_mismatched_drug_rows(self) -> None: + with pytest.raises(ValueError, match="drug_entity_ids length"): + build_model_input_batch( + _pairs(1), + cell_line_entity_ids=np.array(["cl1"]), + drug_entity_ids=np.array(["d1"]), + cell_line_features=np.array([[0.1]], dtype=np.float32), + drug_features=np.array([[1.0], [2.0]], dtype=np.float32), + ) + + def test_zero_dimensional_features_are_rejected(self) -> None: + with pytest.raises(ValueError, match=r"cell_line_features rows \(0\)"): + build_model_input_batch( + _pairs(1), + cell_line_entity_ids=np.array(["cl1"]), + drug_entity_ids=None, + cell_line_features=np.array(1.0, dtype=np.float32), + drug_features=None, + ) + + def test_empty_cell_line_entity_ids_with_features_are_rejected(self) -> None: + with pytest.raises(ValueError, match="cell_line_entity_ids must be non-empty"): + build_model_input_batch( + _pairs(1), + cell_line_entity_ids=np.array([]), + drug_entity_ids=None, + cell_line_features=np.array([[0.1]], dtype=np.float32), + drug_features=None, + ) + + def test_empty_drug_entity_ids_with_features_are_rejected(self) -> None: + with pytest.raises(ValueError, match="drug_entity_ids must be non-empty"): + build_model_input_batch( + _pairs(1), + cell_line_entity_ids=np.array(["cl1"]), + drug_entity_ids=np.array([]), + cell_line_features=np.array([[0.1]], dtype=np.float32), + drug_features=np.array([[1.0]], dtype=np.float32), + ) + + def test_build_model_input_batch_rejects_missing_pair_ids(self) -> None: + response = ResponseBatch( + response=np.array([1.0]), + cell_line_ids=np.array(["missing"]), + drug_ids=np.array(["d1"]), + ) + + with pytest.raises(ValueError, match="Missing cell-line identifiers"): + build_model_input_batch( + response, + cell_line_entity_ids=np.array(["cl1"]), + drug_entity_ids=np.array(["d1"]), + cell_line_features=np.array([[0.1]], dtype=np.float32), + drug_features=np.array([[1.0]], dtype=np.float32), + ) + + def test_build_model_input_batch_rejects_missing_drug_pair_ids(self) -> None: + response = ResponseBatch( + response=np.array([1.0]), + cell_line_ids=np.array(["cl1"]), + drug_ids=np.array(["missing"]), + ) + + with pytest.raises(ValueError, match="Missing drug identifiers"): + build_model_input_batch( + response, + cell_line_entity_ids=np.array(["cl1"]), + drug_entity_ids=np.array(["d1"]), + cell_line_features=np.array([[0.1]], dtype=np.float32), + drug_features=np.array([[1.0]], dtype=np.float32), + ) diff --git a/tests/types/data/batch/test_response_batch.py b/tests/types/data/batch/test_response_batch.py new file mode 100644 index 000000000..cce05171d --- /dev/null +++ b/tests/types/data/batch/test_response_batch.py @@ -0,0 +1,73 @@ +"""Tests for the immutable ``ResponseBatch`` triple container.""" + +from __future__ import annotations + +import dataclasses + +import numpy as np +import pytest + +from drevalpy.types.data.batch.response_batch import ResponseBatch + + +@pytest.fixture() +def batch() -> ResponseBatch: + """Two measured pairs across two cell lines and two drugs.""" + return ResponseBatch( + response=np.array([1.0, 2.0]), + cell_line_ids=np.array(["cl1", "cl2"]), + drug_ids=np.array(["d1", "d2"]), + ) + + +class TestConstruction: + def test_fields_are_stored_verbatim(self, batch: ResponseBatch): + np.testing.assert_array_equal(batch.response, np.array([1.0, 2.0])) + assert batch.cell_line_ids.tolist() == ["cl1", "cl2"] + assert batch.drug_ids.tolist() == ["d1", "d2"] + + def test_arrays_are_not_copied(self): + response = np.array([1.0]) + + batch = ResponseBatch(response=response, cell_line_ids=np.array(["cl1"]), drug_ids=np.array(["d1"])) + + assert batch.response is response + + def test_keyword_construction_requires_all_three_fields(self): + with pytest.raises(TypeError): + ResponseBatch(response=np.array([1.0])) # type: ignore[call-arg] + + +class TestLength: + def test_len_counts_response_pairs(self, batch: ResponseBatch): + assert len(batch) == 2 + + def test_an_empty_batch_has_length_zero(self): + empty = ResponseBatch( + response=np.array([]), + cell_line_ids=np.array([]), + drug_ids=np.array([]), + ) + + assert len(empty) == 0 + + def test_nan_responses_still_count_as_pairs(self): + """NaN marks an unmeasured pair; filtering happens in the predictors, not here.""" + with_nan = ResponseBatch( + response=np.array([1.0, np.nan]), + cell_line_ids=np.array(["cl1", "cl2"]), + drug_ids=np.array(["d1", "d2"]), + ) + + assert len(with_nan) == 2 + + +class TestImmutability: + def test_fields_cannot_be_reassigned(self, batch: ResponseBatch): + with pytest.raises(dataclasses.FrozenInstanceError): + batch.response = np.array([9.0]) # type: ignore[misc] + + def test_slots_replace_the_instance_dict(self, batch: ResponseBatch): + """``slots=True`` keeps these per-pair containers cheap to allocate.""" + assert ResponseBatch.__slots__ == ("response", "cell_line_ids", "drug_ids") + assert not hasattr(batch, "__dict__") diff --git a/tests/types/data/dataset_utils/test_aligned_fetch.py b/tests/types/data/dataset_utils/test_aligned_fetch.py new file mode 100644 index 000000000..354df6c67 --- /dev/null +++ b/tests/types/data/dataset_utils/test_aligned_fetch.py @@ -0,0 +1,89 @@ +"""Tests for the generic aligned-row fetch used by every feature accessor. + +The behaviour under test is the alignment contract: the result always has one +row per requested id, in the requested order, with NaN standing in for ids the +source does not carry -- and ``strict`` turns that silent fill into an error. +""" + +from __future__ import annotations + +import logging + +import numpy as np +import pandas as pd +import pytest + +from drevalpy.types.data.dataset_utils.aligned_fetch import _aligned_fetch + +INDEX = pd.Index(["a", "b", "c"]) +DATA = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) + + +def _fetch(ids: list[str], *, strict: bool = False) -> np.ndarray: + return _aligned_fetch(INDEX, np.array(ids), DATA, strict=strict, entity_label="cell line") + + +class TestAlignment: + def test_rows_follow_the_requested_order(self): + result = _fetch(["c", "a"]) + + np.testing.assert_array_equal(result, np.array([[5.0, 6.0], [1.0, 2.0]], dtype=np.float32)) + + def test_a_repeated_id_yields_a_repeated_row(self): + result = _fetch(["b", "b"]) + + np.testing.assert_array_equal(result[0], result[1]) + + def test_the_result_is_float32(self): + assert _fetch(["a"]).dtype == np.float32 + + def test_an_empty_request_keeps_the_feature_width(self): + result = _fetch([]) + + assert result.shape == (0, DATA.shape[1]) + + +class TestMissingIds: + def test_missing_ids_become_nan_rows(self): + result = _fetch(["a", "absent"]) + + assert not np.isnan(result[0]).any() + assert np.isnan(result[1]).all() + + def test_an_all_missing_request_is_entirely_nan(self): + result = _fetch(["absent1", "absent2"]) + + assert np.isnan(result).all() + + def test_missing_ids_are_logged_once(self, caplog: pytest.LogCaptureFixture): + with caplog.at_level(logging.WARNING, logger="drevalpy.types.data.dataset_utils.aligned_fetch"): + _fetch(["a", "absent"]) + + assert "1 of 2 cell line IDs not found" in caplog.text + + def test_the_warning_previews_at_most_five_ids(self, caplog: pytest.LogCaptureFixture): + with caplog.at_level(logging.WARNING, logger="drevalpy.types.data.dataset_utils.aligned_fetch"): + _fetch([f"absent{i}" for i in range(7)]) + + assert caplog.text.count("absent") == 5 + + def test_no_warning_is_logged_when_every_id_is_present(self, caplog: pytest.LogCaptureFixture): + with caplog.at_level(logging.WARNING, logger="drevalpy.types.data.dataset_utils.aligned_fetch"): + _fetch(["a", "b"]) + + assert caplog.text == "" + + +class TestStrictMode: + def test_strict_mode_raises_for_a_missing_id(self): + with pytest.raises(KeyError, match="1 of 2 cell line IDs not found"): + _fetch(["a", "absent"], strict=True) + + def test_strict_mode_passes_when_every_id_is_present(self): + result = _fetch(["a", "b"], strict=True) + + assert result.shape == (2, 2) + + def test_the_entity_label_appears_in_the_error(self): + with pytest.raises(KeyError, match="drug IDs not found"): + _aligned_fetch(INDEX, np.array(["absent"]), DATA, strict=True, entity_label="drug") diff --git a/tests/types/data/dataset_utils/test_dense.py b/tests/types/data/dataset_utils/test_dense.py new file mode 100644 index 000000000..a6861052f --- /dev/null +++ b/tests/types/data/dataset_utils/test_dense.py @@ -0,0 +1,83 @@ +"""Tests for the shared densification helper. + +``to_dense`` is on every matrix read on the dataset hot path, so the contract +worth pinning is that it duck-types on ``toarray`` and returns dense inputs +untouched rather than copying them. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from scipy import sparse + +from drevalpy.types.data.dataset_utils._dense import to_dense + + +class TestSparseInput: + @pytest.mark.parametrize( + "make_sparse", + [ + pytest.param(sparse.csr_matrix, id="csr"), + pytest.param(sparse.csc_matrix, id="csc"), + pytest.param(sparse.coo_matrix, id="coo"), + ], + ) + def test_sparse_matrices_are_densified(self, make_sparse): + dense = np.array([[1.0, 0.0], [0.0, 2.0]]) + + result = to_dense(make_sparse(dense)) + + assert isinstance(result, np.ndarray) + np.testing.assert_array_equal(result, dense) + + def test_densification_preserves_the_shape(self): + result = to_dense(sparse.csr_matrix((3, 5), dtype=np.float32)) + + assert result.shape == (3, 5) + + def test_implicit_zeros_materialize(self): + matrix = sparse.csr_matrix(([1.0], ([0], [0])), shape=(2, 2)) + + assert to_dense(matrix).sum() == 1.0 + + +class TestDenseInput: + def test_a_dense_array_is_returned_unchanged(self): + array = np.array([[1.0, 2.0]]) + + assert to_dense(array) is array + + def test_a_dataframe_is_returned_unchanged(self): + frame = pd.DataFrame({"a": [1.0]}) + + assert to_dense(frame) is frame + + @pytest.mark.parametrize( + "value", + [ + pytest.param(None, id="none"), + pytest.param([[1.0]], id="nested-list"), + pytest.param(3.5, id="scalar"), + ], + ) + def test_objects_without_toarray_pass_through(self, value): + assert to_dense(value) is value + + +class TestDuckTyping: + def test_any_object_exposing_toarray_is_densified(self): + class FakeSparse: + def toarray(self) -> np.ndarray: + return np.array([[7.0]]) + + np.testing.assert_array_equal(to_dense(FakeSparse()), np.array([[7.0]])) + + def test_a_non_callable_toarray_attribute_is_ignored(self): + class NotSparse: + toarray = "not callable" + + instance = NotSparse() + + assert to_dense(instance) is instance diff --git a/tests/types/data/dataset_utils/test_feature_access.py b/tests/types/data/dataset_utils/test_feature_access.py new file mode 100644 index 000000000..9a1d23dd2 --- /dev/null +++ b/tests/types/data/dataset_utils/test_feature_access.py @@ -0,0 +1,209 @@ +"""Tests for the cell-line and drug feature access mixin. + +``tests/types/data/test_dataset.py`` covers the happy paths through the real +fixture. This module targets what that cannot reach: the ``name:variant`` varm +prefix resolution, the ``entities_with_modality`` NaN filtering, and the strict +vs warn behaviour, all of which need datasets shaped deliberately wrong. +""" + +from __future__ import annotations + +import anndata as ad +import mudata as md +import numpy as np +import pandas as pd +import pytest + +from drevalpy.types.data.dataset import Dataset + +CELL_LINES = np.array(["cl1", "cl2", "cl3"]) +DRUGS = np.array(["d1", "d2"]) + + +def _dataset( + *, + varm: dict[str, np.ndarray] | None = None, + obsm: dict[str, np.ndarray] | None = None, + uns: dict[str, object] | None = None, + gene_expression: np.ndarray | None = None, + cnv_under_stored_name: bool = False, +) -> Dataset: + """Build a 3x2 Dataset with exactly the pieces a test needs.""" + response = ad.AnnData( + X=np.arange(6.0, dtype=np.float32).reshape(3, 2), + obs=pd.DataFrame({"tissue": ["Lung", "Blood", "Skin"]}, index=CELL_LINES), + var=pd.DataFrame(index=DRUGS), + ) + for key, value in (varm or {}).items(): + response.varm[key] = value + for key, value in (obsm or {}).items(): + response.obsm[key] = value + + mods: dict[str, ad.AnnData] = {"response": response} + default_expression = np.array([[0.0, 1.0], [2.0, 3.0], [4.0, 5.0]], dtype=np.float32) + mods["gene_expression"] = ad.AnnData( + X=default_expression if gene_expression is None else gene_expression, + obs=pd.DataFrame(index=CELL_LINES), + var=pd.DataFrame(index=["GENE1", "GENE2"]), + ) + if cnv_under_stored_name: + mods["copy_number_variation"] = ad.AnnData( + X=np.zeros((3, 2), dtype=np.float32), + obs=pd.DataFrame(index=CELL_LINES), + var=pd.DataFrame(index=["GENE1", "GENE2"]), + ) + + md.set_options(pull_on_update=False) + mdata = md.MuData(mods) + for key, value in (uns or {}).items(): + mdata.uns[key] = value + return Dataset(mdata, name="feature-access") + + +class TestModalityResolution: + def test_a_public_name_resolves_through_the_accessor_map(self): + dataset = _dataset(cnv_under_stored_name=True) + + features = dataset.get_cell_line_features("copy_number_variation_gistic", CELL_LINES) + + assert features.shape == (3, 2) + + def test_an_unknown_modality_lists_the_public_names_available(self): + dataset = _dataset(cnv_under_stored_name=True) + + with pytest.raises(KeyError, match="copy_number_variation_gistic"): + dataset.get_cell_line_features("absent", CELL_LINES) + + def test_feature_names_come_from_var_names(self): + assert _dataset().get_cell_line_feature_names("gene_expression") == ("GENE1", "GENE2") + + def test_feature_names_are_none_for_an_absent_modality(self): + assert _dataset().get_cell_line_feature_names("absent") is None + + def test_pathway_features_have_no_column_names(self): + dataset = _dataset(obsm={"pathway_features": np.zeros((3, 4), dtype=np.float32)}) + + assert dataset.get_cell_line_feature_names("pathway_features") is None + + +class TestObsmFeatures: + def test_pathway_features_are_read_from_obsm(self): + dataset = _dataset(obsm={"pathway_features": np.ones((3, 4), dtype=np.float32)}) + + features = dataset.get_cell_line_features("pathway_features", CELL_LINES[:2]) + + assert features.shape == (2, 4) + + def test_a_missing_obsm_key_raises(self): + with pytest.raises(KeyError, match="obsm key 'pathway_features' not found"): + _dataset().get_cell_line_features("pathway_features", CELL_LINES) + + def test_strict_mode_rejects_unknown_cell_lines(self): + dataset = _dataset(obsm={"pathway_features": np.ones((3, 4), dtype=np.float32)}) + + with pytest.raises(KeyError, match="cell line IDs not found"): + dataset.get_cell_line_features("pathway_features", np.array(["absent"]), strict=True) + + +class TestVarmKeyResolution: + def test_an_exact_varm_key_wins(self): + dataset = _dataset(varm={"pca": np.zeros((2, 3), dtype=np.float32)}) + + assert dataset.get_drug_features("pca", DRUGS).shape == (2, 3) + + def test_a_variant_key_is_found_by_prefix(self): + """Precomputed variants are stored as ``storage_key:index``.""" + dataset = _dataset(varm={"pca:0": np.zeros((2, 3), dtype=np.float32)}) + + assert dataset.get_drug_features("pca", DRUGS).shape == (2, 3) + + def test_a_prefix_match_requires_the_colon(self): + dataset = _dataset(varm={"pca_extra": np.zeros((2, 3), dtype=np.float32)}) + + with pytest.raises(KeyError, match="Drug feature 'pca' not found"): + dataset.get_drug_features("pca", DRUGS) + + def test_available_drug_views_are_sorted(self): + dataset = _dataset( + varm={ + "zeta": np.zeros((2, 1), dtype=np.float32), + "alpha": np.zeros((2, 1), dtype=np.float32), + } + ) + + assert dataset.available_drug_views == ["alpha", "zeta"] + + def test_drug_feature_names_fall_back_to_positional_labels(self): + dataset = _dataset(varm={"pca": np.zeros((2, 3), dtype=np.float32)}) + + assert dataset.get_drug_feature_names("pca") == ("0", "1", "2") + + def test_drug_feature_names_use_dataframe_columns_when_present(self): + frame = pd.DataFrame(np.zeros((2, 2), dtype=np.float32), columns=["bit0", "bit1"], index=DRUGS) + dataset = _dataset(varm={"fingerprint": frame}) + + assert dataset.get_drug_feature_names("fingerprint") == ("bit0", "bit1") + + def test_drug_feature_names_are_none_for_an_absent_view(self): + assert _dataset().get_drug_feature_names("absent") is None + + def test_strict_mode_rejects_unknown_drugs(self): + dataset = _dataset(varm={"pca": np.zeros((2, 3), dtype=np.float32)}) + + with pytest.raises(KeyError, match="drug IDs not found"): + dataset.get_drug_features("pca", np.array(["absent"]), strict=True) + + +class TestDrugGraphs: + def test_graphs_are_aligned_to_the_requested_ids(self): + dataset = _dataset(uns={"drug_graphs": {"d1": {"x": np.zeros(1)}}}) + + graphs = dataset.get_drug_graphs(np.array(["d2", "d1"])) + + assert graphs[0] is None + assert graphs[1] is not None + + def test_a_dataset_without_graphs_raises(self): + with pytest.raises(KeyError, match="'drug_graphs' not found"): + _dataset().get_drug_graphs(DRUGS) + + +class TestEntitiesWithModality: + def test_cell_lines_with_an_all_nan_row_are_excluded(self): + matrix = np.array([[0.0, 1.0], [np.nan, np.nan], [4.0, 5.0]], dtype=np.float32) + dataset = _dataset(gene_expression=matrix) + + assert dataset.entities_with_modality("gene_expression") == frozenset({"cl1", "cl3"}) + + def test_a_partially_nan_row_still_counts(self): + matrix = np.array([[0.0, np.nan], [2.0, 3.0], [4.0, 5.0]], dtype=np.float32) + dataset = _dataset(gene_expression=matrix) + + assert "cl1" in dataset.entities_with_modality("gene_expression") + + def test_pathway_features_are_filtered_the_same_way(self): + obsm = np.array([[1.0], [np.nan], [3.0]], dtype=np.float32) + dataset = _dataset(obsm={"pathway_features": obsm}) + + assert dataset.entities_with_modality("pathway_features") == frozenset({"cl1", "cl3"}) + + def test_absent_pathway_features_yield_an_empty_set(self): + assert _dataset().entities_with_modality("pathway_features") == frozenset() + + def test_drug_views_are_filtered_by_nan_rows(self): + varm = np.array([[1.0], [np.nan]], dtype=np.float32) + dataset = _dataset(varm={"pca": varm}) + + assert dataset.entities_with_modality("pca", side="drug") == frozenset({"d1"}) + + def test_drug_graph_membership_comes_from_uns(self): + dataset = _dataset(uns={"drug_graphs": {"d1": {"x": np.zeros(1)}}}) + + assert dataset.entities_with_modality("drug_graph", side="drug") == frozenset({"d1"}) + + def test_drug_graph_without_uns_yields_an_empty_set(self): + assert _dataset().entities_with_modality("drug_graph", side="drug") == frozenset() + + def test_an_absent_drug_view_raises(self): + with pytest.raises(KeyError, match="Drug feature 'absent' not found"): + _dataset().entities_with_modality("absent", side="drug") diff --git a/tests/types/data/dataset_utils/test_randomization.py b/tests/types/data/dataset_utils/test_randomization.py new file mode 100644 index 000000000..2bd2307f6 --- /dev/null +++ b/tests/types/data/dataset_utils/test_randomization.py @@ -0,0 +1,171 @@ +"""Tests for randomization utilities.""" + +from __future__ import annotations + +import numpy as np + +from drevalpy.types.data.dataset_utils.randomization import ( + _degree_preserving_rewire, + _is_graph_dict, + _randomize_graph, + _randomize_matrix, +) + + +class TestRandomizeMatrix: + """Tests for _randomize_matrix.""" + + def test_permutation_preserves_rows(self): + rng = np.random.default_rng(0) + data = np.arange(20, dtype=np.float32).reshape(5, 4) + result = _randomize_matrix(data, rng, "permutation") + + assert result.shape == data.shape + # Rows are a permutation of the original + original_set = {tuple(row) for row in data} + result_set = {tuple(row) for row in result} + assert original_set == result_set + + def test_permutation_changes_order(self): + rng = np.random.default_rng(42) + data = np.arange(40, dtype=np.float32).reshape(10, 4) + result = _randomize_matrix(data, rng, "permutation") + assert not np.array_equal(result, data) + + def test_invariant_preserves_row_statistics(self): + rng = np.random.default_rng(0) + data = rng.standard_normal((100, 50)).astype(np.float32) + + result = _randomize_matrix(data, np.random.default_rng(1), "invariant") + + assert result.shape == data.shape + # Per-row means should match approximately + np.testing.assert_allclose(result.mean(axis=1), data.mean(axis=1), atol=0.5) + # Content should be different + assert not np.array_equal(result, data) + + def test_invariant_handles_zero_std(self): + rng = np.random.default_rng(0) + data = np.ones((5, 10), dtype=np.float32) * 3.0 + result = _randomize_matrix(data, rng, "invariant") + assert result.shape == data.shape + # With near-zero std, values should be close to the mean + np.testing.assert_allclose(result.mean(axis=1), 3.0, atol=0.1) + + +class TestDegreePreservingRewire: + """Tests for _degree_preserving_rewire.""" + + def test_preserves_degree_sequence(self): + rng = np.random.default_rng(42) + edge_index = np.array([[0, 0, 1, 2, 3], [1, 2, 3, 3, 4]]) + result = _degree_preserving_rewire(edge_index, rng) + + assert result.shape == edge_index.shape + # Degree sequence (in+out per node) must be preserved + max_node = max(edge_index.max(), result.max()) + orig_deg = np.zeros(max_node + 1, dtype=int) + new_deg = np.zeros(max_node + 1, dtype=int) + for i in range(edge_index.shape[1]): + orig_deg[edge_index[0, i]] += 1 + orig_deg[edge_index[1, i]] += 1 + for i in range(result.shape[1]): + new_deg[result[0, i]] += 1 + new_deg[result[1, i]] += 1 + np.testing.assert_array_equal(sorted(orig_deg), sorted(new_deg)) + + def test_single_edge_unchanged(self): + rng = np.random.default_rng(0) + edge_index = np.array([[0], [1]]) + result = _degree_preserving_rewire(edge_index, rng) + np.testing.assert_array_equal(result, edge_index) + + def test_no_self_loops_introduced(self): + rng = np.random.default_rng(7) + edge_index = np.array([[0, 1, 2, 3], [1, 2, 3, 0]]) + result = _degree_preserving_rewire(edge_index, rng) + assert not np.any(result[0] == result[1]) + + +class TestRandomizeGraph: + """Tests for _randomize_graph.""" + + def test_preserves_structure_keys(self): + rng = np.random.default_rng(0) + graph = { + "x": np.random.randn(5, 3).astype(np.float32), + "edge_index": np.array([[0, 1, 2], [1, 2, 0]]), + "edge_attr": np.random.randn(3, 2).astype(np.float32), + } + result = _randomize_graph(graph, rng) + assert set(result.keys()) == set(graph.keys()) + + def test_node_features_randomized(self): + rng = np.random.default_rng(0) + graph = { + "x": np.arange(15, dtype=np.float32).reshape(5, 3), + "edge_index": np.array([[0, 1], [1, 2]]), + } + result = _randomize_graph(graph, rng) + assert not np.array_equal(result["x"], graph["x"]) + assert result["x"].shape == graph["x"].shape + + def test_edge_index_degree_preserved(self): + rng = np.random.default_rng(0) + edge_index = np.array([[0, 0, 1, 2, 3], [1, 2, 3, 3, 4]]) + graph = {"x": np.ones((5, 2), dtype=np.float32), "edge_index": edge_index} + result = _randomize_graph(graph, rng) + + orig_out_degree = np.bincount(edge_index[0]) + new_out_degree = np.bincount(result["edge_index"][0], minlength=len(orig_out_degree)) + np.testing.assert_array_equal(sorted(orig_out_degree), sorted(new_out_degree)) + + +class TestIsGraphDict: + """Tests for _is_graph_dict.""" + + def test_graph_collection_detected(self): + data = { + "drug1": {"x": np.zeros((3, 2)), "edge_index": np.array([[0], [1]])}, + "drug2": {"x": np.zeros((2, 2)), "edge_index": np.array([[0], [1]])}, + } + assert _is_graph_dict(data) is True + + def test_plain_dict_not_detected(self): + data = {"a": 1, "b": 2, "c": 3} + assert _is_graph_dict(data) is False + + def test_nested_dict_without_edge_index(self): + data = {"drug1": {"x": np.zeros((3, 2)), "features": np.zeros(5)}} + assert _is_graph_dict(data) is False + + +class TestOrchestratorPassesType: + """Test that the randomization orchestrator threads randomization_type.""" + + def test_orchestrator_passes_type(self, monkeypatch): + from unittest.mock import MagicMock + + from drevalpy.experiment import randomization + + mock_config = MagicMock() + mock_config.cell_line_views.return_value = ["gene_expression"] + mock_config.drug_views.return_value = [] + + mock_model = MagicMock() + mock_model.model_config.return_value = mock_config + + mock_dataset = MagicMock() + mock_dataset.with_randomized_views.return_value = MagicMock(randomization=("SVRC", "gene_expression")) + + randomization(mock_model, mock_dataset, ["SVRC"], randomization_type="invariant", random_state=0) + + mock_dataset.with_randomized_views.assert_called_once() + call_kwargs = mock_dataset.with_randomized_views.call_args + assert call_kwargs.kwargs.get("randomization_type") or call_kwargs[1].get("randomization_type") is None + # Check positional or keyword + args, kwargs = call_kwargs + if "randomization_type" in kwargs: + assert kwargs["randomization_type"] == "invariant" + else: + assert args[1] == "invariant" diff --git a/tests/types/data/dataset_utils/test_sampling.py b/tests/types/data/dataset_utils/test_sampling.py new file mode 100644 index 000000000..d59f32301 --- /dev/null +++ b/tests/types/data/dataset_utils/test_sampling.py @@ -0,0 +1,79 @@ +"""Tests for Optuna-backed hyperparameter sampling. + +Each call creates a real Optuna study, so the tests stay at one or two trials: +the point is that declared distributions are respected and that an empty space +short-circuits before a study is created. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.types.data.dataset_utils.sampling import _sample_hp_configs + + +def _featurizer(space: dict) -> type: + """Build a throwaway class exposing *space* as its hyperparameter space.""" + return type("StubFeaturizer", (), {"get_hyperparameter_space": classmethod(lambda cls: space)}) + + +_CATEGORICAL = _featurizer({"scaler": {"type": "categorical", "choices": ["standard", "minmax"]}}) +_MIXED = _featurizer( + { + "n_components": {"type": "int", "low": 2, "high": 4}, + "learning_rate": {"type": "float", "low": 1e-4, "high": 1e-2, "log": True}, + "hidden_dim": {"type": "pow2", "low": 3, "high": 5}, + } +) + + +class TestEmptySpace: + def test_a_featurizer_without_hyperparameters_yields_empty_configs(self): + assert _sample_hp_configs(_featurizer({}), 2) == [{}, {}] + + def test_requesting_zero_configs_from_an_empty_space_yields_nothing(self): + assert _sample_hp_configs(_featurizer({}), 0) == [] + + +class TestSampling: + def test_the_requested_number_of_configs_is_returned(self): + configs = _sample_hp_configs(_CATEGORICAL, 2) + + assert len(configs) == 2 + + def test_every_config_covers_the_whole_space(self): + (config,) = _sample_hp_configs(_MIXED, 1) + + assert set(config) == {"n_components", "learning_rate", "hidden_dim"} + + def test_categorical_values_come_from_the_declared_choices(self): + configs = _sample_hp_configs(_CATEGORICAL, 2) + + assert all(config["scaler"] in {"standard", "minmax"} for config in configs) + + @pytest.mark.parametrize( + ("key", "low", "high"), + [ + pytest.param("n_components", 2, 4, id="int-range"), + pytest.param("learning_rate", 1e-4, 1e-2, id="log-float-range"), + pytest.param("hidden_dim", 8, 32, id="pow2-range"), + ], + ) + def test_sampled_values_respect_the_declared_bounds(self, key, low, high): + (config,) = _sample_hp_configs(_MIXED, 1) + + assert low <= config[key] <= high + + def test_integer_parameters_are_sampled_as_integers(self): + (config,) = _sample_hp_configs(_MIXED, 1) + + assert isinstance(config["n_components"], int) + + def test_pow2_parameters_are_exponentiated(self): + (config,) = _sample_hp_configs(_MIXED, 1) + + assert config["hidden_dim"] in {8, 16, 32} + + def test_an_unknown_parameter_type_is_rejected(self): + with pytest.raises(ValueError, match="Unknown hyperparameter type"): + _sample_hp_configs(_featurizer({"weird": {"type": "quaternion"}}), 1) diff --git a/tests/types/data/test_dataset.py b/tests/types/data/test_dataset.py new file mode 100644 index 000000000..09877ef5c --- /dev/null +++ b/tests/types/data/test_dataset.py @@ -0,0 +1,387 @@ +"""Tests for ``Dataset`` against a synthetic dataset written to and read from disk. + +This module used to load a gitignored, downloaded ``.h5mu`` from the repo-local +data directory, which meant it could not run on a clean checkout. It now writes +the synthetic fixture to ``tmp_path`` and reads it back, so the one thing an +in-memory fixture would otherwise drop -- real on-disk ``.h5mu`` I/O -- stays +covered. Expected dimensions are derived from the builder's constants rather +than hard-coded, so the fixture and the assertions cannot drift apart. +""" + +# ruff: noqa: D102 + +from __future__ import annotations + +import numpy as np +import pytest +from upath import UPath + +from drevalpy.types.data.dataset import Dataset +from tests.synthetic import ( + BPE_LENGTH, + BUILTIN_MEASURE, + CHEMBERTA_DIM, + CNV_MODALITY, + FINGERPRINT_BITS, + N_CELL_LINES, + N_DRUGS, + N_GENES, + N_PATHWAYS, + OMICS_MODALITIES, + build_synthetic_dataset, +) +from tests.types.data._helpers import stub_featurizer, stub_fit_transform_featurizer + + +@pytest.fixture(scope="module") +def h5mu_path(tmp_path_factory: pytest.TempPathFactory) -> UPath: + """Write the synthetic dataset to a real .h5mu file once per module.""" + path = UPath(tmp_path_factory.mktemp("dataset")) / "synthetic.h5mu" + build_synthetic_dataset().save(path) + return path + + +@pytest.fixture() +def mudataset(h5mu_path: UPath) -> Dataset: + """Read the synthetic dataset back from disk.""" + return Dataset.load(h5mu_path) + + +class TestRoundTrip: + """Test that a saved dataset reads back with its identity and structure intact.""" + + def test_loads_without_error(self, mudataset: Dataset): + assert mudataset is not None + + def test_name_survives_the_round_trip(self, mudataset: Dataset): + assert mudataset.name == build_synthetic_dataset().name + + def test_all_modalities_survive_the_round_trip(self, mudataset: Dataset): + assert set(mudataset.mdata.mod) == {"response", *OMICS_MODALITIES} + + def test_copy_number_is_stored_under_the_accessor_name(self, mudataset: Dataset): + """The datasets store CNV without the ``_gistic`` suffix; the fixture follows suit.""" + assert CNV_MODALITY in mudataset.mdata.mod + assert CNV_MODALITY == "copy_number_variation" + + def test_repr(self, mudataset: Dataset): + r = repr(mudataset) + assert "Dataset" in r + assert "Cell lines:" in r + + def test_cell_line_ids(self, mudataset: Dataset): + ids = mudataset.cell_line_ids + assert ids.ndim == 1 + assert len(ids) == N_CELL_LINES + assert ids.dtype.kind in ("U", "O") + + def test_drug_ids(self, mudataset: Dataset): + ids = mudataset.drug_ids + assert ids.ndim == 1 + assert len(ids) == N_DRUGS + assert ids.dtype.kind in ("U", "O") + + +class TestResponse: + """Test response matrix access.""" + + def test_response_matrix_shape(self, mudataset: Dataset): + mat = mudataset.response_matrix + assert mat.shape == (N_CELL_LINES, N_DRUGS) + assert mat.dtype == np.float32 + + def test_response_matrix_is_nan_sparse(self, mudataset: Dataset): + mat = mudataset.response_matrix + assert np.isnan(mat).any() + assert not np.isnan(mat).all() + + def test_response_layer_auc(self, mudataset: Dataset): + auc = mudataset.get_response_layer("AUC") + assert auc.shape == (N_CELL_LINES, N_DRUGS) + assert auc.dtype == np.float32 + + def test_builtin_measure_layer_matches_x(self, mudataset: Dataset): + layer = mudataset.get_response_layer(BUILTIN_MEASURE) + np.testing.assert_array_equal(np.isnan(layer), np.isnan(mudataset.response_matrix)) + + def test_response_layer_missing(self, mudataset: Dataset): + with pytest.raises(KeyError, match="nonexistent"): + mudataset.get_response_layer("nonexistent") + + +class TestCellLineFeatures: + """Test cell-line feature retrieval.""" + + def test_gene_expression(self, mudataset: Dataset): + ids = mudataset.cell_line_ids[:5] + features = mudataset.get_cell_line_features("gene_expression", ids) + assert features.shape == (5, N_GENES) + assert features.dtype == np.float32 + + def test_missing_ids_get_nan(self, mudataset: Dataset): + ids = np.array(["FAKE_ID_1", "FAKE_ID_2"]) + features = mudataset.get_cell_line_features("gene_expression", ids) + assert features.shape == (2, N_GENES) + assert np.all(np.isnan(features)) + + def test_pathway_features(self, mudataset: Dataset): + ids = mudataset.cell_line_ids[:3] + features = mudataset.get_cell_line_features("pathway_features", ids) + assert features.shape == (3, N_PATHWAYS) + assert features.dtype == np.float32 + + def test_gene_names_are_real_symbols(self, mudataset: Dataset): + names = mudataset.get_cell_line_feature_names("gene_expression") + assert names is not None + assert len(names) == N_GENES + assert all(name.isupper() or any(ch.isdigit() for ch in name) for name in names) + + def test_unknown_modality_raises(self, mudataset: Dataset): + with pytest.raises(KeyError, match="nonexistent"): + mudataset.get_cell_line_features("nonexistent", mudataset.cell_line_ids[:1]) + + +class TestDrugFeatures: + """Test drug feature retrieval.""" + + def test_chemberta(self, mudataset: Dataset): + ids = mudataset.drug_ids[:4] + features = mudataset.get_drug_features("chemberta", ids) + assert features.shape == (4, CHEMBERTA_DIM) + assert features.dtype == np.float32 + + def test_morgan_fingerprint(self, mudataset: Dataset): + features = mudataset.get_drug_features("morgan_fingerprint", mudataset.drug_ids) + assert features.shape == (N_DRUGS, FINGERPRINT_BITS) + + def test_bpe_smiles(self, mudataset: Dataset): + features = mudataset.get_drug_features("bpe_smiles", mudataset.drug_ids) + assert features.shape == (N_DRUGS, BPE_LENGTH) + + def test_canonical_smiles_is_the_single_raw_drug_view(self, mudataset: Dataset): + assert "canonical_smiles" in mudataset.response.var.columns + assert mudataset.response.var["canonical_smiles"].notna().all() + + def test_missing_drug_raises(self, mudataset: Dataset): + with pytest.raises(KeyError, match="nonexistent"): + mudataset.get_drug_features("nonexistent", mudataset.drug_ids[:1]) + + +class TestDrugGraphs: + """Test drug graph access.""" + + def test_get_drug_graphs(self, mudataset: Dataset): + graphs = mudataset.get_drug_graphs(mudataset.drug_ids[:3]) + assert len(graphs) == 3 + for g in graphs: + assert g is not None + assert "x" in g + assert "edge_index" in g + assert "edge_attr" in g + + +class TestMetadata: + """Test metadata access.""" + + def test_cell_line_meta(self, mudataset: Dataset): + meta = mudataset.cell_line_meta + assert "cell_line_name" in meta.columns + assert "tissue" in meta.columns + + def test_get_tissue(self, mudataset: Dataset): + tissues = mudataset.get_tissue(mudataset.cell_line_ids[:5]) + assert len(tissues) == 5 + + def test_get_tissue_unknown_id(self, mudataset: Dataset): + tissues = mudataset.get_tissue(np.array(["FAKE_ID"])) + assert len(tissues) == 1 + + def test_enough_tissues_for_leave_tissue_out(self, mudataset: Dataset): + tissues = np.unique(mudataset.get_tissue(mudataset.cell_line_ids)) + assert len(tissues) >= 3 + + +class TestSubsetting: + """Test subsetting operations.""" + + def test_subset_cell_lines(self, mudataset: Dataset): + ids = mudataset.cell_line_ids[:10] + sub = mudataset.subset_cell_lines(ids) + assert len(sub.cell_line_ids) == 10 + assert sub.response_matrix.shape == (10, N_DRUGS) + + def test_subset_drugs(self, mudataset: Dataset): + ids = mudataset.drug_ids[:5] + sub = mudataset.subset_drugs(ids) + assert len(sub.drug_ids) == 5 + assert sub.response_matrix.shape == (N_CELL_LINES, 5) + + def test_subset_preserves_uns(self, mudataset: Dataset): + sub = mudataset.subset_cell_lines(mudataset.cell_line_ids[:5]) + assert "drug_graphs" in sub.mdata.uns + + +class TestUns: + """Test uns access.""" + + def test_get_uns(self, mudataset: Dataset): + bpe = mudataset.get_uns("bpe_codes") + assert isinstance(bpe, str) + + def test_pathways_gmt_is_tab_delimited(self, mudataset: Dataset): + gmt = mudataset.get_uns("pathways_gmt") + assert all("\t" in line for line in gmt.strip().splitlines()) + + def test_sparsego_carries_both_text_files(self, mudataset: Dataset): + sparsego = mudataset.get_uns("sparsego") + assert set(sparsego) == {"gene2ind", "ontology"} + + def test_get_uns_missing(self, mudataset: Dataset): + with pytest.raises(KeyError, match="nonexistent"): + mudataset.get_uns("nonexistent") + + +@pytest.fixture() +def records() -> list[dict]: + """Collect the ``store`` calls a precompute run makes.""" + return [] + + +class TestPrecompute: + """Test explicit pre-computation of featurizer representations.""" + + def test_default_config_is_prepended_to_explicit_configs(self, mudataset: Dataset, records: list[dict]): + mudataset.precompute(stub_featurizer(records), [{"n_components": 2}]) + + assert [call["hyperparameters"] for call in records] == [{}, {"n_components": 2}] + + def test_cell_line_side_precomputes_every_cell_line(self, mudataset: Dataset, records: list[dict]): + mudataset.precompute(stub_featurizer(records), []) + + assert records[0]["n_entities"] == N_CELL_LINES + + def test_drug_side_precomputes_every_drug(self, mudataset: Dataset, records: list[dict]): + mudataset.precompute(stub_featurizer(records, side="drug"), []) + + assert records[0]["n_entities"] == N_DRUGS + + def test_featurizer_without_compute_from_source_is_fitted_first(self, mudataset: Dataset, records: list[dict]): + mudataset.precompute(stub_fit_transform_featurizer(records), []) + + assert records[0] == {"call": "fit"} + assert records[1]["shape"] == (N_CELL_LINES, 3) + + def test_view_is_forwarded_to_the_featurizer_constructor(self, mudataset: Dataset, records: list[dict]): + seen: list[dict] = [] + featurizer_cls = stub_featurizer(records) + original_init = featurizer_cls.__init__ + + def spy_init(self, **kwargs): + seen.append(kwargs) + original_init(self, **kwargs) + + featurizer_cls.__init__ = spy_init + mudataset.precompute(featurizer_cls, [], view="methylation") + + assert seen == [{"view": "methylation"}] + + def test_int_hyperparameters_are_sampled(self, mudataset: Dataset, records: list[dict]): + """An int asks for N sampled configs; the empty HP space short-circuits Optuna.""" + mudataset.precompute(stub_featurizer(records), 2) + + assert len(records) == 2 + + +class TestPrecomputeSingle: + """Test the eligibility branches ``precompute_all`` funnels every featurizer through.""" + + def test_featurizer_not_marked_for_precomputation_is_skipped(self, mudataset: Dataset, records: list[dict]): + mudataset._precompute_single(stub_featurizer(records, precompute=False), 1) + + assert records == [] + + def test_entity_id_only_featurizer_is_skipped(self, mudataset: Dataset, records: list[dict]): + mudataset._precompute_single(stub_featurizer(records, entity_id_only=True), 1) + + assert records == [] + + def test_featurizer_with_missing_source_data_is_skipped(self, mudataset: Dataset, records: list[dict]): + mudataset._precompute_single(stub_featurizer(records, source_views=("absent_modality",)), 1) + + assert records == [] + + def test_view_featurizer_without_declared_input_views_is_skipped(self, mudataset: Dataset, records: list[dict]): + featurizer_cls = stub_featurizer(records, requires_view=True, input_views=None) + + mudataset._precompute_single(featurizer_cls, 1) + + assert records == [] + + def test_eligible_featurizer_is_precomputed(self, mudataset: Dataset, records: list[dict]): + mudataset._precompute_single(stub_featurizer(records, source_views=("gene_expression",)), 1) + + assert len(records) == 1 + + def test_featurizer_errors_are_swallowed(self, mudataset: Dataset, records: list[dict]): + def raise_value_error(source, entity_ids): + raise ValueError("no features") + + mudataset._precompute_single(stub_featurizer(records, compute=raise_value_error), 1) + + assert records == [] + + def test_precompute_all_visits_every_registered_featurizer(self, mudataset: Dataset, monkeypatch): + from drevalpy.registry.cell_line_featurizer import cell_line_featurizer_registry + from drevalpy.registry.drug_featurizer import drug_featurizer_registry + + visited: list[str] = [] + monkeypatch.setattr( + Dataset, + "_precompute_single", + lambda self, cls, n_variants: visited.append(cls.__name__), + ) + + mudataset.precompute_all(n_variants=1) + + expected = len(cell_line_featurizer_registry.list_names()) + len(drug_featurizer_registry.list_names()) + assert len(visited) == expected + + +class TestSourceAvailability: + """Test the raw-source checks that gate precomputation and model applicability.""" + + def test_canonical_smiles_is_found_on_the_response_var(self, mudataset: Dataset, records: list[dict]): + drug_cls = stub_featurizer(records, side="drug") + + assert mudataset._has_source_data(("canonical_smiles",), drug_cls) is True + + def test_cell_line_modality_is_resolved_through_the_accessor_map(self, mudataset: Dataset, records: list[dict]): + cell_line_cls = stub_featurizer(records) + + assert mudataset._has_source_data(("copy_number_variation_gistic",), cell_line_cls) is True + + def test_missing_cell_line_modality_is_reported_absent(self, mudataset: Dataset, records: list[dict]): + assert mudataset._has_source_data(("absent_modality",), stub_featurizer(records)) is False + + def test_drug_view_is_looked_up_in_response_varm(self, mudataset: Dataset, records: list[dict]): + drug_cls = stub_featurizer(records, side="drug") + + assert mudataset._has_source_data(("morgan_fingerprint",), drug_cls) is True + + def test_missing_drug_view_is_reported_absent(self, mudataset: Dataset, records: list[dict]): + drug_cls = stub_featurizer(records, side="drug") + + assert mudataset._has_source_data(("absent_view",), drug_cls) is False + + @pytest.mark.parametrize( + ("views", "available"), + [ + pytest.param(("gene_expression",), True, id="modality"), + pytest.param(("morgan_fingerprint",), True, id="varm-drug-view"), + pytest.param(("pathway_features",), True, id="obsm-view"), + pytest.param(("gene_expression", "chemberta"), True, id="both-sides"), + pytest.param(("gene_expression", "absent"), False, id="one-missing"), + ], + ) + def test_required_views_span_modalities_varm_and_obsm(self, mudataset: Dataset, views, available): + assert mudataset._has_required_views(views) is available diff --git a/tests/types/data/test_feature_source.py b/tests/types/data/test_feature_source.py new file mode 100644 index 000000000..54eb24d3d --- /dev/null +++ b/tests/types/data/test_feature_source.py @@ -0,0 +1,135 @@ +"""Tests for the ``FeatureSource`` ABC and its two Dataset-backed adapters. + +The special cases each adapter's ``get_entity_view`` carries are the point of +this module: ``"tissue"`` is metadata rather than a matrix on the cell-line side, +and ``"drug_graph"`` is a ``uns`` dict rather than a ``varm`` row on the drug side. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.feature_source import ( + CellLineFeatureSource, + DrugFeatureSource, + FeatureSource, +) +from tests.synthetic import CHEMBERTA_DIM, N_GENES + + +@pytest.fixture() +def cell_line_source(synthetic_dataset: Dataset) -> CellLineFeatureSource: + """Cell-line adapter over the first five cell lines.""" + return CellLineFeatureSource(synthetic_dataset, synthetic_dataset.cell_line_ids[:5]) + + +@pytest.fixture() +def drug_source(synthetic_dataset: Dataset) -> DrugFeatureSource: + """Drug adapter over every drug in the fixture.""" + return DrugFeatureSource(synthetic_dataset, synthetic_dataset.drug_ids) + + +class TestSharedBase: + def test_feature_source_cannot_be_instantiated(self, synthetic_dataset: Dataset): + with pytest.raises(TypeError, match="abstract"): + FeatureSource(synthetic_dataset, np.array(["x"])) # type: ignore[abstract] + + def test_identifiers_are_coerced_to_strings(self, synthetic_dataset: Dataset): + source = CellLineFeatureSource(synthetic_dataset, np.array([1, 2])) + + assert source.identifiers.dtype.kind == "U" + assert source.identifiers.tolist() == ["1", "2"] + + def test_identifiers_preserve_the_requested_order(self, synthetic_dataset: Dataset): + ids = synthetic_dataset.cell_line_ids[[3, 0, 1]] + + source = CellLineFeatureSource(synthetic_dataset, ids) + + assert source.identifiers.tolist() == list(ids) + + def test_mdata_exposes_the_dataset_backing_object( + self, synthetic_dataset: Dataset, cell_line_source: CellLineFeatureSource + ): + assert cell_line_source.mdata is synthetic_dataset.mdata + + def test_get_metadata_reads_dataset_uns(self, cell_line_source: CellLineFeatureSource): + assert set(cell_line_source.get_metadata("sparsego")) == {"gene2ind", "ontology"} + + def test_get_metadata_propagates_missing_keys(self, cell_line_source: CellLineFeatureSource): + with pytest.raises(KeyError, match="nonexistent"): + cell_line_source.get_metadata("nonexistent") + + +class TestCellLineFeatureSource: + def test_get_view_matrix_returns_one_row_per_requested_id(self, cell_line_source: CellLineFeatureSource): + matrix = cell_line_source.get_view_matrix("gene_expression", cell_line_source.identifiers) + + assert matrix.shape == (5, N_GENES) + + def test_get_feature_names_returns_gene_symbols(self, cell_line_source: CellLineFeatureSource): + names = cell_line_source.get_feature_names("gene_expression") + + assert names is not None + assert len(names) == N_GENES + + def test_get_feature_names_is_none_for_an_unknown_view(self, cell_line_source: CellLineFeatureSource): + assert cell_line_source.get_feature_names("absent_view") is None + + def test_get_entity_view_special_cases_tissue( + self, synthetic_dataset: Dataset, cell_line_source: CellLineFeatureSource + ): + entity_id = str(cell_line_source.identifiers[0]) + + tissue = cell_line_source.get_entity_view(entity_id, "tissue") + + assert tissue == synthetic_dataset.get_tissue(np.array([entity_id]))[0] + + def test_tissue_of_an_unknown_cell_line_is_nan(self, cell_line_source: CellLineFeatureSource): + assert np.isnan(cell_line_source.get_entity_view("NOT_A_CELL_LINE", "tissue")) + + def test_get_entity_view_returns_a_single_omics_row(self, cell_line_source: CellLineFeatureSource): + entity_id = str(cell_line_source.identifiers[0]) + + row = cell_line_source.get_entity_view(entity_id, "gene_expression") + + assert row.shape == (N_GENES,) + + def test_get_entity_view_row_matches_the_matrix_row(self, cell_line_source: CellLineFeatureSource): + entity_id = str(cell_line_source.identifiers[2]) + + row = cell_line_source.get_entity_view(entity_id, "gene_expression") + + matrix = cell_line_source.get_view_matrix("gene_expression", np.array([entity_id])) + np.testing.assert_array_equal(row, matrix[0]) + + +class TestDrugFeatureSource: + def test_get_view_matrix_returns_one_row_per_requested_id(self, drug_source: DrugFeatureSource): + matrix = drug_source.get_view_matrix("chemberta", drug_source.identifiers[:3]) + + assert matrix.shape == (3, CHEMBERTA_DIM) + + def test_get_feature_names_covers_every_varm_column(self, drug_source: DrugFeatureSource): + names = drug_source.get_feature_names("chemberta") + + assert names is not None + assert len(names) == CHEMBERTA_DIM + + def test_get_feature_names_is_none_for_an_unknown_view(self, drug_source: DrugFeatureSource): + assert drug_source.get_feature_names("absent_view") is None + + def test_get_entity_view_special_cases_drug_graph(self, drug_source: DrugFeatureSource): + graph = drug_source.get_entity_view(str(drug_source.identifiers[0]), "drug_graph") + + assert graph is not None + assert set(graph) >= {"x", "edge_index", "edge_attr"} + + def test_graph_of_an_unknown_drug_is_none(self, drug_source: DrugFeatureSource): + assert drug_source.get_entity_view("NOT_A_DRUG", "drug_graph") is None + + def test_get_entity_view_returns_a_single_varm_row(self, drug_source: DrugFeatureSource): + row = drug_source.get_entity_view(str(drug_source.identifiers[0]), "chemberta") + + assert row.shape == (CHEMBERTA_DIM,) diff --git a/tests/types/data/test_modalities.py b/tests/types/data/test_modalities.py new file mode 100644 index 000000000..b7b3f1128 --- /dev/null +++ b/tests/types/data/test_modalities.py @@ -0,0 +1,86 @@ +"""Tests for the omics-name to modality-key resolution layer. + +Every omics access in the package funnels through these three functions, so the +tests pin the property that makes the double naming safe: the name as written +always wins over the accessor map, which is what lets one code path read both +dataset generations. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.types.data.modalities import ( + OMICS_ACCESSORS, + backing_modality, + public_omics_name, + resolve_omics_accessor, +) + + +class TestAccessorMap: + def test_map_is_read_only(self): + with pytest.raises(TypeError): + OMICS_ACCESSORS["gene_expression"] = "something_else" # type: ignore[index] + + def test_map_is_injective(self): + assert len(set(OMICS_ACCESSORS.values())) == len(OMICS_ACCESSORS) + + def test_copy_number_is_the_only_non_identity_entry(self): + non_identity = {public for public, accessor in OMICS_ACCESSORS.items() if public != accessor} + + assert non_identity == {"copy_number_variation_gistic"} + + +class TestResolveOmicsAccessor: + @pytest.mark.parametrize( + ("name", "expected"), + [ + pytest.param("gene_expression", "gene_expression", id="identity-entry"), + pytest.param("copy_number_variation_gistic", "copy_number_variation", id="suffixed-name"), + pytest.param("pathway_features", "pathway_features", id="non-omics-view"), + pytest.param("", "", id="empty-string"), + ], + ) + def test_public_names_resolve_to_stored_keys(self, name, expected): + assert resolve_omics_accessor(name) == expected + + +class TestPublicOmicsName: + @pytest.mark.parametrize( + ("accessor", "expected"), + [ + pytest.param("copy_number_variation", "copy_number_variation_gistic", id="stored-key"), + pytest.param("methylation", "methylation", id="identity-entry"), + pytest.param("custom_matrix", "custom_matrix", id="non-omics-view"), + ], + ) + def test_stored_keys_resolve_back_to_public_names(self, accessor, expected): + assert public_omics_name(accessor) == expected + + def test_round_trip_is_lossless_for_every_registered_view(self): + assert all(public_omics_name(resolve_omics_accessor(name)) == name for name in OMICS_ACCESSORS) + + +class TestBackingModality: + def test_the_name_as_written_wins_over_the_accessor_map(self): + """A file already carrying the suffixed name is read directly, with no rename.""" + available = {"copy_number_variation_gistic", "copy_number_variation"} + + assert backing_modality("copy_number_variation_gistic", available) == "copy_number_variation_gistic" + + def test_the_accessor_map_is_the_fallback(self): + assert backing_modality("copy_number_variation_gistic", {"copy_number_variation"}) == "copy_number_variation" + + def test_a_view_absent_from_the_file_is_unbacked(self): + assert backing_modality("proteomics", {"gene_expression"}) is None + + def test_a_non_omics_view_is_matched_by_name_only(self): + assert backing_modality("pathway_features", {"pathway_features"}) == "pathway_features" + + def test_an_unbacked_non_omics_view_is_none(self): + assert backing_modality("pathway_features", set()) is None + + def test_any_container_of_keys_is_accepted(self): + """Callers pass ``mdata.mod``, sets and lists interchangeably.""" + assert backing_modality("methylation", ["methylation"]) == "methylation" diff --git a/tests/types/data/test_mudatalike.py b/tests/types/data/test_mudatalike.py new file mode 100644 index 000000000..a7ed39b78 --- /dev/null +++ b/tests/types/data/test_mudatalike.py @@ -0,0 +1,152 @@ +"""Tests for the ``MuDataLike`` protocol. + +The protocol exists so splitters can be exercised against a hand-built stand-in +instead of a full ``Dataset``. It is ``runtime_checkable``, so these tests pin +what ``isinstance`` actually enforces -- member presence, not signatures -- to +keep callers from relying on a guarantee it does not give. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.types.data import mudatalike +from drevalpy.types.data.dataset import Dataset +from drevalpy.types.data.mudatalike import MuDataLike +from tests.models.synthetic_fixtures import synthetic_mudataset_gene_expression_fingerprints + + +class _MinimalDataset: + """Smallest object satisfying the protocol: two entities, one measured pair.""" + + @property + def cell_line_ids(self) -> np.ndarray: + return np.array(["cl1", "cl2"]) + + @property + def drug_ids(self) -> np.ndarray: + return np.array(["d1"]) + + @property + def response_matrix(self) -> np.ndarray: + return np.array([[1.0], [np.nan]]) + + def get_tissue(self, ids: np.ndarray) -> np.ndarray: + return np.array(["Lung"] * len(ids)) + + def response_layer_names(self) -> list[str]: + return ["relevance_score"] + + def get_response_layer(self, name: str) -> np.ndarray: + if name != "relevance_score": + raise KeyError(name) + return np.array([[9.0], [np.nan]]) + + +class _MissingGetTissue: + """Satisfies every member but the tissue lookup.""" + + @property + def cell_line_ids(self) -> np.ndarray: + return np.array(["cl1"]) + + @property + def drug_ids(self) -> np.ndarray: + return np.array(["d1"]) + + @property + def response_matrix(self) -> np.ndarray: + return np.array([[1.0]]) + + def response_layer_names(self) -> list[str]: + return [] + + def get_response_layer(self, name: str) -> np.ndarray: + raise KeyError(name) + + +class _MissingGetResponseLayer: + """Satisfies every member but the layer accessor the quality filter needs.""" + + @property + def cell_line_ids(self) -> np.ndarray: + return np.array(["cl1"]) + + @property + def drug_ids(self) -> np.ndarray: + return np.array(["d1"]) + + @property + def response_matrix(self) -> np.ndarray: + return np.array([[1.0]]) + + def get_tissue(self, ids: np.ndarray) -> np.ndarray: + return np.array(["Lung"] * len(ids)) + + def response_layer_names(self) -> list[str]: + return [] + + +class TestProtocolConformance: + def test_dataset_satisfies_the_protocol(self): + dataset = synthetic_mudataset_gene_expression_fingerprints() + + assert isinstance(dataset, MuDataLike) + + def test_dataset_declares_the_protocol_as_a_base(self): + assert MuDataLike in Dataset.__mro__ + + def test_a_hand_built_stand_in_satisfies_the_protocol(self): + assert isinstance(_MinimalDataset(), MuDataLike) + + def test_an_object_missing_a_member_does_not_satisfy_the_protocol(self): + assert not isinstance(_MissingGetTissue(), MuDataLike) + + def test_an_object_missing_the_layer_accessor_does_not_satisfy_the_protocol(self): + assert not isinstance(_MissingGetResponseLayer(), MuDataLike) + + def test_an_unrelated_object_does_not_satisfy_the_protocol(self): + assert not isinstance(object(), MuDataLike) + + +class TestProtocolLimits: + def test_the_protocol_cannot_be_instantiated(self): + # Resolved by name at runtime so the static checker does not (correctly) + # reject the deliberate protocol instantiation outright. + construct = getattr(mudatalike, "MuDataLike") # noqa: B009 - defeats static resolution on purpose + + with pytest.raises(TypeError): + construct() + + def test_isinstance_does_not_check_signatures(self): + """Only member presence is tested, never a signature. + + Callers must not read a successful ``isinstance`` as a signature guarantee. + """ + + class WrongSignatures: + cell_line_ids = "not an array" + drug_ids = "not an array" + response_matrix = "not an array" + response_layer_names = "not a method" + + def get_tissue(self) -> None: + return None + + def get_response_layer(self) -> None: + return None + + assert isinstance(WrongSignatures(), MuDataLike) + + +class TestStandInBehaviour: + def test_response_matrix_carries_nan_for_unmeasured_pairs(self): + dataset = _MinimalDataset() + + assert np.isnan(dataset.response_matrix).sum() == 1 + + def test_get_tissue_returns_one_label_per_requested_id(self): + dataset = _MinimalDataset() + + assert len(dataset.get_tissue(dataset.cell_line_ids)) == 2 diff --git a/tests/types/data/test_split_mask.py b/tests/types/data/test_split_mask.py new file mode 100644 index 000000000..7ad0bb20a --- /dev/null +++ b/tests/types/data/test_split_mask.py @@ -0,0 +1,169 @@ +"""Tests for the single 2-D boolean :class:`SplitMask`. + +Carved out of ``test_split_masks.py``, which now covers only the three-way +``SplitMasks`` container it mirrors. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from drevalpy.types import SplitMask + + +class TestConstruction: + def test_creation_from_mask(self): + mask = np.array([[True, False], [False, True]]) + + scope = SplitMask(mask=mask) + + assert scope.pairs.shape == (2, 2) + assert len(scope) == 2 + + def test_non_boolean_input_is_coerced(self): + scope = SplitMask(mask=np.array([[1, 0], [0, 2]])) + + assert scope.mask.dtype == np.bool_ + assert len(scope) == 2 + + def test_from_pairs(self): + pairs = np.array([[0, 0], [1, 1]]) + + scope = SplitMask.from_pairs(pairs, shape=(2, 2)) + + assert scope.mask[0, 0] + assert scope.mask[1, 1] + assert not scope.mask[0, 1] + assert len(scope) == 2 + + def test_from_empty_pairs_yields_an_all_false_mask(self): + scope = SplitMask.from_pairs(np.empty((0, 2), dtype=int), shape=(3, 2)) + + assert scope.shape == (3, 2) + assert not scope.any() + + def test_shape_reports_the_underlying_matrix_shape(self): + scope = SplitMask(np.zeros((4, 7), dtype=bool)) + + assert scope.shape == (4, 7) + + +class TestPairs: + def test_pairs_property_matches_mask(self): + mask = np.array([[True, False, True], [False, True, False]]) + scope = SplitMask(mask=mask) + + np.testing.assert_array_equal(scope.pairs, np.argwhere(mask)) + + def test_pairs_are_row_major_without_a_seed(self): + scope = SplitMask(np.ones((2, 2), dtype=bool)) + + assert scope.pairs.tolist() == [[0, 0], [0, 1], [1, 0], [1, 1]] + + def test_shuffled_preserves_the_pair_set(self): + scope = SplitMask(np.ones((3, 3), dtype=bool)) + + shuffled = scope.shuffled(seed=0) + + assert sorted(map(tuple, shuffled.pairs)) == sorted(map(tuple, scope.pairs)) + + def test_shuffled_reorders_the_pairs(self): + scope = SplitMask(np.ones((4, 4), dtype=bool)) + + shuffled = scope.shuffled(seed=0) + + assert shuffled.pairs.tolist() != scope.pairs.tolist() + + def test_shuffled_is_reproducible_for_one_seed(self): + scope = SplitMask(np.ones((4, 4), dtype=bool)) + + first = scope.shuffled(seed=7).pairs + second = scope.shuffled(seed=7).pairs + + np.testing.assert_array_equal(first, second) + + def test_shuffled_leaves_the_mask_untouched(self): + scope = SplitMask(np.array([[True, False], [False, True]])) + + shuffled = scope.shuffled(seed=1) + + np.testing.assert_array_equal(shuffled.mask, scope.mask) + + +class TestSetOperations: + def test_or_unions_the_masks(self): + left = SplitMask(np.array([[True, False], [False, False]])) + right = SplitMask(np.array([[False, True], [False, False]])) + + assert (left | right).pairs.tolist() == [[0, 0], [0, 1]] + + def test_and_intersects_the_masks(self): + left = SplitMask(np.array([[True, True], [False, False]])) + right = SplitMask(np.array([[False, True], [True, False]])) + + assert (left & right).pairs.tolist() == [[0, 1]] + + def test_invert_flips_every_entry(self): + scope = SplitMask(np.array([[True, False], [False, False]])) + + assert (~scope).pairs.tolist() == [[0, 1], [1, 0], [1, 1]] + + def test_train_and_test_partition_of_a_full_mask_is_disjoint(self): + train = SplitMask(np.array([[True, True], [False, False]])) + + assert not (train & ~train).any() + + +class TestPredicates: + @pytest.mark.parametrize( + ("mask", "expected"), + [ + pytest.param(np.zeros((2, 2), dtype=bool), False, id="all-false"), + pytest.param(np.array([[False, True], [False, False]]), True, id="single-true"), + ], + ) + def test_any_reports_whether_a_pair_is_selected(self, mask, expected): + assert SplitMask(mask).any() is expected + + def test_sum_counts_selected_pairs(self): + scope = SplitMask(np.array([[True, True], [False, True]])) + + assert scope.sum() == 3 + + def test_sum_returns_a_python_int(self): + assert type(SplitMask(np.ones((2, 2), dtype=bool)).sum()) is int + + def test_len_matches_sum(self): + scope = SplitMask(np.array([[True, True], [False, True]])) + + assert len(scope) == scope.sum() + + +class TestValueSemantics: + def test_masks_with_equal_contents_are_equal(self): + mask = np.array([[True, False], [False, True]]) + + assert SplitMask(mask) == SplitMask(mask.copy()) + + def test_masks_with_different_contents_are_unequal(self): + assert SplitMask(np.ones((2, 2), dtype=bool)) != SplitMask(np.zeros((2, 2), dtype=bool)) + + def test_a_shuffle_seed_does_not_affect_equality(self): + scope = SplitMask(np.ones((2, 2), dtype=bool)) + + assert scope == scope.shuffled(seed=3) + + def test_comparison_against_a_foreign_type_is_not_equal(self): + assert SplitMask(np.ones((1, 1), dtype=bool)) != "not a mask" + + def test_equal_masks_hash_alike(self): + mask = np.array([[True, False], [False, True]]) + + assert hash(SplitMask(mask)) == hash(SplitMask(mask.copy())) + + def test_masks_are_usable_as_set_members(self): + first = SplitMask(np.ones((2, 2), dtype=bool)) + second = SplitMask(np.zeros((2, 2), dtype=bool)) + + assert len({first, SplitMask(np.ones((2, 2), dtype=bool)), second}) == 2 diff --git a/tests/types/data/test_split_masks.py b/tests/types/data/test_split_masks.py new file mode 100644 index 000000000..a2a68bdaf --- /dev/null +++ b/tests/types/data/test_split_masks.py @@ -0,0 +1,99 @@ +"""Tests for the three-way ``SplitMasks`` container. + +The single-mask behaviour of :class:`SplitMask` lives in ``test_split_mask.py``. +""" + +from __future__ import annotations + +import tempfile + +import numpy as np + +from drevalpy.types import SplitMask, SplitMasks + + +def _mask(shape: tuple[int, int], *positions: tuple[int, int]) -> SplitMask: + """Helper to build a SplitMask with True at given positions.""" + m = np.zeros(shape, dtype=bool) + for r, c in positions: + m[r, c] = True + return SplitMask(m) + + +class TestSplitMasks: + def test_creation(self): + shape = (4, 3) + masks = SplitMasks( + train=_mask(shape, (0, 0), (1, 1)), + test=_mask(shape, (2, 0)), + val=_mask(shape, (3, 1)), + ) + assert masks.train.shape == shape + assert masks.test.shape == shape + assert masks.val.shape == shape + assert len(masks.train) == 2 + assert len(masks.test) == 1 + assert len(masks.val) == 1 + + def test_metadata_default_empty(self): + shape = (2, 2) + masks = SplitMasks( + train=_mask(shape, (0, 0)), + test=_mask(shape, (1, 0)), + val=SplitMask(np.zeros(shape, dtype=bool)), + ) + assert masks.metadata == {} + + def test_metadata_mutable(self): + shape = (2, 2) + masks = SplitMasks( + train=_mask(shape, (0, 0)), + test=_mask(shape, (1, 0)), + val=SplitMask(np.zeros(shape, dtype=bool)), + ) + masks.metadata["key"] = "value" + assert masks.metadata["key"] == "value" + + def test_save_load_roundtrip(self): + shape = (6, 3) + masks = SplitMasks( + train=_mask(shape, (0, 0), (1, 1), (2, 2)), + test=_mask(shape, (3, 0), (4, 1)), + val=_mask(shape, (5, 2)), + metadata={"mode": "LCO", "fold_index": 0, "custom": 42}, + ) + with tempfile.NamedTemporaryFile(suffix=".npz") as f: + masks.save(f.name) + loaded = SplitMasks.load(f.name) + + np.testing.assert_array_equal(loaded.train.mask, masks.train.mask) + np.testing.assert_array_equal(loaded.test.mask, masks.test.mask) + np.testing.assert_array_equal(loaded.val.mask, masks.val.mask) + assert loaded.metadata == masks.metadata + + def test_save_load_empty_val(self): + shape = (2, 2) + masks = SplitMasks( + train=_mask(shape, (0, 0)), + test=_mask(shape, (1, 0)), + val=SplitMask(np.zeros(shape, dtype=bool)), + ) + with tempfile.NamedTemporaryFile(suffix=".npz") as f: + masks.save(f.name) + loaded = SplitMasks.load(f.name) + + assert not loaded.val.any() + assert loaded.val.shape == shape + + def test_save_load_no_metadata(self): + shape = (2, 2) + masks = SplitMasks( + train=_mask(shape, (0, 0)), + test=_mask(shape, (1, 0)), + val=SplitMask(np.zeros(shape, dtype=bool)), + ) + with tempfile.NamedTemporaryFile(suffix=".npz") as f: + masks.save(f.name) + loaded = SplitMasks.load(f.name) + + assert loaded.metadata == {} diff --git a/tests/types/data/test_tensor_data.py b/tests/types/data/test_tensor_data.py new file mode 100644 index 000000000..8b4edbf7b --- /dev/null +++ b/tests/types/data/test_tensor_data.py @@ -0,0 +1,161 @@ +"""Tests for the lazy pair-level DataLoader factory. + +``IndexedPairDataset`` exists to avoid materializing a pair-level feature matrix, +so the tests assert that a pair index reads through to the compact entity matrix +rather than checking a pre-expanded array. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import torch + +from drevalpy.types.data.tensor_data import IndexedPairDataset, make_pair_loader + +CELL_LINE_FEATURES = np.array([[0.1, 0.2], [0.3, 0.4]], dtype=np.float32) +DRUG_FEATURES = np.array([[1.0], [2.0], [3.0]], dtype=np.float32) +CELL_LINE_PAIR_IDX = np.array([0, 1, 0, 1]) +DRUG_PAIR_IDX = np.array([0, 1, 2, 0]) +RESPONSE = np.array([1.0, 2.0, 3.0, 4.0]) + + +class TestIndexedPairDataset: + def test_length_follows_the_pair_index(self): + dataset = IndexedPairDataset((CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX)) + + assert len(dataset) == 4 + + def test_length_is_zero_without_any_feature_spec(self): + assert len(IndexedPairDataset()) == 0 + + def test_getitem_reads_through_to_the_entity_row(self): + dataset = IndexedPairDataset((CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX)) + + (features,) = dataset[1] + + torch.testing.assert_close(features, torch.tensor([0.3, 0.4])) + + def test_repeated_pair_indices_share_the_same_entity_row(self): + dataset = IndexedPairDataset((CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX)) + + torch.testing.assert_close(dataset[0][0], dataset[2][0]) + + def test_getitem_returns_one_tensor_per_feature_spec(self): + dataset = IndexedPairDataset( + (CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), + (DRUG_FEATURES, DRUG_PAIR_IDX), + ) + + assert len(dataset[0]) == 2 + + def test_response_is_appended_as_a_trailing_scalar(self): + dataset = IndexedPairDataset((CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), response=RESPONSE) + + item = dataset[2] + + assert len(item) == 2 + torch.testing.assert_close(item[-1], torch.tensor(3.0)) + + def test_features_are_cast_to_float32(self): + dataset = IndexedPairDataset((CELL_LINE_FEATURES.astype(np.float64), CELL_LINE_PAIR_IDX)) + + assert dataset[0][0].dtype is torch.float32 + + def test_response_is_cast_to_float32(self): + dataset = IndexedPairDataset((CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), response=RESPONSE) + + assert dataset[0][-1].dtype is torch.float32 + + def test_pair_count_follows_the_first_feature_spec(self): + """``__len__`` reads the first spec's index, so callers must keep specs aligned.""" + dataset = IndexedPairDataset( + (CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), + (DRUG_FEATURES, DRUG_PAIR_IDX[:2]), + ) + + assert len(dataset) == len(CELL_LINE_PAIR_IDX) + + def test_reading_past_a_shorter_spec_fails_loudly(self): + """``strict=True`` zipping stops a silently truncated batch.""" + dataset = IndexedPairDataset( + (CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), + (DRUG_FEATURES, DRUG_PAIR_IDX[:2]), + ) + + with pytest.raises(IndexError): + dataset[3] + + +class TestMakePairLoader: + def test_loader_batches_pairs(self): + loader = make_pair_loader( + (CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), + response=RESPONSE, + batch_size=2, + shuffle=False, + ) + + batches = list(loader) + + assert len(batches) == 2 + + def test_batch_shapes_stack_the_entity_rows(self): + loader = make_pair_loader( + (CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), + (DRUG_FEATURES, DRUG_PAIR_IDX), + response=RESPONSE, + batch_size=4, + shuffle=False, + ) + + cell_lines, drugs, response = next(iter(loader)) + + assert cell_lines.shape == (4, 2) + assert drugs.shape == (4, 1) + assert response.shape == (4,) + + def test_unshuffled_loader_preserves_pair_order(self): + loader = make_pair_loader( + (CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), + response=RESPONSE, + batch_size=4, + shuffle=False, + ) + + _, response = next(iter(loader)) + + torch.testing.assert_close(response, torch.tensor(RESPONSE, dtype=torch.float32)) + + def test_drop_last_discards_an_incomplete_batch(self): + loader = make_pair_loader( + (CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), + response=RESPONSE, + batch_size=3, + shuffle=False, + drop_last=True, + ) + + assert [batch[-1].shape[0] for batch in loader] == [3] + + def test_keeping_the_last_batch_yields_every_pair(self): + loader = make_pair_loader( + (CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), + response=RESPONSE, + batch_size=3, + shuffle=False, + drop_last=False, + ) + + assert sum(batch[-1].shape[0] for batch in loader) == len(RESPONSE) + + def test_loader_without_a_response_yields_features_only(self): + loader = make_pair_loader( + (CELL_LINE_FEATURES, CELL_LINE_PAIR_IDX), + batch_size=4, + shuffle=False, + ) + + batch = next(iter(loader)) + + assert len(batch) == 1 diff --git a/tests/types/enums/test_literature_reference.py b/tests/types/enums/test_literature_reference.py new file mode 100644 index 000000000..d224e1c86 --- /dev/null +++ b/tests/types/enums/test_literature_reference.py @@ -0,0 +1,18 @@ +"""Tests for LiteratureReference.""" + +from __future__ import annotations + +from drevalpy.types.enums.literature_reference import LiteratureReference + + +def test_literature_reference_strips_fields() -> None: + ref = LiteratureReference( + repo_url=" https://github.com/example/repo ", + citation_doi=" 10.1234/example ", + citation_text=" Example paper. ", + deviations=" none ", + ) + assert ref.repo_url == "https://github.com/example/repo" + assert ref.citation_doi == "10.1234/example" + assert ref.citation_text == "Example paper." + assert ref.deviations == "none" diff --git a/tests/types/enums/test_model_scope.py b/tests/types/enums/test_model_scope.py new file mode 100644 index 000000000..cce26f451 --- /dev/null +++ b/tests/types/enums/test_model_scope.py @@ -0,0 +1,48 @@ +"""Tests for the ``ModelScope`` training-scope enum.""" + +from __future__ import annotations + +from enum import StrEnum + +import pytest + +from drevalpy.types.enums.model_scope import ModelScope + + +class TestMembers: + def test_exactly_two_scopes_exist(self): + assert set(ModelScope) == {ModelScope.MULTI_DRUG, ModelScope.SINGLE_DRUG} + + @pytest.mark.parametrize( + ("member", "value"), + [ + pytest.param(ModelScope.MULTI_DRUG, "multi_drug", id="multi-drug"), + pytest.param(ModelScope.SINGLE_DRUG, "single_drug", id="single-drug"), + ], + ) + def test_values_are_the_serialized_names(self, member, value): + assert member.value == value + + def test_lookup_by_value_returns_the_member(self): + assert ModelScope("single_drug") is ModelScope.SINGLE_DRUG + + def test_an_unknown_value_is_rejected(self): + with pytest.raises(ValueError, match="per_tissue"): + ModelScope("per_tissue") + + +class TestStringBehaviour: + def test_members_are_strings(self): + assert isinstance(ModelScope.MULTI_DRUG, str) + assert issubclass(ModelScope, StrEnum) + + def test_members_compare_equal_to_their_value(self): + assert ModelScope.MULTI_DRUG == "multi_drug" + + def test_string_formatting_uses_the_value(self): + assert f"{ModelScope.SINGLE_DRUG}" == "single_drug" + + def test_members_are_usable_as_mapping_keys_alongside_strings(self): + registry = {ModelScope.MULTI_DRUG: "global"} + + assert registry["multi_drug"] == "global" diff --git a/tests/types/enums/test_prediction_mode.py b/tests/types/enums/test_prediction_mode.py new file mode 100644 index 000000000..ce2bdcfb6 --- /dev/null +++ b/tests/types/enums/test_prediction_mode.py @@ -0,0 +1,33 @@ +"""Tests for shared drevalpy type definitions.""" + +from __future__ import annotations + +import drevalpy.models.config as model_config +import drevalpy.types.enums.prediction_mode as prediction_mode_module +from drevalpy.types.enums.prediction_mode import PredictionMode + + +def test_prediction_mode_values() -> None: + assert PredictionMode.REGRESSION == "regression" + assert PredictionMode.CLASSIFICATION == "classification" + + +def test_prediction_mode_reexported_from_models_config() -> None: + assert model_config.PredictionMode is PredictionMode + + +def test_predictors_base_imports_prediction_mode_from_types() -> None: + import drevalpy.components.predictors.abstract.base as base_module + + source_path = base_module.__file__ + assert source_path is not None + text = open(source_path, encoding="utf-8").read() + assert "drevalpy.types.enums.prediction_mode" in text + assert "drevalpy.models.config" not in text + + +def test_prediction_mode_module_has_no_models_dependency() -> None: + source_path = prediction_mode_module.__file__ + assert source_path is not None + text = open(source_path, encoding="utf-8").read() + assert "drevalpy.models" not in text diff --git a/tests/types/results/test_experiment.py b/tests/types/results/test_experiment.py new file mode 100644 index 000000000..6b08c5c47 --- /dev/null +++ b/tests/types/results/test_experiment.py @@ -0,0 +1,481 @@ +"""Tests for the experiment-level result: guards, capabilities and normalization.""" + +from __future__ import annotations + +import json +import logging + +import numpy as np +import pytest +from upath import UPath + +from drevalpy.evaluation import AVAILABLE_METRICS +from drevalpy.types.results.experiment import ExperimentResult, _run_array_bytes +from drevalpy.types.results.run import intern_ids +from drevalpy.types.results.trial import TrialResult +from drevalpy.visualization.requirements import PlotRequirement +from tests.synthetic import NORMALIZED_METRIC, REFERENCE_MODEL, make_experiment_result, make_run_result + + +class TestConstruction: + def test_rejects_an_empty_run_list(self) -> None: + with pytest.raises(ValueError, match="must not be empty"): + ExperimentResult([]) + + def test_rejects_mixed_dataset_names(self) -> None: + runs = [ + make_run_result(dataset_name="GDSC1"), + make_run_result(dataset_name="CTRPv2"), + ] + + with pytest.raises(ValueError, match="same dataset_name"): + ExperimentResult(runs) + + def test_rejects_mixed_split_modes(self) -> None: + runs = [ + make_run_result(model_name="A", split_mode="LPO"), + make_run_result(model_name="B", split_mode="LCO"), + ] + + with pytest.raises(ValueError, match="same split_mode"): + ExperimentResult(runs) + + def test_ignores_blank_split_modes_when_checking_consistency(self) -> None: + runs = [ + make_run_result(model_name="A", split_mode="LPO"), + make_run_result(model_name="B", split_mode=""), + ] + + assert ExperimentResult(runs).split_mode == "LPO" + + def test_split_mode_is_blank_when_no_run_declares_one(self) -> None: + assert ExperimentResult([make_run_result(split_mode="")]).split_mode == "" + + def test_groups_runs_by_model_name(self) -> None: + runs = [ + make_run_result(model_name="A", fold_index=0), + make_run_result(model_name="A", fold_index=1), + make_run_result(model_name="B", fold_index=0), + ] + + result = ExperimentResult(runs) + + assert result.model_names == ["A", "B"] + assert [m.n_folds for m in result.models] == [2, 1] + + def test_propagates_dataset_name_to_each_model(self) -> None: + result = make_experiment_result(n_models=2, n_folds=1) + + assert {m.dataset_name for m in result.models} == {"SyntheticDataset"} + + def test_starts_unnormalized(self) -> None: + assert make_experiment_result(n_models=1, n_folds=1).normalized_by is None + + +class TestCapabilities: + def test_counts_distinct_models(self) -> None: + assert make_experiment_result(n_models=3, n_folds=2).n_models == 3 + + def test_max_folds_takes_the_largest_model(self) -> None: + runs = [ + make_run_result(model_name="A", fold_index=0), + make_run_result(model_name="B", fold_index=0), + make_run_result(model_name="B", fold_index=1), + ] + + assert ExperimentResult(runs).max_folds == 2 + + def test_randomization_is_absent_by_default(self) -> None: + assert make_experiment_result(n_models=2, n_folds=1).has_randomization is False + + def test_randomization_is_detected(self) -> None: + result = make_experiment_result(n_models=2, n_folds=1, with_randomization=True) + + assert result.has_randomization is True + + def test_robustness_is_absent_by_default(self) -> None: + assert make_experiment_result(n_models=2, n_folds=1).has_robustness is False + + def test_robustness_is_detected_from_fold_metadata(self) -> None: + result = make_experiment_result(n_models=2, n_folds=1, with_robustness=True) + + assert result.has_robustness is True + + def test_summary_table_holds_the_mean_of_each_metric(self) -> None: + runs = [ + make_run_result(model_name="A", fold_index=0, metrics={"MSE": 0.2}), + make_run_result(model_name="A", fold_index=1, metrics={"MSE": 0.4}), + ] + + table = ExperimentResult(runs).summary_table + + assert table == {"A": {"MSE": pytest.approx(0.3)}} + + +class TestSatisfies: + def test_no_requirements_are_always_satisfied(self) -> None: + assert make_experiment_result(n_models=1, n_folds=1).satisfies(frozenset()) is True + + @pytest.mark.parametrize( + ("requirement", "n_models", "n_folds"), + [ + pytest.param(PlotRequirement.MULTIPLE_MODELS, 1, 2, id="one-model"), + pytest.param(PlotRequirement.MULTIPLE_FOLDS, 2, 1, id="one-fold"), + ], + ) + def test_structural_requirements_are_rejected_when_unmet( + self, requirement: PlotRequirement, n_models: int, n_folds: int + ) -> None: + result = make_experiment_result(n_models=n_models, n_folds=n_folds) + + assert result.satisfies(frozenset({requirement})) is False + + def test_structural_requirements_are_accepted_when_met(self) -> None: + result = make_experiment_result(n_models=2, n_folds=2) + + requirements = frozenset({PlotRequirement.MULTIPLE_MODELS, PlotRequirement.MULTIPLE_FOLDS}) + assert result.satisfies(requirements) is True + + def test_randomization_requirement_needs_randomization_data(self) -> None: + requirements = frozenset({PlotRequirement.RANDOMIZATION}) + + assert make_experiment_result(n_models=2, n_folds=2).satisfies(requirements) is False + assert make_experiment_result(n_models=2, n_folds=2, with_randomization=True).satisfies(requirements) is True + + def test_robustness_requirement_needs_robustness_data(self) -> None: + requirements = frozenset({PlotRequirement.ROBUSTNESS}) + + assert make_experiment_result(n_models=2, n_folds=2).satisfies(requirements) is False + assert make_experiment_result(n_models=2, n_folds=2, with_robustness=True).satisfies(requirements) is True + + +class TestNormalize: + def test_drops_the_reference_model(self) -> None: + normalized = make_experiment_result(n_models=3, n_folds=2).normalize() + + assert REFERENCE_MODEL not in normalized.model_names + assert normalized.n_models == 2 + + def test_records_the_reference_model(self) -> None: + assert make_experiment_result(n_models=2, n_folds=1).normalize().normalized_by == REFERENCE_MODEL + + def test_subtracts_reference_predictions_pairwise(self) -> None: + experiment = make_experiment_result(n_models=2, n_folds=1) + reference, other = experiment.models + + normalized_run = experiment.normalize().models[0].runs[0] + + expected = other.runs[0].predictions - reference.runs[0].predictions + np.testing.assert_allclose(normalized_run.predictions, expected) + + def test_recomputes_only_the_standard_metrics(self) -> None: + normalized = make_experiment_result(n_models=2, n_folds=1).normalize() + + metrics = normalized.models[0].runs[0].metrics + assert set(metrics) == set(AVAILABLE_METRICS) + assert NORMALIZED_METRIC not in metrics + + def test_treats_pairs_missing_from_the_reference_as_zero(self) -> None: + reference = make_run_result(model_name=REFERENCE_MODEL, n_pairs=4, n_cell_lines=2, n_drugs=2) + other = make_run_result(model_name="ElasticNet", n_pairs=4, n_cell_lines=2, n_drugs=2) + other.cell_line_ids = np.array(["CL_9", "CL_9", "CL_9", "CL_9"]) + + normalized_run = ExperimentResult([reference, other]).normalize().models[0].runs[0] + + np.testing.assert_allclose(normalized_run.predictions, other.predictions) + + def test_yields_no_metrics_when_every_pair_is_nan(self) -> None: + reference = make_run_result(model_name=REFERENCE_MODEL, n_pairs=4) + other = make_run_result(model_name="ElasticNet", n_pairs=4) + other.predictions = np.full(4, np.nan) + + normalized_run = ExperimentResult([reference, other]).normalize().models[0].runs[0] + + assert normalized_run.metrics == {} + + def test_carries_trials_over(self) -> None: + reference = make_run_result(model_name=REFERENCE_MODEL, n_pairs=4) + other = make_run_result(model_name="ElasticNet", n_pairs=4) + other.trials = [ + TrialResult( + hyperparameters={"alpha": 0.1}, + metrics={"MSE": 0.3}, + optimization_metric="MSE", + predictions=np.zeros(4), + ) + ] + + normalized_run = ExperimentResult([reference, other]).normalize().models[0].runs[0] + + assert normalized_run.trials is not None + assert normalized_run.trials[0].hyperparameters == {"alpha": 0.1} + + def test_preserves_run_identity(self) -> None: + experiment = make_experiment_result(n_models=2, n_folds=2, split_mode="LCO") + + normalized_run = experiment.normalize().models[0].runs[1] + + assert normalized_run.fold_id == "fold_1" + assert normalized_run.fold_index == 1 + assert normalized_run.split_mode == "LCO" + + def test_rejects_a_second_normalization(self) -> None: + normalized = make_experiment_result(n_models=2, n_folds=1).normalize() + + with pytest.raises(ValueError, match="Already normalized"): + normalized.normalize() + + def test_rejects_an_unknown_reference_model(self) -> None: + experiment = make_experiment_result(n_models=2, n_folds=1) + + with pytest.raises(ValueError, match="not found"): + experiment.normalize(reference_model="NotAModel") + + def test_rejects_a_fold_without_a_reference_run(self) -> None: + reference = make_run_result(model_name=REFERENCE_MODEL, fold_index=0) + other = make_run_result(model_name="ElasticNet", fold_index=1) + + with pytest.raises(ValueError, match="No reference run"): + ExperimentResult([reference, other]).normalize() + + def test_accepts_an_explicit_reference_model(self) -> None: + experiment = make_experiment_result(n_models=3, n_folds=1) + + normalized = experiment.normalize(reference_model="ElasticNet") + + assert normalized.normalized_by == "ElasticNet" + assert "ElasticNet" not in normalized.model_names + + def test_normalizes_interned_id_arrays(self) -> None: + reference = make_run_result(model_name=REFERENCE_MODEL, n_pairs=8) + other = make_run_result(model_name="ElasticNet", n_pairs=8) + for run in (reference, other): + run.cell_line_ids = intern_ids(run.cell_line_ids) + run.drug_ids = intern_ids(run.drug_ids) + + normalized_run = ExperimentResult([reference, other]).normalize().models[0].runs[0] + + np.testing.assert_allclose(normalized_run.predictions, other.predictions - reference.predictions) + + def test_normalizes_only_the_pairs_the_reference_covers(self) -> None: + reference = make_run_result(model_name=REFERENCE_MODEL, n_pairs=4, n_cell_lines=2, n_drugs=2) + other = make_run_result(model_name="ElasticNet", n_pairs=4, n_cell_lines=2, n_drugs=2) + other.cell_line_ids = np.array(["CL_0", "CL_9", "CL_0", "CL_9"]) + other.drug_ids = np.array(["D_0", "D_0", "D_1", "D_1"]) + + normalized_run = ExperimentResult([reference, other]).normalize().models[0].runs[0] + + ref_by_pair = dict( + zip( + zip(reference.cell_line_ids, reference.drug_ids, strict=True), + reference.predictions, + strict=True, + ) + ) + expected = other.predictions - np.array( + [ref_by_pair.get((cl, dr), 0.0) for cl, dr in zip(other.cell_line_ids, other.drug_ids, strict=True)] + ) + np.testing.assert_allclose(normalized_run.predictions, expected) + + def test_a_duplicated_reference_pair_resolves_to_its_last_prediction(self) -> None: + reference = make_run_result(model_name=REFERENCE_MODEL, n_pairs=2) + reference.cell_line_ids = np.array(["CL_0", "CL_0"]) + reference.drug_ids = np.array(["D_0", "D_0"]) + reference.predictions = np.array([1.0, 5.0]) + other = make_run_result(model_name="ElasticNet", n_pairs=1) + other.cell_line_ids = np.array(["CL_0"]) + other.drug_ids = np.array(["D_0"]) + other.predictions = np.array([7.0]) + other.ground_truth = np.array([7.0]) + + normalized_run = ExperimentResult([reference, other]).normalize().models[0].runs[0] + + np.testing.assert_allclose(normalized_run.predictions, np.array([2.0])) + + def test_normalizes_ground_truth_by_the_same_offset(self) -> None: + experiment = make_experiment_result(n_models=2, n_folds=1) + reference, other = experiment.models + + normalized_run = experiment.normalize().models[0].runs[0] + + expected = other.runs[0].ground_truth - reference.runs[0].predictions + np.testing.assert_allclose(normalized_run.ground_truth, expected) + + +class TestPersistence: + def test_save_writes_one_directory_per_model(self, tmp_path) -> None: + directory = UPath(tmp_path) / "experiment" + + make_experiment_result(n_models=2, n_folds=1).save(directory) + + assert (directory / REFERENCE_MODEL / "metadata.json").is_file() + assert (directory / "ElasticNet" / "metadata.json").is_file() + + def test_save_records_experiment_metadata(self, tmp_path) -> None: + directory = UPath(tmp_path) / "experiment" + + make_experiment_result(n_models=2, n_folds=1, split_mode="LDO").save(directory) + meta = json.loads((directory / "metadata.json").read_text()) + + assert meta["dataset_name"] == "SyntheticDataset" + assert meta["split_mode"] == "LDO" + assert meta["normalized_by"] is None + assert meta["models"] == [REFERENCE_MODEL, "ElasticNet"] + + def test_round_trip_preserves_structure(self, tmp_path) -> None: + directory = UPath(tmp_path) / "experiment" + experiment = make_experiment_result(n_models=3, n_folds=2) + + experiment.save(directory) + loaded = ExperimentResult.load(directory) + + assert loaded.model_names == experiment.model_names + assert loaded.max_folds == 2 + assert loaded.split_mode == experiment.split_mode + assert loaded.normalized_by is None + + def test_round_trip_preserves_the_reference_model(self, tmp_path) -> None: + directory = UPath(tmp_path) / "experiment" + make_experiment_result(n_models=2, n_folds=1).normalize().save(directory) + + assert ExperimentResult.load(directory).normalized_by == REFERENCE_MODEL + + def test_load_backfills_a_missing_split_mode_from_metadata(self, tmp_path) -> None: + directory = UPath(tmp_path) / "experiment" + runs = [ + make_run_result(model_name="A", split_mode="LTO"), + make_run_result(model_name="B", split_mode=""), + ] + ExperimentResult(runs).save(directory) + + loaded = ExperimentResult.load(directory) + + assert {r.split_mode for m in loaded.models for r in m.runs} == {"LTO"} + + def test_accepts_a_plain_string_directory(self, tmp_path) -> None: + directory = str(UPath(tmp_path) / "experiment") + + make_experiment_result(n_models=1, n_folds=1).save(directory) + + assert ExperimentResult.load(directory).n_models == 1 + + +class TestTrialSkipping: + """The report path loads without trials; every other caller keeps the default.""" + + @staticmethod + def _saved_with_trials(directory: UPath) -> None: + experiment = make_experiment_result(n_models=2, n_folds=1) + for model in experiment.models: + for run in model.runs: + run.trials = [ + TrialResult( + hyperparameters={"alpha": 0.1}, + metrics={"MSE": 0.3}, + optimization_metric="MSE", + predictions=np.zeros(4), + ) + ] + experiment.save(directory) + + def test_trials_are_loaded_by_default(self, tmp_path) -> None: + directory = UPath(tmp_path) / "experiment" + self._saved_with_trials(directory) + + loaded = ExperimentResult.load(directory) + + assert all(run.trials for model in loaded.models for run in model.runs) + + def test_trials_can_be_skipped(self, tmp_path) -> None: + directory = UPath(tmp_path) / "experiment" + self._saved_with_trials(directory) + + loaded = ExperimentResult.load(directory, with_trials=False) + + assert all(run.trials is None for model in loaded.models for run in model.runs) + + def test_skipping_trials_preserves_the_structure(self, tmp_path) -> None: + directory = UPath(tmp_path) / "experiment" + self._saved_with_trials(directory) + + loaded = ExperimentResult.load(directory, with_trials=False) + + assert loaded.model_names == [REFERENCE_MODEL, "ElasticNet"] + assert loaded.max_folds == 1 + + +class TestLoadLogging: + """The load summary is the line that establishes the scale of a run.""" + + def test_reports_models_runs_rows_and_bytes(self, tmp_path, caplog) -> None: + directory = UPath(tmp_path) / "experiment" + make_experiment_result(n_models=2, n_folds=2, n_pairs=20).save(directory) + + with caplog.at_level(logging.INFO, logger="drevalpy.types.results.experiment"): + ExperimentResult.load(directory) + + message = caplog.records[-1].getMessage() + assert "2 models" in message + assert "4 runs" in message + assert "80 prediction rows" in message + assert "GB of arrays" in message + + def test_records_whether_trials_were_skipped(self, tmp_path, caplog) -> None: + directory = UPath(tmp_path) / "experiment" + make_experiment_result(n_models=1, n_folds=1).save(directory) + + with caplog.at_level(logging.INFO, logger="drevalpy.types.results.experiment"): + ExperimentResult.load(directory, with_trials=False) + + assert "trials skipped" in caplog.records[-1].getMessage() + + def test_records_when_trials_were_loaded(self, tmp_path, caplog) -> None: + directory = UPath(tmp_path) / "experiment" + make_experiment_result(n_models=1, n_folds=1).save(directory) + + with caplog.at_level(logging.INFO, logger="drevalpy.types.results.experiment"): + ExperimentResult.load(directory) + + assert "trials loaded" in caplog.records[-1].getMessage() + + def test_counts_trial_predictions_in_the_byte_total(self, tmp_path) -> None: + run = make_run_result(n_pairs=8) + without = _run_array_bytes(run) + run.trials = [ + TrialResult( + hyperparameters={}, + metrics={}, + optimization_metric="MSE", + predictions=np.zeros(1000), + ) + ] + + assert _run_array_bytes(run) > without + + def test_interned_ids_count_their_shared_strings(self) -> None: + run = make_run_result(n_pairs=8) + run.cell_line_ids = intern_ids(run.cell_line_ids) + run.drug_ids = intern_ids(run.drug_ids) + + assert _run_array_bytes(run) > run.predictions.nbytes + run.ground_truth.nbytes + + +class TestRepr: + def test_reports_experiment_level_metadata(self) -> None: + text = repr(make_experiment_result(n_models=2, n_folds=2, split_mode="LCO")) + + assert text.startswith("ExperimentResult") + assert "Dataset: SyntheticDataset" in text + assert "Split mode: LCO" in text + assert "Normalized by: None" in text + assert "Models: 2" in text + + def test_reports_metrics_per_model(self) -> None: + runs = [make_run_result(model_name="A", metrics={"MSE": 0.5})] + + assert " A (1 folds): MSE=0.5000" in repr(ExperimentResult(runs)).splitlines() + + def test_reports_models_without_metrics(self) -> None: + runs = [make_run_result(model_name="A", metrics={})] + + assert " A (1 folds): no metrics" in repr(ExperimentResult(runs)).splitlines() diff --git a/tests/types/results/test_init.py b/tests/types/results/test_init.py new file mode 100644 index 000000000..619caf5d9 --- /dev/null +++ b/tests/types/results/test_init.py @@ -0,0 +1,20 @@ +"""Tests for the public surface of the results package.""" + +from __future__ import annotations + +from drevalpy.types import results +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.types.results.model import ModelResult +from drevalpy.types.results.run import RunResult +from drevalpy.types.results.trial import TrialResult + + +def test_all_lists_every_result_type() -> None: + assert sorted(results.__all__) == ["ExperimentResult", "ModelResult", "RunResult", "TrialResult"] + + +def test_re_exports_are_the_defining_classes() -> None: + assert results.ExperimentResult is ExperimentResult + assert results.ModelResult is ModelResult + assert results.RunResult is RunResult + assert results.TrialResult is TrialResult diff --git a/tests/types/results/test_model.py b/tests/types/results/test_model.py new file mode 100644 index 000000000..f3c56bd0a --- /dev/null +++ b/tests/types/results/test_model.py @@ -0,0 +1,197 @@ +"""Tests for the model-level result that aggregates runs across folds.""" + +from __future__ import annotations + +import json + +import numpy as np +import pytest +from upath import UPath + +from drevalpy.types.results.model import ModelResult +from drevalpy.types.results.trial import TrialResult +from tests.synthetic import make_model_result, make_run_result + + +class TestFoldCount: + def test_counts_the_contained_runs(self) -> None: + assert make_model_result(n_folds=4).n_folds == 4 + + def test_is_zero_without_runs(self) -> None: + assert ModelResult(model_name="ElasticNet", dataset_name="SyntheticDataset").n_folds == 0 + + +class TestAggregateMetrics: + def test_is_empty_without_runs(self) -> None: + result = ModelResult(model_name="ElasticNet", dataset_name="SyntheticDataset") + + assert result.aggregate_metrics == {} + + def test_reports_mean_and_std_across_folds(self) -> None: + result = ModelResult( + model_name="ElasticNet", + dataset_name="SyntheticDataset", + runs=[ + make_run_result(fold_index=0, metrics={"MSE": 0.2}), + make_run_result(fold_index=1, metrics={"MSE": 0.4}), + ], + ) + + assert result.aggregate_metrics["MSE"]["mean"] == pytest.approx(0.3) + assert result.aggregate_metrics["MSE"]["std"] == pytest.approx(0.1) + + def test_std_is_zero_for_a_single_fold(self) -> None: + result = ModelResult( + model_name="ElasticNet", + dataset_name="SyntheticDataset", + runs=[make_run_result(metrics={"MSE": 0.7})], + ) + + assert result.aggregate_metrics["MSE"] == {"mean": pytest.approx(0.7), "std": pytest.approx(0.0)} + + def test_unions_metric_keys_across_folds(self) -> None: + result = ModelResult( + model_name="ElasticNet", + dataset_name="SyntheticDataset", + runs=[ + make_run_result(fold_index=0, metrics={"MSE": 0.2}), + make_run_result(fold_index=1, metrics={"MSE": 0.4, "Pearson": 0.9}), + ], + ) + + assert set(result.aggregate_metrics) == {"MSE", "Pearson"} + assert result.aggregate_metrics["Pearson"]["mean"] == pytest.approx(0.9) + + def test_covers_every_metric_the_builder_emits(self) -> None: + result = make_model_result(n_folds=3) + + assert set(result.aggregate_metrics) == set(result.runs[0].metrics) + + +class TestPersistence: + def test_save_creates_missing_parent_directories(self, tmp_path) -> None: + directory = UPath(tmp_path) / "nested" / "ElasticNet" + + make_model_result(n_folds=1).save(directory) + + assert (directory / "metadata.json").is_file() + + def test_save_writes_one_npz_per_fold(self, tmp_path) -> None: + directory = UPath(tmp_path) / "ElasticNet" + + make_model_result(n_folds=3).save(directory) + + assert sorted(p.name for p in directory.glob("fold_*.npz")) == [ + "fold_0.npz", + "fold_1.npz", + "fold_2.npz", + ] + + def test_save_records_aggregates_in_metadata(self, tmp_path) -> None: + directory = UPath(tmp_path) / "ElasticNet" + result = make_model_result(n_folds=2) + + result.save(directory) + meta = json.loads((directory / "metadata.json").read_text()) + + assert meta["model_name"] == "ElasticNet" + assert meta["dataset_name"] == "SyntheticDataset" + assert meta["n_folds"] == 2 + assert meta["aggregate_metrics"]["MSE"]["mean"] == pytest.approx(result.aggregate_metrics["MSE"]["mean"]) + + def test_round_trip_preserves_identity_and_folds(self, tmp_path) -> None: + directory = UPath(tmp_path) / "RandomForest" + result = make_model_result(model_name="RandomForest", n_folds=3) + + result.save(directory) + loaded = ModelResult.load(directory) + + assert loaded.model_name == "RandomForest" + assert loaded.dataset_name == "SyntheticDataset" + assert loaded.n_folds == 3 + + def test_round_trip_preserves_fold_order(self, tmp_path) -> None: + directory = UPath(tmp_path) / "ElasticNet" + result = make_model_result(n_folds=3) + + result.save(directory) + loaded = ModelResult.load(directory) + + assert [r.fold_index for r in loaded.runs] == [0, 1, 2] + np.testing.assert_allclose(loaded.runs[1].predictions, result.runs[1].predictions) + + def test_round_trip_of_an_empty_result_yields_no_runs(self, tmp_path) -> None: + directory = UPath(tmp_path) / "ElasticNet" + ModelResult(model_name="ElasticNet", dataset_name="SyntheticDataset").save(directory) + + assert ModelResult.load(directory).runs == [] + + def test_accepts_a_plain_string_directory(self, tmp_path) -> None: + directory = str(UPath(tmp_path) / "ElasticNet") + + make_model_result(n_folds=1).save(directory) + + assert ModelResult.load(directory).n_folds == 1 + + +class TestTrialSkipping: + """``with_trials`` is forwarded to every fold so the report can skip trial arrays.""" + + @staticmethod + def _with_trials(directory: UPath) -> None: + result = make_model_result(n_folds=2) + for run in result.runs: + run.trials = [ + TrialResult( + hyperparameters={"alpha": 0.1}, + metrics={"MSE": 0.3}, + optimization_metric="MSE", + predictions=np.zeros(4), + ) + ] + result.save(directory) + + def test_trials_are_loaded_by_default(self, tmp_path) -> None: + directory = UPath(tmp_path) / "ElasticNet" + self._with_trials(directory) + + assert all(run.trials for run in ModelResult.load(directory).runs) + + def test_trials_can_be_skipped_for_every_fold(self, tmp_path) -> None: + directory = UPath(tmp_path) / "ElasticNet" + self._with_trials(directory) + + assert all(run.trials is None for run in ModelResult.load(directory, with_trials=False).runs) + + def test_skipping_trials_keeps_the_fold_count(self, tmp_path) -> None: + directory = UPath(tmp_path) / "ElasticNet" + self._with_trials(directory) + + assert ModelResult.load(directory, with_trials=False).n_folds == 2 + + +class TestRepr: + def test_reports_identity_and_fold_count(self) -> None: + text = repr(make_model_result(model_name="RandomForest", n_folds=2)) + + assert text.startswith("ModelResult") + assert "Model: RandomForest" in text + assert "Dataset: SyntheticDataset" in text + assert "Folds: 2" in text + + def test_formats_metrics_as_mean_plus_minus_std(self) -> None: + result = ModelResult( + model_name="ElasticNet", + dataset_name="SyntheticDataset", + runs=[ + make_run_result(fold_index=0, metrics={"MSE": 0.2}), + make_run_result(fold_index=1, metrics={"MSE": 0.4}), + ], + ) + + assert " MSE: 0.3000 +/- 0.1000" in repr(result).splitlines() + + def test_omits_the_metric_block_without_runs(self) -> None: + result = ModelResult(model_name="ElasticNet", dataset_name="SyntheticDataset") + + assert "Metrics" not in repr(result) diff --git a/tests/types/results/test_run.py b/tests/types/results/test_run.py new file mode 100644 index 000000000..a0f939334 --- /dev/null +++ b/tests/types/results/test_run.py @@ -0,0 +1,306 @@ +"""Tests for the per-fold run result dataclass and its npz round-trip.""" + +from __future__ import annotations + +import json + +import numpy as np +import pytest +from upath import UPath + +from drevalpy.types.results.run import RunResult, intern_ids +from drevalpy.types.results.trial import TrialResult +from tests.synthetic import make_metrics, make_run_result + + +def _trial(seed: int) -> TrialResult: + return TrialResult( + hyperparameters={"alpha": 0.1 * seed}, + metrics={"MSE": 0.5 - 0.1 * seed}, + optimization_metric="MSE", + predictions=np.full(4, float(seed)), + ) + + +class TestDefaults: + def test_optional_metadata_defaults_to_empty(self) -> None: + run = RunResult( + model_name="ElasticNet", + dataset_name="SyntheticDataset", + fold_index=0, + predictions=np.zeros(3), + ground_truth=np.zeros(3), + cell_line_ids=np.array(["CL_0", "CL_1", "CL_2"]), + drug_ids=np.array(["D_0", "D_1", "D_2"]), + ) + + assert run.split_mode == "" + assert run.fold_id == "" + assert run.best_hyperparameters == {} + assert run.metrics == {} + assert run.fold_metadata == {} + assert run.trials is None + assert run.randomization is None + + def test_builder_derives_fold_id_from_fold_index(self) -> None: + assert make_run_result(fold_index=2).fold_id == "fold_2" + + def test_builder_metrics_cover_every_reported_metric(self) -> None: + assert make_run_result().metrics.keys() == make_metrics().keys() + + +class TestRepr: + def test_reports_model_and_dataset(self) -> None: + text = repr(make_run_result(model_name="RandomForest")) + + assert text.startswith("RunResult") + assert "Model: RandomForest" in text + assert "Dataset: SyntheticDataset" in text + + def test_reports_absent_randomization_explicitly(self) -> None: + assert "Randomization: None" in repr(make_run_result()) + + def test_reports_randomization_mode_and_view(self) -> None: + run = make_run_result(randomization=("SVRC", "gene_expression")) + + assert "Randomization: SVRC (gene_expression)" in repr(run) + + def test_omits_fold_index_from_the_metadata_block(self) -> None: + run = make_run_result(fold_index=1, fold_metadata={"fold_index": 1, "robustness_trial": 3}) + + lines = repr(run).splitlines() + + assert " robustness_trial: 3" in lines + assert " fold_index: 1" not in lines + + def test_counts_only_non_nan_ground_truth(self) -> None: + run = make_run_result(n_pairs=5) + run.ground_truth = np.array([1.0, np.nan, 3.0, np.nan, 5.0]) + + assert "Ground truth: 3 non-NaN values" in repr(run) + + def test_reports_pair_count(self) -> None: + assert "Predictions: 7 pairs" in repr(make_run_result(n_pairs=7)) + + def test_lists_hyperparameters_and_metrics(self) -> None: + run = make_run_result(best_hyperparameters={"alpha": 0.25}, metrics={"MSE": 0.5}) + + lines = repr(run).splitlines() + + assert " Hyperparameters:" in lines + assert " alpha: 0.25" in lines + assert " MSE: 0.5000" in lines + + def test_omits_empty_hyperparameter_and_metric_blocks(self) -> None: + run = RunResult( + model_name="ElasticNet", + dataset_name="SyntheticDataset", + fold_index=0, + predictions=np.zeros(3), + ground_truth=np.zeros(3), + cell_line_ids=np.array(["CL_0", "CL_1", "CL_2"]), + drug_ids=np.array(["D_0", "D_1", "D_2"]), + ) + + text = repr(run) + + assert "Hyperparameters:" not in text + assert "Metrics:" not in text + + def test_reports_trial_count_when_present(self) -> None: + run = make_run_result() + run.trials = [_trial(0), _trial(1)] + + assert "HPO Trials: 2" in repr(run) + + def test_omits_trial_line_without_trials(self) -> None: + assert "HPO Trials" not in repr(make_run_result()) + + +class TestPersistence: + def test_round_trip_preserves_arrays(self, tmp_path) -> None: + run = make_run_result(n_pairs=9) + path = UPath(tmp_path) / "fold_0.npz" + + run.save(path) + loaded = RunResult.load(path) + + np.testing.assert_allclose(loaded.predictions, run.predictions) + np.testing.assert_allclose(loaded.ground_truth, run.ground_truth) + np.testing.assert_array_equal(loaded.cell_line_ids, run.cell_line_ids) + np.testing.assert_array_equal(loaded.drug_ids, run.drug_ids) + + def test_round_trip_preserves_scalar_metadata(self, tmp_path) -> None: + run = make_run_result(model_name="RandomForest", fold_index=2, split_mode="LCO") + path = UPath(tmp_path) / "fold_2.npz" + + run.save(path) + loaded = RunResult.load(path) + + assert loaded.model_name == "RandomForest" + assert loaded.dataset_name == run.dataset_name + assert loaded.split_mode == "LCO" + assert loaded.fold_index == 2 + assert loaded.fold_id == "fold_2" + + def test_round_trip_preserves_metric_and_hyperparameter_dicts(self, tmp_path) -> None: + run = make_run_result(best_hyperparameters={"alpha": 0.25}, fold_metadata={"note": "kept"}) + path = UPath(tmp_path) / "fold.npz" + + run.save(path) + loaded = RunResult.load(path) + + assert loaded.metrics == pytest.approx(run.metrics) + assert loaded.best_hyperparameters == {"alpha": 0.25} + assert loaded.fold_metadata == {"note": "kept"} + + def test_round_trip_restores_randomization_as_a_tuple(self, tmp_path) -> None: + run = make_run_result(randomization=("SVRC", "gene_expression")) + path = UPath(tmp_path) / "fold.npz" + + run.save(path) + loaded = RunResult.load(path) + + assert loaded.randomization == ("SVRC", "gene_expression") + + def test_round_trip_keeps_randomization_none(self, tmp_path) -> None: + path = UPath(tmp_path) / "fold.npz" + make_run_result().save(path) + + assert RunResult.load(path).randomization is None + + def test_round_trip_restores_every_trial(self, tmp_path) -> None: + run = make_run_result() + run.trials = [_trial(0), _trial(1)] + path = UPath(tmp_path) / "fold.npz" + + run.save(path) + loaded = RunResult.load(path) + + assert loaded.trials is not None + assert [t.hyperparameters for t in loaded.trials] == [t.hyperparameters for t in run.trials] + assert [t.optimization_metric for t in loaded.trials] == ["MSE", "MSE"] + np.testing.assert_allclose(loaded.trials[1].predictions, np.full(4, 1.0)) + + def test_round_trip_keeps_trials_none(self, tmp_path) -> None: + path = UPath(tmp_path) / "fold.npz" + make_run_result().save(path) + + assert RunResult.load(path).trials is None + + def test_empty_trial_list_is_not_serialized(self, tmp_path) -> None: + run = make_run_result() + run.trials = [] + path = UPath(tmp_path) / "fold.npz" + + run.save(path) + + with np.load(path, allow_pickle=False) as data: + assert json.loads(str(data["_metadata"]))["trials"] is None + + def test_accepts_a_plain_string_path(self, tmp_path) -> None: + path = str(UPath(tmp_path) / "fold.npz") + make_run_result(fold_index=1).save(path) + + assert RunResult.load(path).fold_index == 1 + + def test_entity_ids_are_stored_as_strings(self, tmp_path) -> None: + run = make_run_result(n_pairs=3) + run.cell_line_ids = np.array([1, 2, 3]) + path = UPath(tmp_path) / "fold.npz" + + run.save(path) + + np.testing.assert_array_equal(RunResult.load(path).cell_line_ids, np.array(["1", "2", "3"])) + + def test_metadata_blob_is_json(self, tmp_path) -> None: + path = UPath(tmp_path) / "fold.npz" + make_run_result(split_mode="LDO").save(path) + + with np.load(path, allow_pickle=False) as data: + meta = json.loads(str(data["_metadata"])) + + assert meta["split_mode"] == "LDO" + assert meta["fold_id"] == "fold_0" + + +class TestTrialSkipping: + """``with_trials=False`` lets the report path avoid the largest arrays in the file.""" + + def test_trials_are_loaded_by_default(self, tmp_path) -> None: + run = make_run_result() + run.trials = [_trial(0), _trial(1)] + path = UPath(tmp_path) / "fold.npz" + run.save(path) + + assert RunResult.load(path).trials is not None + + def test_trials_can_be_skipped(self, tmp_path) -> None: + run = make_run_result() + run.trials = [_trial(0), _trial(1)] + path = UPath(tmp_path) / "fold.npz" + run.save(path) + + assert RunResult.load(path, with_trials=False).trials is None + + def test_skipping_trials_leaves_the_fold_arrays_intact(self, tmp_path) -> None: + run = make_run_result(n_pairs=6) + run.trials = [_trial(0)] + path = UPath(tmp_path) / "fold.npz" + run.save(path) + + loaded = RunResult.load(path, with_trials=False) + + np.testing.assert_allclose(loaded.predictions, run.predictions) + np.testing.assert_array_equal(loaded.drug_ids, run.drug_ids) + + +class TestInternIds: + """Entity ids are deduplicated at load; ``<U40`` costs 160 B per element otherwise.""" + + def test_values_are_preserved(self) -> None: + ids = np.array(["CL_0", "CL_1", "CL_0"], dtype="<U40") + + np.testing.assert_array_equal(intern_ids(ids), ids) + + def test_the_result_is_an_object_array(self) -> None: + assert intern_ids(np.array(["CL_0", "CL_1"])).dtype == object + + def test_indexing_still_yields_a_str(self) -> None: + assert intern_ids(np.array(["CL_0"]))[0] == "CL_0" + assert isinstance(intern_ids(np.array(["CL_0"]))[0], str) + + def test_repeated_ids_share_one_string_object(self) -> None: + interned = intern_ids(np.array(["CL_0"] * 5)) + + assert len({id(value) for value in interned.tolist()}) == 1 + + def test_an_empty_array_stays_empty(self) -> None: + assert intern_ids(np.array([], dtype="<U40")).shape == (0,) + + def test_the_pointer_table_is_smaller_than_fixed_width_storage(self) -> None: + ids = np.array([f"BRD-K{i:035d}" for i in range(4)] * 250, dtype="<U40") + + assert intern_ids(ids).nbytes < ids.nbytes + + def test_load_interns_both_id_arrays(self, tmp_path) -> None: + path = UPath(tmp_path) / "fold.npz" + make_run_result(n_pairs=6).save(path) + + loaded = RunResult.load(path) + + assert loaded.cell_line_ids.dtype == object + assert loaded.drug_ids.dtype == object + + def test_the_save_load_round_trip_stays_lossless_through_interning(self, tmp_path) -> None: + run = make_run_result(n_pairs=8, n_cell_lines=3, n_drugs=2) + first = UPath(tmp_path) / "a.npz" + second = UPath(tmp_path) / "b.npz" + + run.save(first) + once = RunResult.load(first) + once.save(second) + twice = RunResult.load(second) + + np.testing.assert_array_equal(once.cell_line_ids, run.cell_line_ids) + np.testing.assert_array_equal(twice.drug_ids, run.drug_ids) diff --git a/tests/types/results/test_trial.py b/tests/types/results/test_trial.py new file mode 100644 index 000000000..93856fbb1 --- /dev/null +++ b/tests/types/results/test_trial.py @@ -0,0 +1,61 @@ +"""Tests for the HPO trial result dataclass.""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np + +from drevalpy.types.results.trial import TrialResult + + +def _trial(**overrides: Any) -> TrialResult: + kwargs: dict[str, Any] = { + "hyperparameters": {"alpha": 0.5}, + "metrics": {"MSE": 0.25, "Pearson": 0.75}, + "optimization_metric": "MSE", + "predictions": np.array([1.0, 2.0, 3.0]), + } + kwargs.update(overrides) + return TrialResult(**kwargs) + + +def test_score_returns_value_of_optimization_metric() -> None: + trial = _trial(optimization_metric="Pearson") + + assert trial.score == 0.75 + + +def test_score_falls_back_to_nan_when_metric_missing() -> None: + trial = _trial(optimization_metric="Kendall") + + assert math.isnan(trial.score) + + +def test_score_is_nan_for_empty_metrics() -> None: + trial = _trial(metrics={}) + + assert math.isnan(trial.score) + + +def test_repr_lists_every_hyperparameter() -> None: + trial = _trial(hyperparameters={"alpha": 0.5, "l1_ratio": 0.1}) + + text = repr(trial) + + assert "alpha: 0.5" in text + assert "l1_ratio: 0.1" in text + + +def test_repr_marks_only_the_optimization_metric() -> None: + trial = _trial(optimization_metric="MSE") + + lines = repr(trial).splitlines() + + assert " MSE: 0.2500 *" in lines + assert " Pearson: 0.7500" in lines + + +def test_repr_starts_with_the_type_name() -> None: + assert repr(_trial()).startswith("TrialResult") diff --git a/tests/types/test_init.py b/tests/types/test_init.py new file mode 100644 index 000000000..b4717a0a7 --- /dev/null +++ b/tests/types/test_init.py @@ -0,0 +1,53 @@ +"""Tests for the public :mod:`drevalpy.types` package surface. + +:mod:`drevalpy.types` is the shared vocabulary roughly fifty other modules import +from, so its ``__all__`` is a compatibility promise: a rename that forgets the +barrel breaks every one of those dependents at import time. Only the re-export +surface is pinned here - each type's behaviour is tested beside its defining +module. + +Origins are recorded against the *leaf* module rather than the intermediate +``types.data`` / ``types.enums`` / ``types.results`` barrels, because comparing a +re-export with the sibling barrel it was imported from cannot fail. The four +assertions themselves live in ``tests/_barrel_surface.py``. +""" + +from __future__ import annotations + +from drevalpy import types +from tests._barrel_surface import DeclaredSurface + +#: ``exported name -> module that defines it``. +EXPECTED_ORIGINS: dict[str, str] = { + "BlockSpec": "drevalpy.types.data.batch.feature_block", + "CellLineFeatureSource": "drevalpy.types.data.feature_source", + "Dataset": "drevalpy.types.data.dataset", + "DrugFeatureSource": "drevalpy.types.data.feature_source", + "ExperimentResult": "drevalpy.types.results.experiment", + "FeatureBlock": "drevalpy.types.data.batch.feature_block", + "FeatureSource": "drevalpy.types.data.feature_source", + "LiteratureReference": "drevalpy.types.enums.literature_reference", + "ModelInputBatch": "drevalpy.types.data.batch.model_input_batch", + "ModelResult": "drevalpy.types.results.model", + "ModelScope": "drevalpy.types.enums.model_scope", + "MuDataLike": "drevalpy.types.data.mudatalike", + "PredictionMode": "drevalpy.types.enums.prediction_mode", + "ResponseBatch": "drevalpy.types.data.batch.response_batch", + "RunResult": "drevalpy.types.results.run", + "SplitMask": "drevalpy.types.data.split_mask", + "SplitMasks": "drevalpy.types.data.split_masks", + "TrialResult": "drevalpy.types.results.trial", + "build_model_input_batch": "drevalpy.types.data.batch.model_input_build", + "graph_feature_block": "drevalpy.types.data.batch.feature_block", + "merge_feature_blocks": "drevalpy.types.data.batch.feature_block", + "metadata_feature_block": "drevalpy.types.data.batch.feature_block", + "numeric_feature_block": "drevalpy.types.data.batch.feature_block", + "pair_cell_line_indices": "drevalpy.types.data.batch.model_input_batch", + "pair_drug_indices": "drevalpy.types.data.batch.model_input_batch", + "ragged_feature_block": "drevalpy.types.data.batch.feature_block", +} + + +class TestTypesSurface(DeclaredSurface): + barrel = types + origins = EXPECTED_ORIGINS diff --git a/tests/utils/test_init.py b/tests/utils/test_init.py new file mode 100644 index 000000000..9dfa10591 --- /dev/null +++ b/tests/utils/test_init.py @@ -0,0 +1,18 @@ +"""Tests for the public surface of the utils package.""" + +from __future__ import annotations + +from drevalpy import utils +from drevalpy.utils.response_transform import fit_response_transformation, get_response_transformation + + +def test_all_lists_the_documented_surface() -> None: + assert utils.__all__ == ["fit_response_transformation", "get_response_transformation"] + + +def test_re_export_is_the_defining_function() -> None: + assert utils.get_response_transformation is get_response_transformation + + +def test_the_fitting_helper_is_re_exported() -> None: + assert utils.fit_response_transformation is fit_response_transformation diff --git a/tests/utils/test_response_transform.py b/tests/utils/test_response_transform.py new file mode 100644 index 000000000..487704e68 --- /dev/null +++ b/tests/utils/test_response_transform.py @@ -0,0 +1,55 @@ +"""Tests for the sklearn response-transformation lookup.""" + +from __future__ import annotations + +import numpy as np +import pytest +from sklearn.preprocessing import MinMaxScaler, RobustScaler, StandardScaler + +from drevalpy.utils.response_transform import get_response_transformation + + +@pytest.mark.parametrize( + "option", + [pytest.param("None", id="literal-none-string"), pytest.param(None, id="none-object")], +) +def test_no_transformation_requested_returns_none(option: str | None) -> None: + assert get_response_transformation(option) is None + + +@pytest.mark.parametrize( + ("option", "expected"), + [ + pytest.param("standard", StandardScaler, id="standard"), + pytest.param("minmax", MinMaxScaler, id="minmax"), + pytest.param("robust", RobustScaler, id="robust"), + ], +) +def test_known_options_return_the_matching_scaler(option: str, expected: type) -> None: + assert isinstance(get_response_transformation(option), expected) + + +def test_each_call_returns_a_fresh_unfitted_transformer() -> None: + first = get_response_transformation("standard") + second = get_response_transformation("standard") + + assert first is not second + assert not hasattr(first, "mean_") + + +def test_returned_transformer_is_usable() -> None: + transformer = get_response_transformation("minmax") + + scaled = transformer.fit_transform(np.array([[0.0], [5.0], [10.0]])) + + np.testing.assert_allclose(scaled.ravel(), [0.0, 0.5, 1.0]) + + +def test_unknown_option_is_rejected() -> None: + with pytest.raises(ValueError, match="Unknown response transformation quantile"): + get_response_transformation("quantile") + + +def test_rejection_message_lists_the_valid_options() -> None: + with pytest.raises(ValueError, match="'None', 'standard', 'minmax', 'robust'"): + get_response_transformation("Standard") diff --git a/tests/utils/test_seed.py b/tests/utils/test_seed.py new file mode 100644 index 000000000..9c064cce5 --- /dev/null +++ b/tests/utils/test_seed.py @@ -0,0 +1,145 @@ +"""Tests for the global RNG seeding helper.""" + +from __future__ import annotations + +import os +import random +import sys +from collections.abc import Iterator + +import numpy as np +import pytest +import torch + +from drevalpy.utils.seed import seed_everything + + +class _FakeCuda: + """Records ``manual_seed_all`` calls without needing a GPU.""" + + def __init__(self, *, available: bool) -> None: + self._available = available + self.seeds: list[int] = [] + + def is_available(self) -> bool: + return self._available + + def manual_seed_all(self, seed: int) -> None: + self.seeds.append(seed) + + +class _FakeTorch: + """Stand-in for ``torch`` isolating the CUDA branch. + + Patching ``torch.cuda.manual_seed_all`` in place is not enough: real + ``torch.manual_seed`` calls it itself, so the branch under test cannot be + observed independently. + """ + + def __init__(self, *, cuda_available: bool) -> None: + self.seeds: list[int] = [] + self.cuda = _FakeCuda(available=cuda_available) + + def manual_seed(self, seed: int) -> None: + self.seeds.append(seed) + + +@pytest.fixture(autouse=True) +def _restore_global_rng_state(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Undo the process-wide state ``seed_everything`` deliberately mutates.""" + monkeypatch.setenv("PYTHONHASHSEED", os.environ.get("PYTHONHASHSEED", "0")) + python_state = random.getstate() + numpy_state = np.random.get_state() + torch_state = torch.get_rng_state() + yield + random.setstate(python_state) + np.random.set_state(numpy_state) + torch.set_rng_state(torch_state) + + +def test_exports_the_hash_seed() -> None: + seed_everything(7) + + assert os.environ["PYTHONHASHSEED"] == "7" + + +def test_defaults_to_forty_two() -> None: + seed_everything() + + assert os.environ["PYTHONHASHSEED"] == "42" + + +def test_python_random_is_reproducible() -> None: + seed_everything(3) + first = random.getstate() + + seed_everything(3) + + assert random.getstate() == first + + +def test_numpy_legacy_random_is_reproducible() -> None: + seed_everything(3) + first = np.random.rand(4) + + seed_everything(3) + + np.testing.assert_array_equal(np.random.rand(4), first) + + +def test_torch_random_is_reproducible() -> None: + seed_everything(3) + first = torch.rand(4) + + seed_everything(3) + + assert torch.equal(torch.rand(4), first) + + +def test_distinct_seeds_produce_distinct_streams() -> None: + seed_everything(1) + first = np.random.rand(4) + + seed_everything(2) + + assert not np.array_equal(np.random.rand(4), first) + + +def test_distinct_seeds_reach_the_python_backend() -> None: + seed_everything(1) + first = random.getstate() + + seed_everything(2) + + assert random.getstate() != first + + +def test_seeds_every_backend_in_one_call() -> None: + seed_everything(11) + expected = (random.getstate(), np.random.rand(), torch.rand(1).item()) + + seed_everything(11) + + assert (random.getstate(), np.random.rand(), torch.rand(1).item()) == expected + + +def test_cuda_is_seeded_when_available(monkeypatch: pytest.MonkeyPatch) -> None: + fake_torch = _FakeTorch(cuda_available=True) + # ``seed_everything`` imports ``torch`` inside the function (to keep it off the + # ``import drevalpy`` path), so the fake has to be installed in ``sys.modules`` + # rather than as a module attribute. + monkeypatch.setitem(sys.modules, "torch", fake_torch) + + seed_everything(5) + + assert fake_torch.cuda.seeds == [5] + + +def test_cuda_is_skipped_when_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + fake_torch = _FakeTorch(cuda_available=False) + monkeypatch.setitem(sys.modules, "torch", fake_torch) + + seed_everything(5) + + assert fake_torch.seeds == [5] + assert fake_torch.cuda.seeds == [] diff --git a/tests/utils/test_torch_io.py b/tests/utils/test_torch_io.py new file mode 100644 index 000000000..71f5234b6 --- /dev/null +++ b/tests/utils/test_torch_io.py @@ -0,0 +1,99 @@ +"""Tests for the trusted PyTorch serialization boundary.""" + +from __future__ import annotations + +import io +import pickle +from pathlib import Path + +import pytest +import torch +from torch_geometric.data import Data + +from drevalpy.utils.torch_io import ( + load_state_dict, + load_torch_payload, + load_trusted_mapping, + load_trusted_payload, + save_torch_payload, + save_trusted_mapping, +) +from drevalpy.utils.torch_io import ( + load_state_dict as load_state_dict_bytes, +) +from drevalpy.utils.torch_io import ( + save_state_dict as save_state_dict_bytes, +) + + +def test_state_dict_bytes_round_trip() -> None: + state = {"layer.weight": torch.tensor([1.0, 2.0])} + loaded = load_state_dict_bytes(save_state_dict_bytes(state)) + assert torch.equal(loaded["layer.weight"], state["layer.weight"]) + + +def test_trusted_mapping_bytes_round_trip() -> None: + payload = {"hyperparameters": {"epochs": 3}, "value": torch.tensor(4.0)} + loaded = load_trusted_mapping(save_trusted_mapping(payload)) + assert loaded["hyperparameters"] == {"epochs": 3} + value = loaded["value"] + expected = payload["value"] + assert isinstance(value, torch.Tensor) + assert isinstance(expected, torch.Tensor) + assert torch.equal(value, expected) + + +def test_state_dict_rejects_non_mapping_payload() -> None: + buffer = io.BytesIO() + save_torch_payload(torch.tensor([1.0, 2.0]), buffer) + with pytest.raises(TypeError, match="state dict mapping"): + load_state_dict(buffer.getvalue()) + + +def test_trusted_mapping_rejects_non_mapping_payload() -> None: + buffer = io.BytesIO() + save_torch_payload(torch.tensor([1.0, 2.0]), buffer) + with pytest.raises(TypeError, match="mapping"): + load_trusted_mapping(buffer.getvalue()) + + +def test_load_state_dict_from_path(tmp_path: Path) -> None: + checkpoint_path = tmp_path / "model.pt" + state = {"layer.bias": torch.tensor([0.5])} + save_torch_payload(state, checkpoint_path) + loaded = load_state_dict(checkpoint_path) + assert torch.equal(loaded["layer.bias"], state["layer.bias"]) + + +def test_load_state_dict_from_string_path(tmp_path: Path) -> None: + checkpoint_path = tmp_path / "model.pt" + state = {"layer.bias": torch.tensor([0.5])} + save_torch_payload(state, checkpoint_path) + loaded = load_state_dict(str(checkpoint_path)) + assert torch.equal(loaded["layer.bias"], state["layer.bias"]) + + +def test_load_state_dict_honors_map_location(tmp_path: Path) -> None: + checkpoint_path = tmp_path / "model.pt" + state = {"layer.weight": torch.tensor([1.0], device="cpu")} + save_torch_payload(state, checkpoint_path) + loaded = load_state_dict(checkpoint_path, map_location="cpu") + assert loaded["layer.weight"].device.type == "cpu" + + +def test_load_trusted_payload_restores_graph_objects(tmp_path: Path) -> None: + graph = Data( + x=torch.ones((2, 3)), + edge_index=torch.tensor([[0], [1]], dtype=torch.long), + ) + graph_path = tmp_path / "graph.pt" + save_torch_payload(graph, graph_path) + loaded = load_trusted_payload(graph_path) + assert isinstance(loaded, Data) + assert torch.equal(loaded.x, graph.x) + assert torch.equal(loaded.edge_index, graph.edge_index) + + +def test_load_torch_payload_rejects_invalid_bytes() -> None: + with pytest.raises((pickle.UnpicklingError, RuntimeError)): + load_torch_payload(b"not-a-torch-checkpoint") diff --git a/tests/visualization/plots/test_comparison_scatter.py b/tests/visualization/plots/test_comparison_scatter.py new file mode 100644 index 000000000..db4f1b783 --- /dev/null +++ b/tests/visualization/plots/test_comparison_scatter.py @@ -0,0 +1,368 @@ +"""Tests for :mod:`drevalpy.visualization.plots.comparison_scatter`. + +The plot's contract is that its retained state and its report payload are both +bounded by ``models x groups``: one correlation per model per drug (or cell +line), with model selection deferred to two Plotly dropdowns. Nothing here may +scale with the number of predictions - that regression is what +``tests/test_visualization_payload_policy.py`` guards. + +The figure is emitted as raw HTML calling ``Plotly.newPlot`` against MultiQC's +bundled Plotly global rather than as a native MultiQC plot, so the assertions +below are on ``Section.content``. +""" + +from __future__ import annotations + +import json +import re + +import numpy as np +import pytest +from plotly.basedatatypes import BaseFigure + +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.visualization.plots._group_metrics import GroupCorrelationMatrix, model_group_correlations +from drevalpy.visualization.plots.comparison_scatter import ( + _AXIS_RANGE, + _POINTS_TRACE, + ComparisonScatterVisualization, + _axis_layout, + _build_figure, + _dropdown_buttons, + _inline_plotly_html, +) +from tests.synthetic import make_experiment_result, make_run_result + +N_MODELS = 3 +N_FOLDS = 2 +N_PAIRS = 20 +N_DRUGS = 4 +N_CELL_LINES = 5 + + +@pytest.fixture(scope="module") +def experiment() -> ExperimentResult: + return make_experiment_result(n_models=N_MODELS, n_folds=N_FOLDS, n_pairs=N_PAIRS) + + +@pytest.fixture(scope="module") +def computed(experiment) -> ComparisonScatterVisualization: + plot = ComparisonScatterVisualization() + plot.compute(experiment) + return plot + + +@pytest.fixture(scope="module") +def matrix(experiment) -> GroupCorrelationMatrix: + return model_group_correlations(experiment, "drug") + + +def _empty_matrix() -> GroupCorrelationMatrix: + return GroupCorrelationMatrix("drug", (), (), np.empty((0, 0), dtype=np.float32)) + + +class TestAxisLayout: + def test_title_is_nested_so_plotly_3_keeps_it(self): + """A bare string under ``title`` is silently dropped by a relayout.""" + layout = _axis_layout("drug", "ElasticNet") + + assert layout["title"] == {"text": "ElasticNet (per-drug Pearson)"} + + def test_range_is_the_fixed_correlation_range(self): + assert _axis_layout("drug", "ElasticNet")["range"] == list(_AXIS_RANGE) + + def test_the_grouping_label_is_spelled_out(self): + assert "per-cell line" in _axis_layout("cell_line", "ElasticNet")["title"]["text"] + + +class TestDropdownButtons: + def test_one_button_per_model(self, matrix): + assert len(_dropdown_buttons(matrix, "x")) == matrix.n_models + + def test_buttons_are_labelled_with_the_model_name(self, matrix): + labels = [button["label"] for button in _dropdown_buttons(matrix, "y")] + + assert labels == list(matrix.model_names) + + def test_each_button_carries_one_models_group_vector(self, matrix): + button = _dropdown_buttons(matrix, "x")[0] + + assert len(button["args"][0]["x"][0]) == matrix.n_groups + + def test_the_x_axis_buttons_restyle_x_and_relayout_xaxis(self, matrix): + button = _dropdown_buttons(matrix, "x")[0] + + assert set(button["args"][0]) == {"x"} + assert set(button["args"][1]) == {"xaxis"} + + def test_the_y_axis_buttons_restyle_y_and_relayout_yaxis(self, matrix): + button = _dropdown_buttons(matrix, "y")[0] + + assert set(button["args"][0]) == {"y"} + assert set(button["args"][1]) == {"yaxis"} + + def test_restyles_target_only_the_points_trace(self, matrix): + """Without an explicit index Plotly cycles the restyle onto the reference line.""" + assert all(button["args"][2] == [_POINTS_TRACE] for button in _dropdown_buttons(matrix, "x")) + + def test_undefined_correlations_become_zero_rather_than_nan(self): + matrix = GroupCorrelationMatrix("drug", ("A",), ("D_0", "D_1"), np.array([[np.nan, 0.5]], dtype=np.float32)) + + values = _dropdown_buttons(matrix, "x")[0]["args"][0]["x"][0] + + assert values[0] == 0.0 + assert all(not np.isnan(value) for value in values) + + def test_values_are_json_serialisable_python_floats(self, matrix): + values = _dropdown_buttons(matrix, "x")[0]["args"][0]["x"][0] + + assert json.loads(json.dumps(values)) == values + + +class TestBuildFigure: + def test_draws_a_points_trace_and_a_reference_line(self, matrix): + fig = _build_figure(matrix) + + assert len(fig.data) == 2 + assert [trace.mode for trace in fig.data] == ["markers", "lines"] + + def test_the_points_trace_is_first(self, matrix): + assert _build_figure(matrix).data[_POINTS_TRACE].mode == "markers" + + def test_the_points_trace_holds_one_point_per_group(self, matrix): + assert len(_build_figure(matrix).data[0].x) == matrix.n_groups + + def test_the_reference_line_spans_the_axis_range(self, matrix): + line = _build_figure(matrix).data[1] + + assert tuple(line.x) == _AXIS_RANGE + assert tuple(line.y) == _AXIS_RANGE + + def test_both_axes_start_on_the_first_model(self, matrix): + fig = _build_figure(matrix) + expected = f"{matrix.model_names[0]} (per-drug Pearson)" + + assert fig.layout.xaxis.title.text == expected + assert fig.layout.yaxis.title.text == expected + + def test_axes_share_the_fixed_correlation_range(self, matrix): + fig = _build_figure(matrix) + + assert tuple(fig.layout.xaxis.range) == _AXIS_RANGE + assert tuple(fig.layout.yaxis.range) == _AXIS_RANGE + + def test_there_are_two_dropdown_menus(self, matrix): + assert len(_build_figure(matrix).layout.updatemenus) == 2 + + def test_the_dropdowns_are_labelled_x_and_y(self, matrix): + texts = [annotation.text for annotation in _build_figure(matrix).layout.annotations] + + assert texts == ["x-axis model:", "y-axis model:"] + + def test_hover_names_the_group(self, matrix): + trace = _build_figure(matrix).data[0] + + assert "Drug:" in trace.hovertemplate + assert tuple(trace.customdata) == matrix.group_names + + def test_the_legend_is_suppressed(self, matrix): + assert _build_figure(matrix).layout.showlegend is False + + def test_an_empty_matrix_yields_an_empty_figure(self): + assert _build_figure(_empty_matrix()).data == () + + +class TestInlinePlotlyHtml: + def test_emits_a_div_with_the_requested_id(self, matrix): + html = _inline_plotly_html(_build_figure(matrix), "my_div") + + assert '<div id="my_div"' in html + + def test_calls_newplot_on_that_div(self, matrix): + html = _inline_plotly_html(_build_figure(matrix), "my_div") + + assert "Plotly.newPlot(target" in html + assert 'getElementById("my_div")' in html + + def test_does_not_bundle_a_second_copy_of_plotly(self, matrix): + """MultiQC's template already loads Plotly and sets ``window.Plotly``.""" + html = _inline_plotly_html(_build_figure(matrix), "my_div") + + assert "plotly.min.js" not in html + assert len(html) < 200_000 + + def test_defers_when_plotly_is_not_yet_defined(self, matrix): + html = _inline_plotly_html(_build_figure(matrix), "my_div") + + assert 'typeof Plotly === "undefined"' in html + assert "DOMContentLoaded" in html + + def test_the_embedded_spec_is_valid_json(self, matrix): + html = _inline_plotly_html(_build_figure(matrix), "my_div") + + spec = json.loads(re.search(r"var spec = (\{.*\});", html, re.DOTALL).group(1)) + + assert set(spec) == {"data", "layout"} + assert len(spec["data"]) == 2 + + def test_numpy_values_survive_serialisation(self, matrix): + html = _inline_plotly_html(_build_figure(matrix), "my_div") + + spec = json.loads(re.search(r"var spec = (\{.*\});", html, re.DOTALL).group(1)) + + assert len(spec["layout"]["updatemenus"]) == 2 + + +class TestCompute: + def test_computes_a_matrix_per_grouping(self, computed): + assert set(computed._matrices) == {"drug", "cell_line"} + + def test_each_matrix_is_models_by_groups(self, computed): + assert computed._matrices["drug"].values.shape == (N_MODELS, N_DRUGS) + assert computed._matrices["cell_line"].values.shape == (N_MODELS, N_CELL_LINES) + + def test_the_figure_shows_the_first_grouping(self, computed): + assert len(computed._fig.data[0].x) == N_DRUGS + + def test_dataset_argument_is_accepted_and_ignored(self, experiment): + plot = ComparisonScatterVisualization() + + plot.compute(experiment, dataset=object()) + + assert set(plot._matrices) == {"drug", "cell_line"} + + def test_a_single_model_yields_an_empty_figure_rather_than_an_error(self): + result = ExperimentResult([make_run_result(model_name="Solo", fold_index=i) for i in range(2)]) + plot = ComparisonScatterVisualization() + + plot.compute(result) + + assert plot._fig.data == () + assert plot._matrices == {} + + def test_recomputing_replaces_the_previous_matrices(self, experiment): + plot = ComparisonScatterVisualization() + plot.compute(experiment) + + plot.compute(ExperimentResult([make_run_result(model_name="Solo")])) + + assert plot._matrices == {} + + def test_models_with_no_defined_correlation_are_dropped(self): + """A constant predictor has no correlation in any group.""" + constant = make_run_result(model_name="Constant", n_pairs=20) + constant.predictions[:] = 1.0 + result = ExperimentResult( + [constant, make_run_result(model_name="A", n_pairs=20), make_run_result(model_name="B", n_pairs=20)] + ) + plot = ComparisonScatterVisualization() + + plot.compute(result) + + assert "Constant" not in plot._matrices["drug"].model_names + + def test_a_grouping_left_with_one_model_is_skipped(self): + constant = make_run_result(model_name="Constant", n_pairs=20) + constant.predictions[:] = 1.0 + result = ExperimentResult([constant, make_run_result(model_name="A", n_pairs=20)]) + plot = ComparisonScatterVisualization() + + plot.compute(result) + + assert plot._matrices == {} + + def test_an_all_randomized_experiment_produces_nothing(self): + result = ExperimentResult( + [ + make_run_result(model_name="A", randomization=("gene_expression", "permutation")), + make_run_result(model_name="B", randomization=("gene_expression", "permutation")), + ] + ) + plot = ComparisonScatterVisualization() + + plot.compute(result) + + assert plot._matrices == {} + assert plot._fig.data == () + + +class TestToMultiqc: + def test_returns_one_section_per_grouping(self, computed): + assert len(computed.to_multiqc()) == 2 + + def test_anchors_are_unique_and_name_the_grouping(self, computed): + anchors = [section.anchor for section in computed.to_multiqc()] + + assert anchors == ["dreval_comp_scatter_drug", "dreval_comp_scatter_cell_line"] + + def test_sections_are_named_after_the_grouping(self, computed): + names = [section.anchor for section in computed.to_multiqc()] + + assert len(set(names)) == 2 + + def test_sections_carry_raw_html_rather_than_a_native_plot(self, computed): + for section in computed.to_multiqc(): + assert section.plot is None + assert "Plotly.newPlot" in section.content + + def test_each_section_targets_its_own_div(self, computed): + divs = [re.search(r'<div id="([^"]+)"', section.content).group(1) for section in computed.to_multiqc()] + + assert divs == ["dreval_comp_scatter_drug_div", "dreval_comp_scatter_cell_line_div"] + + def test_descriptions_report_the_model_and_group_counts(self, computed): + description = computed.to_multiqc()[0].description + + assert f"{N_MODELS} models" in description + assert f"{N_DRUGS} drugs" in description + + def test_returns_nothing_when_there_is_no_comparison_to_make(self): + result = ExperimentResult([make_run_result(model_name="Solo")]) + plot = ComparisonScatterVisualization() + plot.compute(result) + + assert plot.to_multiqc() == [] + + def test_the_payload_is_bounded_by_models_times_groups(self, computed): + """~90 bytes per (model, group) value, nowhere near per-prediction.""" + total = sum(len(section.content) for section in computed.to_multiqc()) + + assert total < 200 * N_MODELS * (N_DRUGS + N_CELL_LINES) + 10_000 + + +class TestRendering: + def test_to_png_writes_a_png_file(self, computed, tmp_path): + out = tmp_path / "cs.png" + + computed.to_png(out) + + assert out.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + +class TestShow: + def test_delegates_to_the_plotly_figure(self, computed, monkeypatch): + calls: list = [] + monkeypatch.setattr(BaseFigure, "show", lambda self, *a, **kw: calls.append(self)) + + computed.show() + + assert calls == [computed._fig] + + +class TestGuardsBeforeCompute: + def test_to_png_raises(self, tmp_path): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_png\(\)"): + ComparisonScatterVisualization().to_png(tmp_path / "cs.png") + + def test_to_multiqc_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_multiqc\(\)"): + ComparisonScatterVisualization().to_multiqc() + + def test_show_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before show\(\)"): + ComparisonScatterVisualization().show() + + +class TestRegistration: + def test_keeps_its_registry_name(self): + assert ComparisonScatterVisualization.registry_name == "comparison_scatter" diff --git a/tests/visualization/plots/test_critical_difference.py b/tests/visualization/plots/test_critical_difference.py new file mode 100644 index 000000000..dc2e5a5f1 --- /dev/null +++ b/tests/visualization/plots/test_critical_difference.py @@ -0,0 +1,396 @@ +"""Tests for :mod:`drevalpy.visualization.plots.critical_difference`. + +The Friedman test underpinning this plot needs at least three models with equal +fold counts. ``MULTIPLE_MODELS`` only demands two, so the plot skips with a +warning rather than letting SciPy raise; that and the modal-fold-count filtering +in ``_create_figure`` are asserted explicitly. +""" + +from __future__ import annotations + +import logging + +import matplotlib +import matplotlib.colors +import matplotlib.pyplot as plt +import pandas as pd +import plotly.colors as pc +import pytest +from upath import UPath + +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.visualization.plots import critical_difference +from drevalpy.visualization.plots.critical_difference import ( + CriticalDifferenceVisualization, + _build_cd_df, + _crossbar_sets_from_adjacency, + _draw_crossbars, + _generate_discrete_palette, + _nonsignificant_adjacency, +) +from tests._trusted_subprocess import run_trusted_python +from tests.synthetic import NORMALIZED_METRIC, make_experiment_result, make_run_result + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + +MODELS = ["A", "B", "C"] + + +def _sig_matrix(a_vs_b: float, a_vs_c: float, b_vs_c: float) -> pd.DataFrame: + """Symmetric p-value matrix in the shape ``posthoc_conover_friedman`` returns.""" + return pd.DataFrame( + [ + [1.0, a_vs_b, a_vs_c], + [a_vs_b, 1.0, b_vs_c], + [a_vs_c, b_vs_c, 1.0], + ], + index=MODELS, + columns=MODELS, + ) + + +@pytest.fixture(autouse=True) +def _close_figures(): + yield + plt.close("all") + + +@pytest.fixture +def experiment() -> ExperimentResult: + return make_experiment_result() + + +@pytest.fixture +def computed(experiment) -> CriticalDifferenceVisualization: + plot = CriticalDifferenceVisualization() + plot.compute(experiment) + return plot + + +class TestBuildCdDf: + def test_columns_are_algorithm_split_and_metric(self, experiment): + df = _build_cd_df(experiment, "MSE") + + assert list(df.columns) == ["algorithm", "CV_split", "MSE"] + + def test_has_one_row_per_run(self, experiment): + df = _build_cd_df(experiment, "MSE") + + assert len(df) == sum(m.n_folds for m in experiment.models) + + def test_randomized_runs_are_excluded(self): + result = ExperimentResult( + [ + make_run_result(model_name="ElasticNet"), + make_run_result(model_name="RandomForest", randomization=("gene_expression", "permutation")), + ] + ) + + df = _build_cd_df(result, "MSE") + + assert df["algorithm"].tolist() == ["ElasticNet"] + + def test_missing_metrics_leave_no_rows_to_rank(self, experiment): + """NaN rows are dropped, so an absent metric yields an empty frame.""" + df = _build_cd_df(experiment, "NotAMetric") + + assert df.empty + assert list(df.columns) == ["algorithm", "CV_split", "NotAMetric"] + + def test_the_legacy_normalized_spelling_is_resolved(self): + result = ExperimentResult([make_run_result(metrics={NORMALIZED_METRIC: 0.42})]) + + df = _build_cd_df(result, "Pearson") + + assert df["Pearson"].tolist() == [0.42] + + def test_the_plain_name_wins_over_the_suffixed_one(self): + result = ExperimentResult([make_run_result(metrics={"Pearson": 0.9, NORMALIZED_METRIC: 0.1})]) + + df = _build_cd_df(result, "Pearson") + + assert df["Pearson"].tolist() == [0.9] + + def test_is_empty_when_every_run_is_randomized(self): + result = ExperimentResult([make_run_result(randomization=("gene_expression", "permutation"))]) + + assert _build_cd_df(result, "MSE").empty + + +class TestGenerateDiscretePalette: + def test_short_requests_slice_the_base_palette(self): + assert _generate_discrete_palette(3) == list(pc.qualitative.D3[:3]) + + def test_returns_exactly_the_requested_number_of_colors(self): + assert len(_generate_discrete_palette(17)) == 17 + + def test_interpolates_beyond_the_base_palette(self): + colors = _generate_discrete_palette(len(pc.qualitative.D3) + 2) + + assert colors[0] == matplotlib.colors.to_hex(pc.qualitative.D3[0]) + assert colors[-1] == matplotlib.colors.to_hex(pc.qualitative.D3[-1]) + + def test_interpolated_colors_are_hex_strings(self): + assert all(c.startswith("#") and len(c) == 7 for c in _generate_discrete_palette(15)) + + def test_zero_colors_yields_an_empty_palette(self): + assert _generate_discrete_palette(0) == [] + + +class TestNonsignificantAdjacency: + def test_marks_non_significant_pairs_as_adjacent(self): + adjacency = _nonsignificant_adjacency(_sig_matrix(a_vs_b=0.9, a_vs_c=0.9, b_vs_c=0.9)) + + assert bool(adjacency.loc["A", "B"]) is True + + def test_significant_pairs_are_not_adjacent(self): + adjacency = _nonsignificant_adjacency(_sig_matrix(a_vs_b=0.001, a_vs_c=0.9, b_vs_c=0.9)) + + assert bool(adjacency.loc["A", "B"]) is False + + def test_diagonal_is_marked_adjacent(self): + """``sign_array`` writes -1 on the diagonal, so ``1 - (-1)`` is truthy.""" + adjacency = _nonsignificant_adjacency(_sig_matrix(a_vs_b=0.001, a_vs_c=0.001, b_vs_c=0.001)) + + assert adjacency.to_numpy().diagonal().all() + + def test_labels_and_dtype_are_preserved(self): + adjacency = _nonsignificant_adjacency(_sig_matrix(a_vs_b=0.9, a_vs_c=0.9, b_vs_c=0.9)) + + assert list(adjacency.index) == list(adjacency.columns) == MODELS + assert adjacency.dtypes.eq(bool).all() + + def test_does_not_mutate_the_input(self): + sig = _sig_matrix(a_vs_b=0.001, a_vs_c=0.9, b_vs_c=0.9) + before = sig.copy() + + _nonsignificant_adjacency(sig) + + pd.testing.assert_frame_equal(sig, before) + + +class TestCrossbarSetsFromAdjacency: + def test_each_model_is_grouped_with_its_non_different_peers(self): + adjacency = _nonsignificant_adjacency(_sig_matrix(a_vs_b=0.9, a_vs_c=0.001, b_vs_c=0.001)) + + sets = _crossbar_sets_from_adjacency(adjacency) + + assert sets["A"] == {"A", "B"} + assert sets["C"] == {"C"} + + def test_all_significant_yields_only_singletons(self): + adjacency = _nonsignificant_adjacency(_sig_matrix(a_vs_b=0.001, a_vs_c=0.001, b_vs_c=0.001)) + + assert _crossbar_sets_from_adjacency(adjacency) == {"A": {"A"}, "B": {"B"}, "C": {"C"}} + + def test_none_significant_groups_everything(self): + adjacency = _nonsignificant_adjacency(_sig_matrix(a_vs_b=0.9, a_vs_c=0.9, b_vs_c=0.9)) + + assert _crossbar_sets_from_adjacency(adjacency)["A"] == set(MODELS) + + +class TestDrawCrossbars: + def test_singleton_groups_draw_nothing(self): + _, ax = plt.subplots() + ranks = pd.Series({"A": 1.0, "B": 2.0, "C": 3.0}) + sets = {name: {name} for name in MODELS} + + ypos = _draw_crossbars(ax, ranks, sets, dict.fromkeys(MODELS, "#000000"), {}) + + assert len(ax.lines) == 0 + assert ypos == -0.5 + + def test_each_group_gets_one_line_and_shifts_the_offset(self): + _, ax = plt.subplots() + ranks = pd.Series({"A": 1.0, "B": 2.0, "C": 3.0}) + sets = {"A": {"A", "B"}, "B": {"A", "B"}, "C": {"C"}} + + ypos = _draw_crossbars(ax, ranks, sets, dict.fromkeys(MODELS, "#000000"), {}) + + assert len(ax.lines) == 2 + assert ypos == -1.5 + + def test_lines_use_the_model_color(self): + _, ax = plt.subplots() + ranks = pd.Series({"A": 1.0, "B": 2.0}) + sets = {"A": {"A", "B"}, "B": {"A", "B"}} + + _draw_crossbars(ax, ranks, sets, {"A": "#ff0000", "B": "#00ff00"}, {}) + + assert matplotlib.colors.to_hex(ax.lines[0].get_color()) == "#ff0000" + + +class TestCompute: + def test_title_names_the_metric_and_the_friedman_p_value(self, computed): + title = computed._fig.axes[0].get_title() + + assert "Critical Difference Diagram: Metric: MSE." in title + assert "Friedman-Chi2 p-value" in title + + def test_every_model_is_placed_on_the_rank_axis(self, computed, experiment): + labels = {text.get_text() for text in computed._fig.axes[0].texts} + + assert len(labels) == len(experiment.models) + assert all(any(name in label for label in labels) for name in experiment.model_names) + + def test_one_marker_is_drawn_per_model(self, computed, experiment): + assert len(computed._fig.axes[0].collections) == len(experiment.models) + + def test_rank_axis_hides_the_y_axis(self, computed): + assert computed._fig.axes[0].yaxis.get_visible() is False + + def test_metric_is_configurable(self, experiment): + plot = CriticalDifferenceVisualization() + + plot.compute(experiment, metric="Pearson") + + assert plot._metric == "Pearson" + assert "Metric: Pearson." in plot._fig.axes[0].get_title() + + def test_models_with_a_minority_fold_count_are_dropped(self): + runs = [ + make_run_result(model_name=name, fold_index=fold) + for name, n_folds in (("A", 3), ("B", 3), ("C", 3), ("D", 2)) + for fold in range(n_folds) + ] + plot = CriticalDifferenceVisualization() + + plot.compute(ExperimentResult(runs)) + + labels = {text.get_text() for text in plot._fig.axes[0].texts} + assert len(labels) == 3 + assert not any("D" in label for label in labels) + + def test_falls_back_to_a_placeholder_when_there_is_no_data(self): + result = ExperimentResult([make_run_result(randomization=("gene_expression", "permutation"))]) + plot = CriticalDifferenceVisualization() + + plot.compute(result) + + assert plot._fig.axes[0].texts[0].get_text() == "No data available" + + def test_fewer_than_three_models_is_skipped_instead_of_raising(self, caplog): + """``MULTIPLE_MODELS`` admits a two-model experiment, which Friedman cannot rank.""" + plot = CriticalDifferenceVisualization() + + with caplog.at_level(logging.WARNING, logger="drevalpy.visualization.plots.critical_difference"): + plot.compute(make_experiment_result(n_models=2)) + + assert plot._fig.axes[0].texts[0].get_text() == "Not enough comparable models" + assert any("only 2 models share" in r.getMessage() for r in caplog.records) + + def test_a_metric_absent_from_every_run_is_skipped_with_a_warning(self, experiment, caplog): + plot = CriticalDifferenceVisualization() + + with caplog.at_level(logging.WARNING, logger="drevalpy.visualization.plots.critical_difference"): + plot.compute(experiment, metric="NotAMetric") + + assert plot._fig.axes[0].texts[0].get_text() == "No data available" + assert any("no finite NotAMetric values" in r.getMessage() for r in caplog.records) + + def test_the_legacy_normalized_spelling_is_still_ranked(self): + """Results written before ``normalize()`` used plain names keep working.""" + runs = [ + make_run_result( + model_name=name, + fold_index=fold, + metrics={NORMALIZED_METRIC: 0.5 + 0.1 * index + 0.01 * fold}, + ) + for index, name in enumerate(("A", "B", "C")) + for fold in range(3) + ] + plot = CriticalDifferenceVisualization() + + plot.compute(ExperimentResult(runs), metric="Pearson") + + assert len(plot._fig.axes[0].collections) == 3 + + def test_dataset_argument_is_accepted_and_ignored(self, experiment): + plot = CriticalDifferenceVisualization() + + plot.compute(experiment, dataset=object()) + + assert plot._fig is not None + + +class TestRendering: + def test_to_png_writes_a_png_file(self, computed, tmp_path): + out = tmp_path / "cd.png" + + computed.to_png(out) + + assert out.read_bytes().startswith(PNG_MAGIC) + + def test_to_multiqc_embeds_the_figure_under_the_registry_name(self, computed): + sections = computed.to_multiqc() + + assert len(sections) == 1 + assert (sections[0].name, sections[0].anchor) == ("critical_difference", "critical_difference") + assert sections[0].content is not None + assert "data:image/png;base64," in sections[0].content + + +class TestGuardsBeforeCompute: + def test_to_png_raises(self, tmp_path): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_png\(\)"): + CriticalDifferenceVisualization().to_png(tmp_path / "c.png") + + def test_to_multiqc_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_multiqc\(\)"): + CriticalDifferenceVisualization().to_multiqc() + + def test_show_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before show\(\)"): + CriticalDifferenceVisualization().show() + + def test_metric_defaults_to_mse(self): + assert CriticalDifferenceVisualization()._metric == "MSE" + + +class TestNoGlobalBackendSwitch: + """``import drevalpy`` must not reach into the process-wide Matplotlib state. + + Registering the builtins imports this module, so a module-scope + ``matplotlib.use("agg")`` here disabled inline plotting in every notebook + that imported the library at all. + + Extended tier: ``test_importing_drevalpy_leaves_the_backend_alone`` can only be + asserted in a pristine interpreter, so it spawns one (~2.3s). Its two siblings + are cheap but belong to the same concern, so the marker sits on the class. + """ + + pytestmark = pytest.mark.slow + + def test_the_module_does_not_call_matplotlib_use(self): + source = UPath(critical_difference.__file__).read_text(encoding="utf-8") + + assert "matplotlib.use(" not in source + + def test_importing_drevalpy_leaves_the_backend_alone(self): + """Asserted in a fresh interpreter: an in-process reload cannot see this.""" + script = ( + "import matplotlib\n" + "before = matplotlib.get_backend()\n" + "import drevalpy # noqa: F401\n" + "print(before, matplotlib.get_backend())\n" + ) + + result = run_trusted_python(script) + + assert result.returncode == 0, result.stderr + before, after = result.stdout.split() + assert before == after + + def test_the_figure_renders_under_the_agg_backend(self, experiment, tmp_path): + """Forcing ``agg`` proves the plot is still headless-safe without the global call.""" + original = matplotlib.get_backend() + matplotlib.use("agg") + try: + plot = CriticalDifferenceVisualization() + plot.compute(experiment) + out = tmp_path / "headless.png" + plot.to_png(out) + finally: + matplotlib.use(original) + + assert out.read_bytes().startswith(PNG_MAGIC) diff --git a/tests/visualization/plots/test_cross_study_table.py b/tests/visualization/plots/test_cross_study_table.py new file mode 100644 index 000000000..a4a0f97c3 --- /dev/null +++ b/tests/visualization/plots/test_cross_study_table.py @@ -0,0 +1,237 @@ +"""Tests for :mod:`drevalpy.visualization.plots.cross_study_table`. + +``compute`` looks for ``rand_setting`` values containing ``"cross-study-"``. +Nothing in the package emits such a setting today, so in practice the plot always +falls through to :func:`_build_simple_table`; the fallback is therefore treated +as the primary behaviour, with the cross-study branch covered by a hand-built +result. +""" + +from __future__ import annotations + +import pytest +from plotly.basedatatypes import BaseFigure + +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.visualization.plots._utils import runs_frame +from drevalpy.visualization.plots.cross_study_table import ( + CrossStudyTableVisualization, + _build_simple_table, +) +from tests.synthetic import make_experiment_result, make_run_result + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _cross_study_result() -> ExperimentResult: + """An experiment carrying the ``cross-study-*`` setting the plot looks for.""" + return ExperimentResult( + [ + make_run_result(model_name="ElasticNet", fold_index=i, randomization=("cross-study-CCLE", "eval")) + for i in (0, 1) + ] + + [make_run_result(model_name="ElasticNet", fold_index=i) for i in (0, 1)] + ) + + +@pytest.fixture(scope="module") +def experiment() -> ExperimentResult: + return make_experiment_result() + + +@pytest.fixture(scope="module") +def computed(experiment) -> CrossStudyTableVisualization: + plot = CrossStudyTableVisualization() + plot.compute(experiment) + return plot + + +class TestBuildSimpleTable: + def test_produces_a_single_plotly_table(self, experiment): + fig = _build_simple_table(experiment) + + assert [trace.type for trace in fig.data] == ["table"] + + def test_header_starts_with_the_model_column(self, experiment): + fig = _build_simple_table(experiment) + + assert fig.data[0].header.values[0] == "Model" + + def test_header_lists_every_metric_alphabetically(self, experiment): + metrics = sorted({m for model in experiment.models for m in model.aggregate_metrics}) + + fig = _build_simple_table(experiment) + + assert list(fig.data[0].header.values[1:]) == metrics + + def test_first_column_holds_the_model_names(self, experiment): + fig = _build_simple_table(experiment) + + assert list(fig.data[0].cells.values[0]) == experiment.model_names + + def test_cells_are_formatted_as_mean_plus_minus_std(self, experiment): + fig = _build_simple_table(experiment) + + assert " ± " in fig.data[0].cells.values[1][0] + + def test_metrics_missing_for_a_model_render_as_not_available(self): + result = ExperimentResult( + [ + make_run_result(model_name="OnlyMse", metrics={"MSE": 0.5}), + make_run_result(model_name="OnlyMae", metrics={"MAE": 0.25}), + ] + ) + + fig = _build_simple_table(result) + + assert list(fig.data[0].header.values) == ["Model", "MAE", "MSE"] + assert list(fig.data[0].cells.values[1]) == ["N/A", "0.250 ± 0.000"] + + def test_is_titled(self, experiment): + assert _build_simple_table(experiment).layout.title.text == "Model Performance Summary" + + +class TestComputeWithoutCrossStudyData: + def test_finds_no_cross_study_datasets(self, computed): + assert computed._cross_study_datasets == [] + + def test_builds_no_per_dataset_figures(self, computed): + assert computed._figures == {} + + def test_falls_back_to_the_simple_summary_table(self, computed): + assert computed._fig.layout.title.text == "Model Performance Summary" + + def test_stores_the_result(self, computed, experiment): + assert computed._result is experiment + + def test_dataset_argument_is_accepted_and_ignored(self, experiment): + plot = CrossStudyTableVisualization() + + plot.compute(experiment, dataset=object()) + + assert plot._fig is not None + + +class TestComputeWithCrossStudyData: + def test_extracts_the_target_dataset_name(self): + plot = CrossStudyTableVisualization() + + plot.compute(_cross_study_result()) + + assert plot._cross_study_datasets == ["CCLE_eval"] + + def test_builds_one_figure_per_cross_study_dataset(self): + plot = CrossStudyTableVisualization() + + plot.compute(_cross_study_result()) + + assert list(plot._figures) == ["CCLE_eval"] + assert plot._fig is plot._figures["CCLE_eval"] + + def test_figure_is_titled_with_the_target_dataset(self): + plot = CrossStudyTableVisualization() + + plot.compute(_cross_study_result()) + + assert plot._fig.layout.title.text == "Evaluation Metrics for Cross-Study Predictions to CCLE_eval" + + def test_only_cross_study_runs_are_summarized(self): + plot = CrossStudyTableVisualization() + + plot.compute(_cross_study_result()) + + assert list(plot._mean_metrics[0].index) == ["ElasticNet"] + + def test_models_are_ordered_by_mean_mse(self): + runs = [ + make_run_result( + model_name=name, + fold_index=fold, + randomization=("cross-study-CCLE", "eval"), + metrics={"MSE": mse + fold}, + ) + for name, mse in (("Worse", 5.0), ("Better", 1.0)) + for fold in (0, 1) + ] + plot = CrossStudyTableVisualization() + + plot.compute(ExperimentResult(runs)) + + assert list(plot._mean_metrics[0].index) == ["Better", "Worse"] + + def test_std_rows_follow_the_sorted_mean_rows(self): + plot = CrossStudyTableVisualization() + + plot.compute(_cross_study_result()) + + assert list(plot._std_metrics[0].index) == list(plot._mean_metrics[0].index) + + +class TestCrossStudyDataframeQuirk: + def test_nothing_in_the_package_emits_a_cross_study_setting(self, experiment): + """Guards the documented fallback: ``rand_setting`` never carries the prefix.""" + df = runs_frame(experiment, indexed=True) + + assert not df["rand_setting"].str.contains("cross-study-").any() + + def test_a_cross_study_setting_reaches_the_index_when_present(self): + """The compute() branch keys off the index, so the label has to survive into it.""" + df = runs_frame(_cross_study_result(), indexed=True) + + assert any("cross-study-CCLE_eval" in idx for idx in df.index) + + +class TestToMultiqc: + def test_returns_a_single_native_table_section(self, computed): + sections = computed.to_multiqc() + + assert len(sections) == 1 + assert (sections[0].name, sections[0].anchor) == ("Model Summary Table", "dreval_summary_table") + + def test_section_carries_a_native_multiqc_plot(self, computed): + assert computed.to_multiqc()[0].plot is not None + + def test_payload_holds_a_mean_and_a_std_column_per_metric(self, computed, monkeypatch): + from multiqc.plots import table as mqc_table + + captured: list = [] + monkeypatch.setattr(mqc_table, "plot", lambda data, headers, pconfig: captured.append((data, headers))) + + computed.to_multiqc() + + data, headers = captured[0] + assert set(data["ElasticNet"]) == set(headers) + assert {"MSE_mean", "MSE_std"} <= set(headers) + + +class TestRendering: + def test_to_png_writes_a_png_file(self, computed, tmp_path): + out = tmp_path / "table.png" + + computed.to_png(out) + + assert out.read_bytes().startswith(PNG_MAGIC) + + +class TestShow: + def test_delegates_to_the_plotly_figure(self, computed, monkeypatch): + calls: list = [] + monkeypatch.setattr(BaseFigure, "show", lambda self, *a, **kw: calls.append(self)) + + computed.show() + + assert calls == [computed._fig] + + +class TestGuardsBeforeCompute: + def test_to_png_raises(self, tmp_path): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_png\(\)"): + CrossStudyTableVisualization().to_png(tmp_path / "t.png") + + def test_to_multiqc_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_multiqc\(\)"): + CrossStudyTableVisualization().to_multiqc() + + def test_show_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before show\(\)"): + CrossStudyTableVisualization().show() diff --git a/tests/visualization/plots/test_group_metrics.py b/tests/visualization/plots/test_group_metrics.py new file mode 100644 index 000000000..ff8de0f2d --- /dev/null +++ b/tests/visualization/plots/test_group_metrics.py @@ -0,0 +1,327 @@ +"""Tests for :mod:`drevalpy.visualization.plots._group_metrics`. + +The module exists to replace a ``groupby().apply(pearsonr)`` with vectorised +``bincount`` sums, so the tests below pin the numerical agreement with +:func:`scipy.stats.pearsonr` as well as the degenerate cases where the two +disagree deliberately: a group with fewer than two observations, and a group with +no variance on one axis, both of which yield NaN here. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from scipy.stats import pearsonr + +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.visualization.plots._group_metrics import ( + GROUPING_LABELS, + GROUPINGS, + GroupCorrelationMatrix, + group_labels, + grouped_pearson, + model_group_correlations, +) +from tests.synthetic import make_experiment_result, make_run_result + +N_MODELS = 3 +N_FOLDS = 2 +N_PAIRS = 20 +N_DRUGS = 4 +N_CELL_LINES = 5 + + +@pytest.fixture(scope="module") +def experiment() -> ExperimentResult: + return make_experiment_result(n_models=N_MODELS, n_folds=N_FOLDS, n_pairs=N_PAIRS) + + +class TestGroupings: + def test_drug_and_cell_line_are_the_supported_groupings(self): + assert GROUPINGS == ("drug", "cell_line") + + def test_every_grouping_has_a_human_label(self): + assert set(GROUPING_LABELS) == set(GROUPINGS) + + +class TestGroupLabels: + def test_drug_grouping_reads_drug_ids(self): + run = make_run_result(n_pairs=6) + + np.testing.assert_array_equal(group_labels(run, "drug"), run.drug_ids) + + def test_cell_line_grouping_reads_cell_line_ids(self): + run = make_run_result(n_pairs=6) + + np.testing.assert_array_equal(group_labels(run, "cell_line"), run.cell_line_ids) + + def test_an_unknown_grouping_is_rejected(self): + with pytest.raises(ValueError, match="Unknown grouping 'tissue'"): + group_labels(make_run_result(), "tissue") + + +class TestGroupedPearson: + def test_agrees_with_scipy_per_group(self): + rng = np.random.default_rng(0) + codes = np.repeat([0, 1], 25) + x = rng.normal(size=50) + y = rng.normal(size=50) + + result = grouped_pearson(codes, 2, x, y) + + for group in (0, 1): + expected = pearsonr(x[codes == group], y[codes == group])[0] + assert result[group] == pytest.approx(expected, abs=1e-9) + + def test_a_perfect_positive_relationship_is_one(self): + x = np.array([1.0, 2.0, 3.0, 4.0]) + + assert grouped_pearson(np.zeros(4, dtype=int), 1, x, 2 * x + 1)[0] == pytest.approx(1.0) + + def test_a_perfect_negative_relationship_is_minus_one(self): + x = np.array([1.0, 2.0, 3.0, 4.0]) + + assert grouped_pearson(np.zeros(4, dtype=int), 1, x, -x)[0] == pytest.approx(-1.0) + + def test_a_single_observation_group_is_nan(self): + result = grouped_pearson(np.array([0, 1, 1]), 2, np.array([1.0, 1.0, 2.0]), np.array([1.0, 3.0, 4.0])) + + assert np.isnan(result[0]) + assert np.isfinite(result[1]) + + def test_a_group_with_no_variance_is_nan(self): + codes = np.zeros(4, dtype=int) + + result = grouped_pearson(codes, 1, np.ones(4), np.array([1.0, 2.0, 3.0, 4.0])) + + assert np.isnan(result[0]) + + def test_an_empty_group_keeps_its_slot_as_nan(self): + result = grouped_pearson(np.zeros(3, dtype=int), 3, np.array([1.0, 2.0, 3.0]), np.array([1.0, 3.0, 2.0])) + + assert result.shape == (3,) + assert np.isnan(result[1:]).all() + + def test_negative_codes_are_dropped(self): + codes = np.array([-1, 0, 0, 0]) + + result = grouped_pearson(codes, 1, np.array([99.0, 1.0, 2.0, 3.0]), np.array([-99.0, 1.0, 2.0, 3.0])) + + assert result[0] == pytest.approx(1.0) + + def test_nan_observations_are_dropped(self): + codes = np.zeros(5, dtype=int) + x = np.array([np.nan, 1.0, 2.0, 3.0, 4.0]) + y = np.array([0.0, 1.0, 2.0, 3.0, np.nan]) + + result = grouped_pearson(codes, 1, x, y) + + assert result[0] == pytest.approx(1.0) + + def test_dropping_nans_can_take_a_group_below_the_minimum(self): + codes = np.zeros(3, dtype=int) + + result = grouped_pearson(codes, 1, np.array([1.0, np.nan, np.nan]), np.array([1.0, 2.0, 3.0])) + + assert np.isnan(result[0]) + + def test_a_higher_min_count_filters_small_groups(self): + codes = np.zeros(3, dtype=int) + x = np.array([1.0, 2.0, 3.0]) + + assert np.isfinite(grouped_pearson(codes, 1, x, x, min_count=3)[0]) + assert np.isnan(grouped_pearson(codes, 1, x, x, min_count=4)[0]) + + def test_results_stay_inside_the_unit_interval(self): + rng = np.random.default_rng(1) + codes = rng.integers(0, 5, size=200) + x = rng.normal(size=200) + + result = grouped_pearson(codes, 5, x, x * 3.0) + + assert np.all(np.abs(result) <= 1.0) + + def test_all_observations_dropped_yields_all_nan(self): + result = grouped_pearson(np.array([-1, -1]), 2, np.array([1.0, 2.0]), np.array([1.0, 2.0])) + + assert np.isnan(result).all() + + +class TestModelGroupCorrelations: + def test_shape_is_models_by_groups(self, experiment): + matrix = model_group_correlations(experiment, "drug") + + assert matrix.values.shape == (N_MODELS, N_DRUGS) + + def test_the_cell_line_axis_has_one_column_per_cell_line(self, experiment): + matrix = model_group_correlations(experiment, "cell_line") + + assert matrix.n_groups == N_CELL_LINES + + def test_values_are_float32_so_the_matrix_stays_small(self, experiment): + assert model_group_correlations(experiment, "drug").values.dtype == np.float32 + + def test_model_names_follow_the_experiment_order(self, experiment): + matrix = model_group_correlations(experiment, "drug") + + assert list(matrix.model_names) == experiment.model_names + + def test_group_names_are_sorted_strings(self, experiment): + matrix = model_group_correlations(experiment, "drug") + + assert list(matrix.group_names) == sorted(matrix.group_names) + assert all(isinstance(name, str) for name in matrix.group_names) + + def test_group_axis_is_the_union_across_models(self): + result = ExperimentResult( + [ + make_run_result(model_name="A", n_pairs=2, n_drugs=2), + make_run_result(model_name="B", n_pairs=6, n_drugs=6), + ] + ) + + assert model_group_correlations(result, "drug").n_groups == 6 + + def test_a_group_a_model_never_saw_is_nan_for_that_model(self): + result = ExperimentResult( + [ + make_run_result(model_name="A", n_pairs=4, n_drugs=2), + make_run_result(model_name="B", n_pairs=8, n_drugs=4), + ] + ) + + matrix = model_group_correlations(result, "drug") + + assert np.isnan(matrix.for_model("A")[2:]).all() + + def test_values_match_a_direct_per_group_pearson(self): + run = make_run_result(model_name="A", n_pairs=20, n_drugs=4) + matrix = model_group_correlations(ExperimentResult([run]), "drug") + + for index, name in enumerate(matrix.group_names): + mask = np.asarray(run.drug_ids) == name + expected = pearsonr(run.predictions[mask], run.ground_truth[mask])[0] + assert matrix.values[0, index] == pytest.approx(expected, abs=1e-6) + + def test_folds_are_pooled_rather_than_averaged(self): + pooled = model_group_correlations( + ExperimentResult([make_run_result(fold_index=i, n_pairs=20) for i in range(2)]), "drug" + ) + single = model_group_correlations(ExperimentResult([make_run_result(fold_index=0, n_pairs=20)]), "drug") + + assert not np.allclose(pooled.values, single.values, equal_nan=True) + + def test_randomized_runs_are_excluded(self): + randomized = ExperimentResult( + [ + make_run_result(model_name="A", fold_index=0), + make_run_result(model_name="A", fold_index=1, randomization=("gene_expression", "permutation")), + ] + ) + plain = ExperimentResult([make_run_result(model_name="A", fold_index=0)]) + + np.testing.assert_allclose( + model_group_correlations(randomized, "drug").values, + model_group_correlations(plain, "drug").values, + ) + + def test_a_fully_randomized_model_is_omitted(self): + result = ExperimentResult( + [ + make_run_result(model_name="A"), + make_run_result(model_name="B", randomization=("gene_expression", "permutation")), + ] + ) + + assert model_group_correlations(result, "drug").model_names == ("A",) + + def test_an_all_randomized_experiment_is_empty(self): + result = ExperimentResult([make_run_result(randomization=("gene_expression", "permutation"))]) + + matrix = model_group_correlations(result, "drug") + + assert matrix.is_empty + assert matrix.values.shape == (0, 0) + + def test_min_count_is_forwarded(self, experiment): + matrix = model_group_correlations(experiment, "drug", min_count=1000) + + assert np.isnan(matrix.values).all() + + def test_an_unknown_grouping_is_rejected(self, experiment): + with pytest.raises(ValueError, match="Unknown grouping"): + model_group_correlations(experiment, "tissue") + + +class TestGroupCorrelationMatrix: + def test_reports_its_dimensions(self, experiment): + matrix = model_group_correlations(experiment, "drug") + + assert (matrix.n_models, matrix.n_groups) == (N_MODELS, N_DRUGS) + assert matrix.is_empty is False + + def test_for_model_returns_the_matching_row(self, experiment): + matrix = model_group_correlations(experiment, "drug") + + np.testing.assert_array_equal(matrix.for_model(matrix.model_names[1]), matrix.values[1]) + + def test_for_model_rejects_an_unknown_name(self, experiment): + matrix = model_group_correlations(experiment, "drug") + + with pytest.raises(KeyError): + matrix.for_model("NotAModel") + + def test_a_matrix_with_no_groups_is_empty(self): + matrix = GroupCorrelationMatrix("drug", ("A",), (), np.empty((1, 0), dtype=np.float32)) + + assert matrix.is_empty + + def test_drop_all_nan_models_removes_undefined_models(self): + matrix = GroupCorrelationMatrix( + "drug", + ("Good", "Undefined"), + ("D_0", "D_1"), + np.array([[0.5, 0.4], [np.nan, np.nan]], dtype=np.float32), + ) + + filtered = matrix.drop_all_nan_models() + + assert filtered.model_names == ("Good",) + assert filtered.values.shape == (1, 2) + + def test_drop_all_nan_models_keeps_a_partially_defined_model(self): + matrix = GroupCorrelationMatrix( + "drug", + ("Partial",), + ("D_0", "D_1"), + np.array([[np.nan, 0.4]], dtype=np.float32), + ) + + assert matrix.drop_all_nan_models().model_names == ("Partial",) + + def test_drop_all_nan_models_returns_self_when_nothing_is_dropped(self, experiment): + matrix = GroupCorrelationMatrix("drug", ("A",), ("D_0",), np.array([[0.3]], dtype=np.float32)) + + assert matrix.drop_all_nan_models() is matrix + + def test_drop_all_nan_models_on_an_empty_matrix_is_a_no_op(self): + matrix = GroupCorrelationMatrix("drug", (), (), np.empty((0, 0), dtype=np.float32)) + + assert matrix.drop_all_nan_models() is matrix + + def test_the_grouping_is_carried_through(self, experiment): + assert model_group_correlations(experiment, "cell_line").grouping == "cell_line" + + +class TestBoundedMemory: + def test_retained_bytes_scale_with_groups_not_predictions(self): + few_rows = make_experiment_result(n_models=3, n_folds=1, n_pairs=20) + many_rows = make_experiment_result(n_models=3, n_folds=6, n_pairs=20) + + small = model_group_correlations(few_rows, "drug") + large = model_group_correlations(many_rows, "drug") + + assert small.values.nbytes == large.values.nbytes + + def test_a_float32_matrix_stays_tiny_at_production_shape(self): + assert np.empty((96, 524), dtype=np.float32).nbytes < 250_000 diff --git a/tests/visualization/plots/test_heatmap.py b/tests/visualization/plots/test_heatmap.py new file mode 100644 index 000000000..e7ca22d0c --- /dev/null +++ b/tests/visualization/plots/test_heatmap.py @@ -0,0 +1,288 @@ +"""Tests for :mod:`drevalpy.visualization.plots.heatmap`. + +``compute()`` is asserted in-process on the resulting Plotly figure. ``to_png()`` +is deliberately not exercised: it goes through kaleido and costs roughly 13 +seconds, which is not worth it for a hook that runs on every commit. +""" + +from __future__ import annotations + +import logging +import math + +import numpy as np +import pandas as pd +import pytest +from plotly.basedatatypes import BaseFigure + +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.visualization.plots._utils import runs_frame +from drevalpy.visualization.plots.heatmap import ( + HeatmapVisualization, + _calc_summary_metric, + _columns_for_setting, + _compute_ssmd, + _resolve_metric_columns, + _setting_groups, +) +from tests.synthetic import NORMALIZED_METRIC, REFERENCE_MODEL, make_experiment_result, make_run_result + + +def _ssmd_frame() -> pd.DataFrame: + """Two models x two folds, indexed the way ``runs_frame`` does.""" + return pd.DataFrame( + {"MSE": [1.0, 2.0, 5.0, 6.0]}, + index=[ + "A_predictions_LPO_split_0", + "A_predictions_LPO_split_1", + "B_predictions_LPO_split_0", + "B_predictions_LPO_split_1", + ], + ) + + +@pytest.fixture(scope="module") +def experiment() -> ExperimentResult: + return make_experiment_result() + + +@pytest.fixture(scope="module") +def computed(experiment) -> HeatmapVisualization: + plot = HeatmapVisualization() + plot.compute(experiment) + return plot + + +class TestSettingGroups: + def test_keeps_the_first_three_index_tokens(self): + df = _ssmd_frame() + + assert sorted(_setting_groups(df).unique()) == ["A_predictions_LPO", "B_predictions_LPO"] + + def test_is_aligned_with_the_frame_index(self): + df = _ssmd_frame() + + assert list(_setting_groups(df).index) == list(df.index) + + +class TestCalcSummaryMetric: + def test_averages_each_column(self): + x = pd.DataFrame({"a": [1.0, 3.0], "b": [2.0, 6.0]}) + + assert _calc_summary_metric(x).to_dict() == {"a": 2.0, "b": 4.0} + + def test_std_error_divides_the_std_by_sqrt_n(self): + x = pd.DataFrame({"a": [1.0, 3.0]}) + + assert _calc_summary_metric(x, std_error=True)["a"] == pytest.approx(1.0 / math.sqrt(2)) + + def test_ignores_nans_when_averaging(self): + x = pd.DataFrame({"a": [1.0, np.nan, 3.0]}) + + assert _calc_summary_metric(x)["a"] == 2.0 + + def test_all_nan_column_stays_nan(self): + x = pd.DataFrame({"a": [np.nan, np.nan]}) + + assert math.isnan(_calc_summary_metric(x)["a"]) + + def test_result_is_indexed_by_column(self): + x = pd.DataFrame({"a": [1.0], "b": [2.0]}) + + assert list(_calc_summary_metric(x).index) == ["a", "b"] + + +class TestComputeSsmd: + def test_returns_an_empty_frame_when_the_metric_is_absent(self): + assert _compute_ssmd(_ssmd_frame(), "Pearson").empty + + def test_is_a_square_model_by_model_matrix(self): + matrix = _compute_ssmd(_ssmd_frame(), "MSE") + + assert list(matrix.index) == list(matrix.columns) == ["A", "B"] + + def test_diagonal_is_zero(self): + matrix = _compute_ssmd(_ssmd_frame(), "MSE") + + assert matrix.loc["A", "A"] == 0.0 + assert matrix.loc["B", "B"] == 0.0 + + def test_off_diagonal_is_antisymmetric(self): + matrix = _compute_ssmd(_ssmd_frame(), "MSE") + + assert matrix.loc["A", "B"] == pytest.approx(-matrix.loc["B", "A"]) + + def test_is_negative_when_the_row_model_scores_lower(self): + matrix = _compute_ssmd(_ssmd_frame(), "MSE") + + assert matrix.loc["A", "B"] < 0 + + def test_is_nan_when_both_models_have_zero_variance(self): + df = pd.DataFrame( + {"MSE": [1.0, 1.0, 2.0, 2.0]}, + index=[ + "A_predictions_LPO_split_0", + "A_predictions_LPO_split_1", + "B_predictions_LPO_split_0", + "B_predictions_LPO_split_1", + ], + ) + + assert math.isnan(_compute_ssmd(df, "MSE").loc["A", "B"]) + + +class TestColumnsForSetting: + @pytest.mark.parametrize( + ("setting", "expected"), + [ + pytest.param("r2", ["R^2"], id="r2"), + pytest.param("correlations", ["Pearson", "Spearman", "Kendall"], id="correlations"), + pytest.param("errors", ["MSE", "RMSE", "MAE"], id="errors"), + pytest.param("unknown", [], id="unknown_setting"), + ], + ) + def test_selects_the_metrics_belonging_to_the_panel(self, setting, expected): + metric_cols = ["R^2", "Pearson", "Spearman", "Kendall", "MSE", "RMSE", "MAE"] + + assert _columns_for_setting(setting, metric_cols) == expected + + def test_normalized_variants_are_grouped_with_their_base_metric(self): + assert _columns_for_setting("r2", ["R^2", "R^2: normalized"]) == ["R^2", "R^2: normalized"] + + def test_normalized_errors_are_excluded_by_exact_match(self): + assert _columns_for_setting("errors", ["MSE: normalized"]) == [] + + +class TestCompute: + def test_builds_five_stacked_heatmap_panels(self, computed): + assert [trace.type for trace in computed._fig.data] == ["heatmap"] * 5 + + def test_rows_are_the_models(self, computed, experiment): + assert set(computed._fig.data[0].y) == set(experiment.model_names) + + def test_first_panel_shows_r2_only(self, computed): + assert list(computed._fig.data[0].x) == ["R^2"] + + def test_ssmd_panels_are_model_by_model(self, computed, experiment): + ssmd_trace = computed._fig.data[3] + + assert set(ssmd_trace.x) == set(ssmd_trace.y) == set(experiment.model_names) + + def test_cells_are_annotated_with_mean_and_standard_error(self, computed): + assert "±" in computed._fig.data[0].text[0][0] + + def test_layout_scales_the_height_with_the_model_count(self, computed, experiment): + assert computed._fig.layout.height == 500 + len(experiment.models) * 35 + assert computed._fig.layout.width == 1300 + + def test_layout_is_titled(self, computed): + assert computed._fig.layout.title.text == "Heatmap of the evaluation metrics" + + def test_stores_the_result_for_to_multiqc(self, computed, experiment): + assert computed._result is experiment + + def test_panels_without_available_metrics_are_skipped(self): + result = ExperimentResult( + [ + make_run_result(model_name=name, fold_index=i, metrics={"MSE": 0.4 + i + offset}) + for offset, name in enumerate("AB") + for i in range(2) + ] + ) + plot = HeatmapVisualization() + + plot.compute(result) + + panels = [list(trace.x) for trace in plot._fig.data] + assert panels[0] == ["MSE"] + assert sorted(panels[1]) == ["A", "B"] + assert len(panels) == 2 + + def test_dataset_argument_is_accepted_and_ignored(self, experiment): + plot = HeatmapVisualization() + + plot.compute(experiment, dataset=object()) + + assert plot._fig is not None + + +class TestMetricNameResolution: + """``normalize()`` emits plain names; older results carry suffixed ones.""" + + def test_a_normalized_experiment_populates_every_panel(self): + normalized = make_experiment_result(n_models=3, n_folds=2).normalize(REFERENCE_MODEL) + plot = HeatmapVisualization() + + plot.compute(normalized) + + assert len(plot._fig.data) == 5 + + def test_the_legacy_suffixed_spelling_is_folded_onto_the_base_name(self): + result = ExperimentResult( + [ + make_run_result(model_name=name, fold_index=i, metrics={NORMALIZED_METRIC: 0.4 + i + offset}) + for offset, name in enumerate("AB") + for i in range(2) + ] + ) + plot = HeatmapVisualization() + + plot.compute(result) + + assert [list(trace.x) for trace in plot._fig.data][0] == ["Pearson"] + + def test_resolve_metric_columns_prefers_the_plain_name(self): + result = ExperimentResult([make_run_result(metrics={"Pearson": 0.9, NORMALIZED_METRIC: 0.1})]) + df = runs_frame(result, indexed=True) + + renamed, columns = _resolve_metric_columns(result, df) + + assert columns == ["Pearson"] + assert renamed["Pearson"].tolist() == [0.9] + + def test_warns_when_no_expected_metric_is_present(self, caplog): + result = ExperimentResult([make_run_result(metrics={"NotAMetric": 1.0})]) + plot = HeatmapVisualization() + + with caplog.at_level(logging.WARNING, logger="drevalpy.visualization.plots.heatmap"): + plot.compute(result) + + assert any("none of the expected metrics" in r.getMessage() for r in caplog.records) + + +class TestToMultiqc: + def test_returns_a_single_native_heatmap_section(self, computed): + sections = computed.to_multiqc() + + assert len(sections) == 1 + assert (sections[0].name, sections[0].anchor) == ("Performance Heatmap", "dreval_heatmap") + + def test_section_carries_a_native_multiqc_plot(self, computed): + assert computed.to_multiqc()[0].plot is not None + + def test_section_is_described(self, computed): + assert "Mean metric values per model" in computed.to_multiqc()[0].description + + +class TestShow: + def test_delegates_to_the_plotly_figure(self, computed, monkeypatch): + calls: list = [] + monkeypatch.setattr(BaseFigure, "show", lambda self, *a, **kw: calls.append(self)) + + computed.show() + + assert calls == [computed._fig] + + +class TestGuardsBeforeCompute: + def test_to_png_raises(self, tmp_path): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_png\(\)"): + HeatmapVisualization().to_png(tmp_path / "h.png") + + def test_to_multiqc_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_multiqc\(\)"): + HeatmapVisualization().to_multiqc() + + def test_show_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before show\(\)"): + HeatmapVisualization().show() diff --git a/tests/visualization/plots/test_leaderboard.py b/tests/visualization/plots/test_leaderboard.py new file mode 100644 index 000000000..771f2702b --- /dev/null +++ b/tests/visualization/plots/test_leaderboard.py @@ -0,0 +1,399 @@ +"""Tests for :mod:`drevalpy.visualization.plots.leaderboard`. + +``_create_figure`` updates the global ``plt.rcParams`` with the dark theme and +never restores it. That is asserted as current behaviour, and every test in this +module runs inside an ``rc_context`` so the mutation cannot leak into the rest +of the suite. + +The PCC panel is the plot that broke when it hard-coded ``"Pearson: normalized"`` +while ``normalize()`` emits ``"Pearson"``: the column came out all-NaN and +``set_xlim`` raised ``ValueError: Axis limits cannot be NaN or Inf``. Both halves +of that failure are pinned - the metric-name resolution and the NaN-safe axis. +""" + +from __future__ import annotations + +import logging + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.visualization.plots.leaderboard import ( + COMPETITOR_COLOR, + DARK_THEME, + LeaderboardVisualization, + _axis_bounds, + _build_leaderboard_df, + _figure_geometry, + _get_bar_color, + _get_test_mode_name, + _gradient_char_colors, +) +from tests.synthetic import NORMALIZED_METRIC, REFERENCE_MODEL, make_experiment_result, make_run_result + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +@pytest.fixture(autouse=True) +def _isolate_matplotlib_state(): + """Contain the rcParams mutation in ``_create_figure`` and close figures.""" + with plt.rc_context(): + yield + plt.close("all") + + +@pytest.fixture +def experiment() -> ExperimentResult: + return make_experiment_result() + + +@pytest.fixture +def computed(experiment) -> LeaderboardVisualization: + plot = LeaderboardVisualization() + plot.compute(experiment) + return plot + + +class TestGetBarColor: + @pytest.mark.parametrize( + ("rank", "expected"), + [ + pytest.param(0, "#F4D03F", id="gold"), + pytest.param(1, "#BDC3C7", id="silver"), + pytest.param(2, "#E67E22", id="bronze"), + ], + ) + def test_top_three_get_medal_colors_at_full_opacity(self, rank, expected): + assert _get_bar_color(rank, False) == {"color": expected, "alpha": 1.0} + + def test_fourth_place_onwards_gets_the_competitor_color(self): + assert _get_bar_color(3, False) == {"color": COMPETITOR_COLOR, "alpha": 0.85} + + def test_baselines_are_grey_regardless_of_rank(self): + assert _get_bar_color(0, True) == _get_bar_color(9, True) == {"color": "#5a5a5a", "alpha": 1.0} + + +class TestGradientCharColors: + def test_returns_one_color_per_character(self): + assert len(_gradient_char_colors("DrEval")) == len("DrEval") + + def test_every_color_is_a_six_digit_hex_string(self): + assert all(len(c) == 7 and c.startswith("#") for c in _gradient_char_colors("Leaderboard")) + + def test_gradient_runs_from_teal_to_purple(self): + colors = _gradient_char_colors("Leaderboard") + + assert (colors[0], colors[-1]) == ("#14b8a6", "#9d4edd") + + def test_single_character_avoids_a_zero_division(self): + assert _gradient_char_colors("X") == ["#14b8a6"] + + def test_empty_title_yields_no_colors(self): + assert _gradient_char_colors("") == [] + + +class TestGetTestModeName: + @pytest.mark.parametrize( + ("mode", "expected"), + [ + pytest.param("LCO", "10-Fold Leave-Cell-Out Cross Validation", id="lco"), + pytest.param("LDO", "10-Fold Leave-Drug-Out Cross Validation", id="ldo"), + pytest.param("LPO", "10-Fold Leave-Pair-Out Cross Validation", id="lpo"), + pytest.param("LTO", "10-Fold Leave-Tissue-Out Cross Validation", id="lto"), + ], + ) + def test_known_modes_are_spelled_out(self, mode, expected): + assert _get_test_mode_name(mode) == expected + + def test_unknown_modes_pass_through_unchanged(self): + assert _get_test_mode_name("CROSS_STUDY") == "CROSS_STUDY" + + +class TestBuildLeaderboardDf: + def test_has_one_row_per_model(self, experiment): + df = _build_leaderboard_df(experiment) + + assert len(df) == len(experiment.models) + + def test_columns_are_the_aggregated_schema(self, experiment): + df = _build_leaderboard_df(experiment) + + assert list(df.columns) == ["algorithm", "PCC", "PCC_std", "RMSE", "RMSE_std", "is_baseline"] + + def test_rows_are_ordered_by_descending_normalized_pearson(self, experiment): + df = _build_leaderboard_df(experiment) + + assert df["PCC"].is_monotonic_decreasing + + def test_naive_models_are_flagged_as_baselines(self, experiment): + df = _build_leaderboard_df(experiment).set_index("algorithm") + + assert bool(df.loc["NaiveMeanEffectsPredictor", "is_baseline"]) is True + assert bool(df.loc["ElasticNet", "is_baseline"]) is False + + def test_single_fold_models_get_a_zero_standard_deviation(self): + result = ExperimentResult([make_run_result(model_name="Solo")]) + + df = _build_leaderboard_df(result) + + assert (df["PCC_std"].iloc[0], df["RMSE_std"].iloc[0]) == (0.0, 0.0) + + def test_randomized_runs_are_excluded(self): + result = ExperimentResult( + [ + make_run_result(model_name="ElasticNet", fold_index=0), + make_run_result(model_name="ElasticNet", fold_index=1), + make_run_result(model_name="RandomForest", randomization=("gene_expression", "permutation")), + ] + ) + + df = _build_leaderboard_df(result) + + assert df["algorithm"].tolist() == ["ElasticNet"] + + def test_returns_the_empty_schema_when_every_run_is_randomized(self): + result = ExperimentResult([make_run_result(randomization=("gene_expression", "permutation"))]) + + df = _build_leaderboard_df(result) + + assert df.empty + assert list(df.columns) == ["algorithm", "PCC", "PCC_std", "RMSE", "RMSE_std", "is_baseline"] + + +class TestMetricNameContract: + """``normalize()`` emits plain metric names; the panel must read those.""" + + def test_a_normalized_experiment_yields_finite_pcc_values(self): + normalized = make_experiment_result(n_models=4, n_folds=3).normalize(REFERENCE_MODEL) + + df = _build_leaderboard_df(normalized) + + assert df["PCC"].notna().all() + + def test_the_reference_model_is_gone_from_the_ranking(self): + normalized = make_experiment_result(n_models=4, n_folds=3).normalize(REFERENCE_MODEL) + + df = _build_leaderboard_df(normalized) + + assert REFERENCE_MODEL not in df["algorithm"].tolist() + + def test_the_legacy_suffixed_spelling_is_still_read(self): + """Results serialized by older releases only carry the suffixed key.""" + result = ExperimentResult( + [make_run_result(fold_index=i, metrics={NORMALIZED_METRIC: 0.4 + 0.1 * i, "RMSE": 1.0}) for i in range(2)] + ) + + df = _build_leaderboard_df(result) + + assert df["PCC"].tolist() == [pytest.approx(0.45)] + + def test_the_plain_name_wins_when_both_are_present(self): + result = ExperimentResult([make_run_result(metrics={"Pearson": 0.9, NORMALIZED_METRIC: 0.1, "RMSE": 1.0})]) + + df = _build_leaderboard_df(result) + + assert df["PCC"].tolist() == [pytest.approx(0.9)] + + def test_a_metric_no_run_reports_becomes_nan_rather_than_raising(self): + result = ExperimentResult([make_run_result(metrics={"MSE": 0.5})]) + + df = _build_leaderboard_df(result) + + assert df["PCC"].isna().all() + + +class TestFigureGeometry: + """The 96-model production report overlapped every tick label at a fixed height.""" + + def test_a_small_experiment_keeps_the_original_canvas(self): + height, font_adder, _ = _figure_geometry(12) + + assert (height, font_adder) == (12.0, 6) + + def test_height_grows_with_the_model_count(self): + assert _figure_geometry(96)[0] > _figure_geometry(20)[0] > 12.0 - 1e-9 + + def test_every_model_gets_at_least_a_quarter_inch_of_height(self): + height, _, _ = _figure_geometry(96) + + assert height / 96 > 0.25 + + def test_the_font_shrinks_as_the_list_gets_long(self): + assert _figure_geometry(96)[1] < _figure_geometry(40)[1] < _figure_geometry(10)[1] + + def test_height_is_capped_so_the_png_stays_writable(self): + assert _figure_geometry(10_000)[0] == 60.0 + + +class TestAxisBounds: + """An all-NaN metric column used to take the whole report down.""" + + def test_bounds_stay_finite_when_every_value_is_nan(self): + left, right = _axis_bounds(np.array([np.nan, np.nan]), np.array([np.nan, np.nan])) + + assert np.isfinite([left, right]).all() + assert left < right + + def test_a_nan_standard_deviation_is_treated_as_zero(self): + assert _axis_bounds(np.array([1.0]), np.array([np.nan])) == _axis_bounds(np.array([1.0]), np.array([0.0])) + + def test_the_upper_bound_clears_the_tallest_bar(self): + _, right = _axis_bounds(np.array([0.2, 0.8]), np.array([0.0, 0.1])) + + assert right > 0.9 + + def test_negative_values_are_inside_the_axis(self): + """A normalized correlation below the reference model is negative.""" + left, right = _axis_bounds(np.array([-0.4, 0.3]), np.array([0.0, 0.0])) + + assert left < -0.4 + assert right > 0.3 + + def test_a_single_zero_value_still_yields_an_ordered_axis(self): + left, right = _axis_bounds(np.array([0.0]), np.array([0.0])) + + assert left < right + + +class TestCompute: + def test_builds_two_ranked_panels(self, computed): + assert len(computed._fig.axes) == 2 + + def test_left_panel_ranks_by_pearson(self, computed): + """The experiment is not normalized, so the label must not claim it is.""" + assert computed._fig.axes[0].get_xlabel() == "PCC" + + def test_left_panel_says_normalized_once_it_is(self, experiment): + plot = LeaderboardVisualization() + + plot.compute(experiment.normalize(REFERENCE_MODEL)) + + assert plot._fig.axes[0].get_xlabel() == "Normalized PCC" + assert "Normalized Pearson" in plot._fig.axes[0].get_title() + + def test_right_panel_ranks_by_rmse(self, computed): + assert computed._fig.axes[1].get_xlabel() == "Root Mean Square Error" + + def test_every_model_gets_a_tick_label(self, computed, experiment): + labels = {label.get_text() for label in computed._fig.axes[0].get_yticklabels()} + + assert labels == set(experiment.model_names) + + def test_panels_are_titled_with_the_optimization_direction(self, computed): + titles = [ax.get_title() for ax in computed._fig.axes] + + assert "higher is better" in titles[0] + assert "lower is better" in titles[1] + + def test_stores_the_result(self, computed, experiment): + assert computed._result is experiment + + def test_models_outside_the_podium_keep_the_default_label_color(self): + experiment = make_experiment_result(n_models=5, n_folds=2) + plot = LeaderboardVisualization() + + plot.compute(experiment) + + colors = [label.get_color() for label in plot._fig.axes[0].get_yticklabels()] + assert DARK_THEME["text"] in colors + + def test_falls_back_to_a_placeholder_when_there_is_no_data(self): + result = ExperimentResult([make_run_result(randomization=("gene_expression", "permutation"))]) + plot = LeaderboardVisualization() + + plot.compute(result) + + assert len(plot._fig.axes) == 1 + assert plot._fig.axes[0].texts[0].get_text() == "No data available for leaderboard" + + def test_a_normalized_experiment_renders_both_panels(self, experiment): + """The regression: this used to raise ``Axis limits cannot be NaN or Inf``.""" + plot = LeaderboardVisualization() + + plot.compute(experiment.normalize(REFERENCE_MODEL)) + + assert len(plot._fig.axes) == 2 + + def test_models_without_a_pcc_value_are_dropped_from_that_panel_only(self): + result = ExperimentResult( + [ + make_run_result(model_name="WithPcc", fold_index=i, metrics={"Pearson": 0.5, "RMSE": 1.0}) + for i in range(2) + ] + + [make_run_result(model_name="NoPcc", fold_index=i, metrics={"RMSE": 2.0}) for i in range(2)] + ) + plot = LeaderboardVisualization() + + plot.compute(result) + + pcc_labels = {label.get_text() for label in plot._fig.axes[0].get_yticklabels()} + rmse_labels = {label.get_text() for label in plot._fig.axes[1].get_yticklabels()} + assert pcc_labels == {"WithPcc"} + assert rmse_labels == {"WithPcc", "NoPcc"} + + def test_skips_with_a_warning_when_no_metric_has_a_finite_value(self, caplog): + result = ExperimentResult( + [make_run_result(model_name="A", fold_index=i, metrics={"MSE": 0.5}) for i in range(2)] + ) + plot = LeaderboardVisualization() + + with caplog.at_level(logging.WARNING, logger="drevalpy.visualization.plots.leaderboard"): + plot.compute(result) + + assert plot._fig.axes[0].texts[0].get_text() == "No data available for leaderboard" + assert any("no finite Pearson/RMSE values" in r.getMessage() for r in caplog.records) + + def test_a_large_experiment_gets_a_taller_canvas(self): + """The 96-model report is the case a fixed 12-inch figure could not render.""" + plot = LeaderboardVisualization() + + plot.compute(make_experiment_result(n_models=40, n_folds=2)) + + assert plot._fig.get_size_inches()[1] > 12.0 + + def test_dataset_argument_is_accepted_and_ignored(self, experiment): + plot = LeaderboardVisualization() + + plot.compute(experiment, dataset=object()) + + assert plot._fig is not None + + def test_mutates_the_global_rcparams_with_the_dark_theme(self, experiment): + plt.rcParams["figure.facecolor"] = "white" + + LeaderboardVisualization().compute(experiment) + + assert plt.rcParams["figure.facecolor"] == DARK_THEME["background"] + + +class TestRendering: + def test_to_png_writes_a_png_file(self, computed, tmp_path): + out = tmp_path / "leaderboard.png" + + computed.to_png(out) + + assert out.read_bytes().startswith(PNG_MAGIC) + + def test_to_multiqc_embeds_the_figure_under_the_registry_name(self, computed): + sections = computed.to_multiqc() + + assert len(sections) == 1 + assert (sections[0].name, sections[0].anchor) == ("leaderboard", "leaderboard") + assert sections[0].content is not None + assert "data:image/png;base64," in sections[0].content + + +class TestGuardsBeforeCompute: + def test_to_png_raises(self, tmp_path): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_png\(\)"): + LeaderboardVisualization().to_png(tmp_path / "l.png") + + def test_to_multiqc_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_multiqc\(\)"): + LeaderboardVisualization().to_multiqc() + + def test_show_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before show\(\)"): + LeaderboardVisualization().show() diff --git a/tests/visualization/plots/test_regression_scatter.py b/tests/visualization/plots/test_regression_scatter.py new file mode 100644 index 000000000..715acf5ad --- /dev/null +++ b/tests/visualization/plots/test_regression_scatter.py @@ -0,0 +1,299 @@ +"""Tests for :mod:`drevalpy.visualization.plots.regression_scatter`. + +This is the only plot registered for ``ModelResult`` rather than +``ExperimentResult``, so the report adds one module per model and the anchor must +carry the model name - the ``ImageVisualization`` default anchors on +``registry_name`` alone and would collide. + +The plot is a matplotlib hexbin built on :class:`matplotlib.figure.Figure` +directly, so nothing enters pyplot's global figure registry; that is asserted +explicitly, since a leak there is invisible until a 96-model run exhausts memory. +""" + +from __future__ import annotations + +import matplotlib.pyplot as plt +import numpy as np +import pytest +from matplotlib.collections import PolyCollection +from matplotlib.figure import Figure + +from drevalpy.types.results.model import ModelResult +from drevalpy.visualization.plots.regression_scatter import ( + RegressionScatterVisualization, + _pearson, + _pooled_predictions, +) +from tests.synthetic import DEFAULT_DATASET_NAME, make_model_result, make_run_result + +N_FOLDS = 2 +N_PAIRS = 40 +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +def _model_result(runs) -> ModelResult: + return ModelResult(model_name="ElasticNet", dataset_name=DEFAULT_DATASET_NAME, runs=runs) + + +@pytest.fixture(scope="module") +def model_result() -> ModelResult: + return make_model_result(n_folds=N_FOLDS, n_pairs=N_PAIRS) + + +@pytest.fixture(scope="module") +def computed(model_result) -> RegressionScatterVisualization: + plot = RegressionScatterVisualization() + plot.compute(model_result) + return plot + + +class TestPooledPredictions: + def test_pools_every_fold_into_two_flat_arrays(self, model_result): + truth, prediction = _pooled_predictions(model_result) + + assert truth.shape == prediction.shape == (N_FOLDS * N_PAIRS,) + + def test_arrays_are_float64(self, model_result): + truth, prediction = _pooled_predictions(model_result) + + assert truth.dtype == prediction.dtype == np.float64 + + def test_values_come_from_the_runs(self): + run = make_run_result(n_pairs=5) + + truth, prediction = _pooled_predictions(_model_result([run])) + + np.testing.assert_allclose(truth, run.ground_truth) + np.testing.assert_allclose(prediction, run.predictions) + + def test_pairs_stay_aligned_across_folds(self): + runs = [make_run_result(fold_index=i, n_pairs=4) for i in range(2)] + + truth, prediction = _pooled_predictions(_model_result(runs)) + + np.testing.assert_allclose(truth[:4], runs[0].ground_truth) + np.testing.assert_allclose(truth[4:], runs[1].ground_truth) + + def test_a_nan_on_either_side_drops_the_pair(self): + run = make_run_result(n_pairs=6) + run.predictions[0] = np.nan + run.ground_truth[1] = np.nan + + truth, prediction = _pooled_predictions(_model_result([run])) + + assert truth.size == prediction.size == 4 + + def test_randomized_runs_are_excluded(self): + result = _model_result( + [ + make_run_result(fold_index=0, n_pairs=4), + make_run_result(fold_index=1, n_pairs=4, randomization=("gene_expression", "permutation")), + ] + ) + + truth, _ = _pooled_predictions(result) + + assert truth.size == 4 + + def test_is_empty_when_every_run_is_randomized(self): + result = _model_result([make_run_result(randomization=("gene_expression", "permutation"))]) + + truth, prediction = _pooled_predictions(result) + + assert truth.size == prediction.size == 0 + + def test_retained_size_is_two_floats_per_prediction(self, model_result): + """8 bytes per value, against the 240 bytes a point dict used to cost.""" + truth, prediction = _pooled_predictions(model_result) + + assert truth.nbytes + prediction.nbytes == 16 * N_FOLDS * N_PAIRS + + +class TestPearson: + def test_a_perfect_relationship_is_one(self): + x = np.array([1.0, 2.0, 3.0, 4.0]) + + assert _pearson(x, 2 * x) == pytest.approx(1.0) + + def test_an_inverted_relationship_is_minus_one(self): + x = np.array([1.0, 2.0, 3.0, 4.0]) + + assert _pearson(x, -x) == pytest.approx(-1.0) + + def test_a_single_point_is_nan(self): + assert np.isnan(_pearson(np.array([1.0]), np.array([2.0]))) + + def test_an_empty_input_is_nan(self): + assert np.isnan(_pearson(np.empty(0), np.empty(0))) + + def test_zero_variance_on_either_side_is_nan(self): + varying = np.array([1.0, 2.0, 3.0]) + constant = np.ones(3) + + assert np.isnan(_pearson(constant, varying)) + assert np.isnan(_pearson(varying, constant)) + + +class TestCompute: + def test_builds_a_hexbin_collection(self, computed): + ax = computed._fig.axes[0] + + assert any(isinstance(collection, PolyCollection) for collection in ax.collections) + + def test_draws_the_identity_line(self, computed): + assert len(computed._fig.axes[0].lines) == 1 + + def test_adds_a_density_colorbar(self, computed): + labels = [ax.get_ylabel() for ax in computed._fig.axes[1:]] + + assert any("log scale" in label for label in labels) + + def test_title_names_the_model(self, computed, model_result): + assert model_result.model_name in computed._fig.axes[0].get_title() + + def test_axes_are_labelled_observed_and_predicted(self, computed): + ax = computed._fig.axes[0] + + assert (ax.get_xlabel(), ax.get_ylabel()) == ("Observed", "Predicted") + + def test_both_axes_share_one_square_range(self, computed): + ax = computed._fig.axes[0] + + assert ax.get_xlim() == ax.get_ylim() + + def test_annotates_the_sample_count_and_the_fit(self, computed): + text = computed._fig.axes[0].texts[0].get_text() + + assert f"n = {N_FOLDS * N_PAIRS:,}" in text + assert "Pearson =" in text + assert "R²" in text + + def test_stores_the_result(self, computed, model_result): + assert computed._result is model_result + + def test_stores_the_pooled_arrays_rather_than_a_dataframe(self, computed): + assert isinstance(computed._ground_truth, np.ndarray) + assert isinstance(computed._predictions, np.ndarray) + + def test_dataset_argument_is_accepted_and_ignored(self, model_result): + plot = RegressionScatterVisualization() + + plot.compute(model_result, dataset=object()) + + assert plot._fig is not None + + def test_no_data_falls_back_to_a_placeholder(self): + result = _model_result([make_run_result(randomization=("gene_expression", "permutation"))]) + plot = RegressionScatterVisualization() + + plot.compute(result) + + assert plot._fig.axes[0].texts[0].get_text() == "No data available" + + def test_a_degenerate_single_point_still_renders(self): + run = make_run_result(n_pairs=1) + plot = RegressionScatterVisualization() + + plot.compute(_model_result([run])) + + assert plot._fig.axes[0].get_xlim()[0] < plot._fig.axes[0].get_xlim()[1] + + def test_a_constant_cloud_gets_a_widened_range(self): + run = make_run_result(n_pairs=6) + run.ground_truth[:] = 1.0 + run.predictions[:] = 1.0 + plot = RegressionScatterVisualization() + + plot.compute(_model_result([run])) + + assert plot._fig.axes[0].get_xlim() == (0.5, 1.5) + + def test_recomputing_replaces_the_pooled_arrays(self, model_result): + plot = RegressionScatterVisualization() + plot.compute(model_result) + + plot.compute(_model_result([make_run_result(n_pairs=3)])) + + assert plot._ground_truth.size == 3 + + +class TestPyplotIsNotUsed: + def test_no_figure_enters_the_pyplot_registry(self, model_result): + plt.close("all") + + RegressionScatterVisualization().compute(model_result) + + assert plt.get_fignums() == [] + + def test_the_figure_is_a_plain_matplotlib_figure(self, computed): + assert isinstance(computed._fig, Figure) + assert computed._fig.canvas.manager is None + + +class TestToMultiqc: + def test_returns_a_single_section(self, computed): + assert len(computed.to_multiqc()) == 1 + + def test_the_anchor_carries_the_model_name(self, computed, model_result): + """One module is added per model, so a shared anchor would collide.""" + assert computed.to_multiqc()[0].anchor == f"dreval_scatter_{model_result.model_name}" + + def test_anchors_differ_between_models(self, model_result): + other = make_model_result(model_name="RandomForest", n_folds=1, n_pairs=10) + first, second = RegressionScatterVisualization(), RegressionScatterVisualization() + first.compute(model_result) + second.compute(other) + + assert first.to_multiqc()[0].anchor != second.to_multiqc()[0].anchor + + def test_the_section_is_named_after_the_model(self, computed, model_result): + assert model_result.model_name in computed.to_multiqc()[0].name + + def test_the_description_reports_the_fold_count(self, computed): + assert f"across {N_FOLDS} fold(s)" in computed.to_multiqc()[0].description + + def test_embeds_a_base64_png_rather_than_a_native_plot(self, computed): + section = computed.to_multiqc()[0] + + assert section.plot is None + assert "data:image/png;base64," in section.content + + def test_the_payload_is_an_image_not_a_point_list(self, computed): + """Bounded by the rendered image, not by the number of predictions.""" + content = computed.to_multiqc()[0].content + + assert content.count("base64") == 1 + assert '"x"' not in content + + +class TestRendering: + def test_to_png_writes_a_png_file(self, computed, tmp_path): + out = tmp_path / "rs.png" + + computed.to_png(out) + + assert out.read_bytes().startswith(PNG_MAGIC) + + +class TestGuardsBeforeCompute: + def test_to_png_raises(self, tmp_path): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_png\(\)"): + RegressionScatterVisualization().to_png(tmp_path / "rs.png") + + def test_to_multiqc_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_multiqc\(\)"): + RegressionScatterVisualization().to_multiqc() + + def test_show_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before show\(\)"): + RegressionScatterVisualization().show() + + +class TestRegistration: + def test_keeps_its_registry_name(self): + assert RegressionScatterVisualization.registry_name == "regression_scatter" + + def test_is_still_registered_for_model_results(self): + from drevalpy.registry.visualization import visualization_registry + + assert visualization_registry._result_types["regression_scatter"] == "ModelResult" diff --git a/tests/visualization/plots/test_utils.py b/tests/visualization/plots/test_utils.py new file mode 100644 index 000000000..21a037c41 --- /dev/null +++ b/tests/visualization/plots/test_utils.py @@ -0,0 +1,137 @@ +"""Tests for :mod:`drevalpy.visualization.plots._utils`. + +Per the underscore-stripping naming convention, this mirrors ``plots/_utils.py``. +``runs_frame`` backs the heatmap, violin and cross-study-table plots; the colour +helpers are a public extension point no shipped plot uses yet, so both are +covered here directly. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.visualization.plots._utils import MODEL_COLORS, compute_ssmd, model_color_palette, runs_frame +from tests.synthetic import make_experiment_result, make_run_result + + +@pytest.fixture(scope="module") +def experiment() -> ExperimentResult: + return make_experiment_result() + + +class TestModelColors: + def test_palette_is_ten_distinct_hex_colors(self): + assert len(MODEL_COLORS) == 10 + assert len(set(MODEL_COLORS)) == 10 + + def test_every_entry_is_a_six_digit_hex_string(self): + assert all(len(c) == 7 and c.startswith("#") for c in MODEL_COLORS) + + +class TestModelColorPalette: + def test_assigns_one_color_per_model_in_order(self): + palette = model_color_palette(["A", "B", "C"]) + + assert palette == {"A": MODEL_COLORS[0], "B": MODEL_COLORS[1], "C": MODEL_COLORS[2]} + + def test_colors_are_distinct_up_to_the_palette_size(self): + names = [f"m{i}" for i in range(len(MODEL_COLORS))] + + assert len(set(model_color_palette(names).values())) == len(MODEL_COLORS) + + def test_cycles_when_there_are_more_models_than_colors(self): + names = [f"m{i}" for i in range(len(MODEL_COLORS) + 2)] + + palette = model_color_palette(names) + + assert palette["m10"] == MODEL_COLORS[0] + assert palette["m11"] == MODEL_COLORS[1] + + def test_empty_input_yields_an_empty_palette(self): + assert model_color_palette([]) == {} + + def test_duplicate_names_keep_only_the_last_assignment(self): + palette = model_color_palette(["A", "A"]) + + assert palette == {"A": MODEL_COLORS[1]} + + +class TestComputeSsmd: + def test_is_zero_for_identical_samples(self): + values = [1.0, 2.0, 3.0] + + assert compute_ssmd(values, values) == 0.0 + + def test_is_positive_when_the_first_sample_is_larger(self): + assert compute_ssmd([2.0, 3.0, 4.0], [1.0, 2.0, 3.0]) > 0 + + def test_is_antisymmetric(self): + a, b = [2.0, 3.0, 5.0], [1.0, 2.0, 2.5] + + assert compute_ssmd(a, b) == pytest.approx(-compute_ssmd(b, a)) + + def test_matches_the_closed_form(self): + a = np.array([1.0, 2.0, 3.0]) + b = np.array([0.0, 0.5, 1.5]) + expected = (a.mean() - b.mean()) / math.sqrt(a.var(ddof=1) + b.var(ddof=1)) + + assert compute_ssmd(a, b) == pytest.approx(expected) + + def test_is_nan_when_both_samples_are_constant(self): + assert math.isnan(compute_ssmd([2.0, 2.0], [1.0, 1.0])) + + def test_accepts_integer_sequences(self): + assert compute_ssmd([1, 2, 3], [1, 2, 3]) == 0.0 + + def test_returns_a_builtin_float(self): + assert type(compute_ssmd([1.0, 2.0, 3.0], [0.0, 1.0, 2.0])) is float + + +class TestRunsFrame: + def test_has_one_row_per_run(self, experiment): + assert len(runs_frame(experiment)) == sum(m.n_folds for m in experiment.models) + + def test_carries_the_identity_columns_plus_every_metric(self, experiment): + df = runs_frame(experiment) + + assert list(df.columns[:4]) == ["algorithm", "rand_setting", "test_mode", "CV_split"] + assert {"MSE", "RMSE", "MAE", "R^2", "Pearson", "Spearman", "Kendall"} <= set(df.columns) + + def test_test_mode_comes_from_the_experiment_split_mode(self, experiment): + assert set(runs_frame(experiment)["test_mode"]) == {experiment.split_mode} + + def test_unrandomized_runs_are_labelled_predictions(self, experiment): + assert set(runs_frame(experiment)["rand_setting"]) == {"predictions"} + + def test_randomized_runs_get_a_composite_setting_label(self): + result = ExperimentResult([make_run_result(randomization=("methylation", "invariant"))]) + + assert runs_frame(result)["rand_setting"].tolist() == ["methylation_invariant"] + + def test_defaults_to_a_positional_index(self, experiment): + df = runs_frame(experiment) + + assert list(df.index) == list(range(len(df))) + + def test_indexed_encodes_model_setting_mode_and_split(self, experiment): + df = runs_frame(experiment, indexed=True) + + assert df.index[0] == f"{experiment.model_names[0]}_predictions_LPO_split_0" + + def test_indexed_reuses_the_setting_label_it_puts_in_the_column(self): + result = ExperimentResult([make_run_result(randomization=("gene_expression", "permutation"))]) + + df = runs_frame(result, indexed=True) + + assert df["rand_setting"].tolist() == ["gene_expression_permutation"] + assert "gene_expression_permutation" in df.index[0] + + def test_indexing_does_not_change_the_rows(self, experiment): + plain = runs_frame(experiment) + indexed = runs_frame(experiment, indexed=True) + + assert plain.to_numpy().tolist() == indexed.to_numpy().tolist() diff --git a/tests/visualization/plots/test_violin.py b/tests/visualization/plots/test_violin.py new file mode 100644 index 000000000..f12af2d91 --- /dev/null +++ b/tests/visualization/plots/test_violin.py @@ -0,0 +1,145 @@ +"""Tests for :mod:`drevalpy.visualization.plots.violin`. + +``to_multiqc`` passes a ``pconfig`` without a ``title``, so MultiQC logs a +validation warning. That is existing behaviour and is asserted as such rather +than worked around. +""" + +from __future__ import annotations + +import logging + +import numpy as np +import pytest +from plotly.basedatatypes import BaseFigure + +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.visualization.plots.violin import ViolinVisualization +from tests.synthetic import REFERENCE_MODEL, make_experiment_result, make_run_result + +#: Metrics the plot draws: the seven base metrics, normalized variants excluded. +N_PLOTTED_METRICS = 7 + + +@pytest.fixture(scope="module") +def experiment() -> ExperimentResult: + return make_experiment_result() + + +@pytest.fixture(scope="module") +def computed(experiment) -> ViolinVisualization: + plot = ViolinVisualization() + plot.compute(experiment) + return plot + + +class TestCompute: + def test_draws_one_violin_per_model_and_metric(self, computed, experiment): + assert len(computed._fig.data) == len(experiment.models) * N_PLOTTED_METRICS + + def test_every_trace_is_a_violin(self, computed): + assert {trace.type for trace in computed._fig.data} == {"violin"} + + def test_trace_names_pair_a_model_with_a_metric(self, computed, experiment): + assert computed._fig.data[0].name == f"{experiment.model_names[0]}: R^2" + + def test_normalized_metrics_are_not_drawn(self, computed): + assert not any("normalized" in trace.name for trace in computed._fig.data) + + def test_each_violin_holds_one_point_per_fold(self, computed, experiment): + assert len(computed._fig.data[0].y) == experiment.models[0].n_folds + + def test_violins_show_a_box_and_a_mean_line(self, computed): + trace = computed._fig.data[0] + + assert trace.box.visible is True + assert trace.meanline.visible is True + + def test_layout_is_titled_and_sized(self, computed): + assert computed._fig.layout.title.text == "All Metrics" + assert (computed._fig.layout.height, computed._fig.layout.width) == (600, 1100) + + def test_multiqc_payload_is_keyed_by_model_and_fold(self, computed, experiment): + expected = {f"{model.model_name}_fold{run.fold_index}" for model in experiment.models for run in model.runs} + + assert set(computed._data) == expected + + def test_multiqc_payload_holds_the_raw_per_fold_metrics(self, computed, experiment): + run = experiment.models[0].runs[0] + + assert computed._data[f"{experiment.models[0].model_name}_fold{run.fold_index}"] == run.metrics + + def test_metrics_absent_from_the_result_are_dropped(self): + result = ExperimentResult( + [make_run_result(fold_index=i, metrics={"MSE": 0.5 + i, "Pearson": np.nan}) for i in range(2)] + ) + plot = ViolinVisualization() + + plot.compute(result) + + assert [trace.name for trace in plot._fig.data] == ["ElasticNet: MSE"] + + def test_dataset_argument_is_accepted_and_ignored(self, experiment): + plot = ViolinVisualization() + + plot.compute(experiment, dataset=object()) + + assert plot._fig is not None + + def test_skips_cleanly_with_a_warning_when_no_metric_survives(self, caplog): + """A plot with nothing to show must warn and skip, not emit an empty figure.""" + result = ExperimentResult([make_run_result(fold_index=i, metrics={"Pearson": np.nan}) for i in range(2)]) + plot = ViolinVisualization() + + with caplog.at_level(logging.WARNING, logger="drevalpy.visualization.plots.violin"): + plot.compute(result) + + assert plot._fig.data == () + assert plot.to_multiqc() == [] + assert any("no metric has a finite value" in r.getMessage() for r in caplog.records) + + def test_a_normalized_experiment_still_draws_every_base_metric(self): + normalized = make_experiment_result(n_models=3, n_folds=2).normalize(REFERENCE_MODEL) + plot = ViolinVisualization() + + plot.compute(normalized) + + assert len(plot._fig.data) == len(normalized.models) * N_PLOTTED_METRICS + + +class TestToMultiqc: + def test_returns_a_single_native_violin_section(self, computed): + sections = computed.to_multiqc() + + assert len(sections) == 1 + assert (sections[0].name, sections[0].anchor) == ("Metric Distributions", "dreval_violin") + + def test_section_carries_a_native_multiqc_plot(self, computed): + assert computed.to_multiqc()[0].plot is not None + + def test_section_is_described(self, computed): + assert "Distribution of evaluation metrics" in computed.to_multiqc()[0].description + + +class TestShow: + def test_delegates_to_the_plotly_figure(self, computed, monkeypatch): + calls: list = [] + monkeypatch.setattr(BaseFigure, "show", lambda self, *a, **kw: calls.append(self)) + + computed.show() + + assert calls == [computed._fig] + + +class TestGuardsBeforeCompute: + def test_to_png_raises(self, tmp_path): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_png\(\)"): + ViolinVisualization().to_png(tmp_path / "v.png") + + def test_to_multiqc_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_multiqc\(\)"): + ViolinVisualization().to_multiqc() + + def test_show_raises(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before show\(\)"): + ViolinVisualization().show() diff --git a/tests/visualization/test_base.py b/tests/visualization/test_base.py new file mode 100644 index 000000000..c8b4b7edd --- /dev/null +++ b/tests/visualization/test_base.py @@ -0,0 +1,258 @@ +"""Tests for :mod:`drevalpy.visualization.base`. + +``ImageVisualization`` supplies ``to_png``/``to_multiqc``/``show`` to every +matplotlib-backed plot and ``PlotlyVisualization`` supplies ``to_png``/``show`` +to every Plotly-backed one, so their ``RuntimeError`` guards and the base64 +embedding are covered here once via stub subclasses rather than repeatedly in +each plot's own module. +""" + +from __future__ import annotations + +import base64 +import sys +import types + +import matplotlib.pyplot as plt +import pytest + +from drevalpy.visualization.base import ( + ImageVisualization, + PlotlyVisualization, + Section, + Visualization, + embedded_png_html, + require_figure, +) + +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" + + +class _StubImagePlot(ImageVisualization): + """Smallest possible ImageVisualization: a one-line figure.""" + + registry_name = "stub_image" + + def compute(self, result=None, dataset=None) -> None: + self._fig = self._create_figure() + + def _create_figure(self): + fig, ax = plt.subplots(figsize=(1, 1)) + ax.plot([0, 1], [0, 1]) + return fig + + +class _RecordingFigure: + """Stand-in for a Plotly figure that records what the base class asked of it.""" + + def __init__(self) -> None: + self.written: list[str] = [] + self.shown = 0 + + def write_image(self, path: str) -> None: + self.written.append(path) + + def show(self) -> None: + self.shown += 1 + + +class _StubPlotlyPlot(PlotlyVisualization): + """Smallest possible PlotlyVisualization.""" + + registry_name = "stub_plotly" + + def compute(self, result=None, dataset=None) -> None: + self._fig = _RecordingFigure() + + def to_multiqc(self) -> list[Section]: + return [] + + +@pytest.fixture(autouse=True) +def _close_figures(): + yield + plt.close("all") + + +@pytest.fixture +def stub() -> _StubImagePlot: + return _StubImagePlot() + + +@pytest.fixture +def displayed(monkeypatch: pytest.MonkeyPatch) -> list: + """Install a recording ``IPython.display.display`` and return what it was handed. + + The stub replaces every real ``IPython*`` entry rather than only filling gaps: + IPython 9 is a real dependency of the dev environment, so leaving it reachable + would make the result depend on whether an earlier test imported it. + + It also has to look enough like IPython for matplotlib, which reads + ``sys.modules["IPython"].version_info`` (and ``get_ipython()``) once per process + the first time a canvas is created. A bare ``ModuleType`` made that one-off probe + raise ``AttributeError`` whenever this test happened to draw the first figure in + the process - which is why it passed serially and failed under ``-n auto``. + """ + recorded: list = [] + ipython = types.ModuleType("IPython") + display_mod = types.ModuleType("IPython.display") + display_mod.display = recorded.append # type: ignore[attr-defined] + ipython.display = display_mod # type: ignore[attr-defined] + ipython.version_info = (9, 15, 0, "") # type: ignore[attr-defined] + ipython.get_ipython = lambda: None # type: ignore[attr-defined] + for name in [n for n in sys.modules if n == "IPython" or n.startswith("IPython.")]: + monkeypatch.delitem(sys.modules, name) + monkeypatch.setitem(sys.modules, "IPython", ipython) + monkeypatch.setitem(sys.modules, "IPython.display", display_mod) + return recorded + + +class TestSection: + def test_optional_fields_default_to_empty(self): + section = Section(name="Some plot", anchor="some_plot") + + assert (section.description, section.plot, section.content) == ("", None, None) + + def test_carries_a_native_plot_object(self): + sentinel = object() + + section = Section(name="n", anchor="a", description="d", plot=sentinel) + + assert section.plot is sentinel + + def test_equality_is_by_value(self): + assert Section(name="n", anchor="a") == Section(name="n", anchor="a") + + +class TestVisualizationContract: + def test_cannot_be_instantiated(self): + with pytest.raises(TypeError): + Visualization() # type: ignore[abstract] + + def test_declares_the_full_abstract_surface(self): + assert Visualization.__abstractmethods__ == frozenset({"compute", "to_png", "to_multiqc", "show"}) + + def test_registry_name_defaults_to_empty(self): + assert Visualization.registry_name == "" + + def test_image_visualization_adds_only_create_figure(self): + assert ImageVisualization.__abstractmethods__ == frozenset({"compute", "_create_figure"}) + + def test_plotly_visualization_leaves_compute_and_to_multiqc_abstract(self): + assert PlotlyVisualization.__abstractmethods__ == frozenset({"compute", "to_multiqc"}) + + +class TestRequireFigure: + def test_returns_the_figure_untouched(self): + sentinel = object() + + assert require_figure(sentinel, "to_png") is sentinel + + def test_names_the_caller_in_the_error(self): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before some_method\(\)"): + require_figure(None, "some_method") + + +class TestEmbeddedPngHtml: + def test_wraps_the_figure_in_an_img_data_uri(self, stub): + stub.compute() + + html = embedded_png_html(stub._fig) + + assert html.startswith('<img src="data:image/png;base64,') + + def test_payload_decodes_to_a_png(self, stub): + stub.compute() + + payload = embedded_png_html(stub._fig).split("base64,", 1)[1].split('"', 1)[0] + + assert base64.b64decode(payload).startswith(PNG_MAGIC) + + +class TestGuardsBeforeCompute: + def test_to_png_raises(self, stub, tmp_path): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_png\(\)"): + stub.to_png(tmp_path / "out.png") + + def test_to_multiqc_raises(self, stub): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_multiqc\(\)"): + stub.to_multiqc() + + def test_show_raises(self, stub): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before show\(\)"): + stub.show() + + +class TestImageVisualizationRendering: + def test_to_png_writes_a_png_file(self, stub, tmp_path): + stub.compute() + out = tmp_path / "out.png" + + stub.to_png(out) + + assert out.read_bytes().startswith(PNG_MAGIC) + + def test_to_png_accepts_a_string_path(self, stub, tmp_path): + stub.compute() + out = tmp_path / "str.png" + + stub.to_png(str(out)) + + assert out.exists() + + def test_to_multiqc_returns_one_section_named_after_the_registry(self, stub): + stub.compute() + + sections = stub.to_multiqc() + + assert [(s.name, s.anchor) for s in sections] == [("stub_image", "stub_image")] + + def test_to_multiqc_embeds_the_figure_as_base64_png(self, stub): + stub.compute() + + content = stub.to_multiqc()[0].content + + assert content is not None + payload = content.split("base64,", 1)[1].split('"', 1)[0] + assert base64.b64decode(payload).startswith(PNG_MAGIC) + + def test_to_multiqc_section_carries_no_native_plot(self, stub): + stub.compute() + + assert stub.to_multiqc()[0].plot is None + + def test_show_delegates_to_ipython_display(self, stub, displayed): + stub.compute() + + stub.show() + + assert displayed == [stub._fig] + + +class TestPlotlyVisualizationRendering: + @pytest.fixture + def plotly_stub(self) -> _StubPlotlyPlot: + return _StubPlotlyPlot() + + def test_to_png_raises_before_compute(self, plotly_stub, tmp_path): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before to_png\(\)"): + plotly_stub.to_png(tmp_path / "out.png") + + def test_show_raises_before_compute(self, plotly_stub): + with pytest.raises(RuntimeError, match=r"Call compute\(\) before show\(\)"): + plotly_stub.show() + + def test_to_png_delegates_to_write_image_with_a_string_path(self, plotly_stub, tmp_path): + plotly_stub.compute() + out = tmp_path / "out.png" + + plotly_stub.to_png(out) + + assert plotly_stub._fig.written == [str(out)] + + def test_show_delegates_to_the_figure(self, plotly_stub): + plotly_stub.compute() + + plotly_stub.show() + + assert plotly_stub._fig.shown == 1 diff --git a/tests/visualization/test_metric_names.py b/tests/visualization/test_metric_names.py new file mode 100644 index 000000000..793808e91 --- /dev/null +++ b/tests/visualization/test_metric_names.py @@ -0,0 +1,90 @@ +"""Tests for :mod:`drevalpy.visualization._metric_names`. + +These functions encode the metric-naming contract between +:meth:`~drevalpy.types.results.experiment.ExperimentResult.normalize`, which +recomputes metrics under their plain names, and the plots that consume them. +Getting it wrong produced an all-NaN leaderboard column and a crash in +``set_xlim``, so the resolution order is pinned here rather than only through the +plots. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.visualization._metric_names import ( + NORMALIZED_SUFFIX, + holds_normalized_values, + metric_keys, + resolve_metric_key, +) +from tests.synthetic import REFERENCE_MODEL, make_experiment_result, make_model_result, make_run_result + + +class TestNormalizedSuffix: + def test_matches_the_legacy_column_spelling(self): + assert f"Pearson{NORMALIZED_SUFFIX}" == "Pearson: normalized" + + +class TestMetricKeys: + def test_collects_the_keys_of_an_experiment(self): + experiment = make_experiment_result(n_models=2, n_folds=2) + + assert "Pearson" in metric_keys(experiment) + + def test_accepts_a_model_result(self): + model = make_model_result(n_folds=2) + + assert metric_keys(model) == set(model.runs[0].metrics) + + def test_unions_keys_that_differ_between_runs(self): + from drevalpy.types.results.experiment import ExperimentResult + + result = ExperimentResult( + [ + make_run_result(model_name="A", metrics={"MSE": 1.0}), + make_run_result(model_name="B", metrics={"Pearson": 0.5}), + ] + ) + + assert metric_keys(result) == {"MSE", "Pearson"} + + +class TestResolveMetricKey: + def test_the_plain_name_is_returned_when_present(self): + assert resolve_metric_key({"Pearson", "Pearson: normalized"}, "Pearson") == "Pearson" + + def test_falls_back_to_the_suffixed_name(self): + assert resolve_metric_key({"Pearson: normalized"}, "Pearson") == "Pearson: normalized" + + def test_returns_none_when_the_metric_is_absent(self): + assert resolve_metric_key({"MSE"}, "Pearson") is None + + def test_accepts_any_iterable_of_names(self): + assert resolve_metric_key(iter(["RMSE"]), "RMSE") == "RMSE" + + @pytest.mark.parametrize("base", ["MSE", "RMSE", "MAE", "R^2", "Pearson", "Spearman", "Kendall"]) + def test_every_reported_metric_resolves_on_a_normalized_experiment(self, base): + normalized = make_experiment_result(n_models=3, n_folds=2).normalize(REFERENCE_MODEL) + + assert resolve_metric_key(metric_keys(normalized), base) == base + + +class TestHoldsNormalizedValues: + def test_true_for_a_normalized_experiment_under_the_plain_name(self): + normalized = make_experiment_result(n_models=3, n_folds=2).normalize(REFERENCE_MODEL) + + assert holds_normalized_values(normalized, "Pearson") is True + + def test_true_for_the_suffixed_key_regardless_of_the_container(self): + experiment = make_experiment_result(n_models=2, n_folds=2) + + assert holds_normalized_values(experiment, "Pearson: normalized") is True + + def test_false_for_a_plain_key_on_an_unnormalized_experiment(self): + experiment = make_experiment_result(n_models=2, n_folds=2) + + assert holds_normalized_values(experiment, "Pearson") is False + + def test_false_for_a_model_result_which_tracks_no_reference(self): + assert holds_normalized_values(make_model_result(n_folds=2), "Pearson") is False diff --git a/tests/visualization/test_progress.py b/tests/visualization/test_progress.py new file mode 100644 index 000000000..4b5280475 --- /dev/null +++ b/tests/visualization/test_progress.py @@ -0,0 +1,189 @@ +"""Tests for :mod:`drevalpy.visualization._progress`. + +The readers take their ``/proc`` and cgroup paths as arguments precisely so these +tests can point them at fixture files; nothing here reads the host's real +``/proc/self/status`` except the two probes that assert the live path still works. +""" + +from __future__ import annotations + +import logging + +import pytest +from upath import UPath + +from drevalpy.visualization import _progress + +_STATUS = """Name:\tpython3 +VmPeak:\t 8388608 kB +VmSize:\t 8388608 kB +VmHWM:\t 3145728 kB +VmRSS:\t 2097152 kB +Threads:\t1 +""" + + +@pytest.fixture() +def status_file(tmp_path: UPath) -> str: + """A ``/proc/<pid>/status`` fixture reporting 2 GiB RSS and a 3 GiB high-water mark.""" + path = UPath(tmp_path) / "status" + path.write_text(_STATUS) + return str(path) + + +def _write(tmp_path: UPath, name: str, text: str) -> str: + path = UPath(tmp_path) / name + path.write_text(text) + return str(path) + + +class TestReadStatus: + def test_reads_the_requested_field_in_kib(self, status_file: str) -> None: + assert _progress._read_status_kib("VmRSS", status_file) == 2097152 + + def test_does_not_confuse_vmpeak_with_vmsize(self, status_file: str) -> None: + assert _progress._read_status_kib("VmSize", status_file) == 8388608 + + def test_missing_field_is_none(self, status_file: str) -> None: + assert _progress._read_status_kib("VmSwap", status_file) is None + + def test_missing_file_is_none(self, tmp_path: UPath) -> None: + assert _progress._read_status_kib("VmRSS", str(UPath(tmp_path) / "absent")) is None + + def test_unparseable_value_is_none(self, tmp_path: UPath) -> None: + path = _write(tmp_path, "status", "VmRSS:\tnot-a-number kB\n") + + assert _progress._read_status_kib("VmRSS", path) is None + + def test_field_without_a_value_is_none(self, tmp_path: UPath) -> None: + path = _write(tmp_path, "status", "VmRSS:\n") + + assert _progress._read_status_kib("VmRSS", path) is None + + +class TestRss: + def test_current_rss_comes_from_vmrss(self, status_file: str) -> None: + assert _progress.rss_gb(status_file) == pytest.approx(2.0) + + def test_peak_rss_comes_from_vmhwm(self, status_file: str) -> None: + assert _progress.peak_rss_gb(status_file) == pytest.approx(3.0) + + def test_rss_falls_back_to_rusage_without_proc(self, tmp_path: UPath) -> None: + assert _progress.rss_gb(str(UPath(tmp_path) / "absent")) > 0 + + def test_peak_falls_back_to_rusage_without_proc(self, tmp_path: UPath) -> None: + assert _progress.peak_rss_gb(str(UPath(tmp_path) / "absent")) > 0 + + def test_rusage_is_normalised_to_kib_on_linux(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_progress.sys, "platform", "linux") + + linux = _progress._rusage_max_rss_gb() + + monkeypatch.setattr(_progress.sys, "platform", "darwin") + assert linux == pytest.approx(_progress._rusage_max_rss_gb() * 1024) + + def test_the_live_proc_path_still_reports_a_positive_rss(self) -> None: + assert _progress.rss_gb() > 0 + + +class TestMemoryLimit: + def test_reads_cgroup_v2_bytes(self, tmp_path: UPath) -> None: + v2 = _write(tmp_path, "memory.max", str(36 * 1024**3)) + + assert _progress.memory_limit_gb(v2, str(UPath(tmp_path) / "absent")) == pytest.approx(36.0) + + def test_falls_back_to_cgroup_v1(self, tmp_path: UPath) -> None: + v1 = _write(tmp_path, "limit_in_bytes", str(8 * 1024**3)) + + assert _progress.memory_limit_gb(str(UPath(tmp_path) / "absent"), v1) == pytest.approx(8.0) + + def test_the_literal_max_is_not_a_limit(self, tmp_path: UPath) -> None: + v2 = _write(tmp_path, "memory.max", "max\n") + v1 = _write(tmp_path, "limit_in_bytes", str(4 * 1024**3)) + + assert _progress.memory_limit_gb(v2, v1) == pytest.approx(4.0) + + def test_an_unbounded_sentinel_is_not_a_limit(self, tmp_path: UPath) -> None: + v2 = _write(tmp_path, "memory.max", str(2**63 - 1)) + + assert _progress.memory_limit_gb(v2, str(UPath(tmp_path) / "absent")) is None + + def test_an_empty_file_is_not_a_limit(self, tmp_path: UPath) -> None: + v2 = _write(tmp_path, "memory.max", "\n") + + assert _progress.memory_limit_gb(v2, str(UPath(tmp_path) / "absent")) is None + + def test_an_unparseable_value_is_not_a_limit(self, tmp_path: UPath) -> None: + v2 = _write(tmp_path, "memory.max", "unlimited\n") + + assert _progress.memory_limit_gb(v2, str(UPath(tmp_path) / "absent")) is None + + def test_both_paths_absent_is_none(self, tmp_path: UPath) -> None: + absent = str(UPath(tmp_path) / "absent") + + assert _progress.memory_limit_gb(absent, absent) is None + + +class TestFormatStage: + def test_names_the_stage_and_both_memory_figures(self, status_file: str, tmp_path: UPath) -> None: + absent = str(UPath(tmp_path) / "absent") + + line = _progress.format_stage("load", status_path=status_file, v2_path=absent, v1_path=absent) + + assert line == "load | rss=2.00 GB peak=3.00 GB" + + def test_reports_the_limit_and_headroom_when_capped(self, status_file: str, tmp_path: UPath) -> None: + v2 = _write(tmp_path, "memory.max", str(12 * 1024**3)) + + line = _progress.format_stage("load", status_path=status_file, v2_path=v2, v1_path=v2) + + assert line == "load | rss=2.00 GB peak=3.00 GB limit=12.00 GB (25%)" + + def test_a_zero_limit_is_not_divided_by(self, status_file: str, tmp_path: UPath) -> None: + v2 = _write(tmp_path, "memory.max", "0") + + line = _progress.format_stage("load", status_path=status_file, v2_path=v2, v1_path=v2) + + assert "limit=" not in line + + +class TestLogStage: + def test_emits_one_info_record_carrying_the_summary( + self, status_file: str, tmp_path: UPath, caplog: pytest.LogCaptureFixture + ) -> None: + absent = str(UPath(tmp_path) / "absent") + logger = logging.getLogger("drevalpy.tests.progress") + + with caplog.at_level(logging.INFO, logger=logger.name): + _progress.log_stage(logger, "plot heatmap", status_path=status_file, v2_path=absent, v1_path=absent) + + assert [r.getMessage() for r in caplog.records] == ["plot heatmap | rss=2.00 GB peak=3.00 GB"] + + def test_honours_the_requested_level( + self, status_file: str, tmp_path: UPath, caplog: pytest.LogCaptureFixture + ) -> None: + absent = str(UPath(tmp_path) / "absent") + logger = logging.getLogger("drevalpy.tests.progress") + + with caplog.at_level(logging.DEBUG, logger=logger.name): + _progress.log_stage( + logger, + "load", + level=logging.DEBUG, + status_path=status_file, + v2_path=absent, + v1_path=absent, + ) + + assert caplog.records[0].levelno == logging.DEBUG + + def test_uses_lazy_formatting_so_the_message_is_the_argument( + self, status_file: str, tmp_path: UPath, caplog: pytest.LogCaptureFixture + ) -> None: + absent = str(UPath(tmp_path) / "absent") + logger = logging.getLogger("drevalpy.tests.progress") + + with caplog.at_level(logging.INFO, logger=logger.name): + _progress.log_stage(logger, "load", status_path=status_file, v2_path=absent, v1_path=absent) + + assert caplog.records[0].msg == "%s" diff --git a/tests/visualization/test_report.py b/tests/visualization/test_report.py new file mode 100644 index 000000000..f3c0677a2 --- /dev/null +++ b/tests/visualization/test_report.py @@ -0,0 +1,413 @@ +"""Tests for :mod:`drevalpy.visualization.report`. + +One end-to-end ``create_report`` run is enough to cover the MultiQC wiring; the +remaining orchestration branches are driven with a stub visualization so the +suite does not pay for seven real plots per assertion. + +A ``create_report`` call costs ~0.25s with a stub plot and ~0.7s with the real +ones, so the tests that only *read* what a build produced share one build through +a module-scoped fixture. Tests whose subject is the call itself - a different +argument, a log line ordering, or a lifecycle side effect - keep their own run and +say so in place. +""" + +from __future__ import annotations + +import gc +import logging +import weakref +from collections.abc import Iterator +from typing import Any, NamedTuple + +import multiqc +import pytest +from matplotlib import pyplot as plt +from upath import UPath + +from drevalpy.registry.visualization import visualization_registry +from drevalpy.types.results.experiment import ExperimentResult +from drevalpy.visualization.base import Section, Visualization +from drevalpy.visualization.report import ( + _add_module, + _ensure_experiment, + _run_visualization, + create_report, + save_all_png, +) +from tests.synthetic import REFERENCE_MODEL, make_experiment_result, make_model_result, make_run_result + + +class _StubViz(Visualization): + """Records what it was asked to compute and emits one trivial section.""" + + registry_name = "stub_viz" + sections: list[Section] = [Section(name="Stub", anchor="stub_section", content="<p>stub</p>")] + #: The most recently constructed instance, so tests can inspect what the + #: report built internally. + last: Any = None + + def __init__(self) -> None: + self.computed: list[tuple[Any, Any]] = [] + type(self).last = self + + def compute(self, result, dataset=None) -> None: + self.computed.append((result, dataset)) + + def to_png(self, path) -> None: + UPath(path).write_bytes(b"stub-png") + + def to_multiqc(self) -> list[Section]: + return list(self.sections) + + def show(self) -> None: # pragma: no cover - not part of the report path + raise AssertionError("show() is not used by the report") + + +class _SilentViz(_StubViz): + """A visualization that has nothing to contribute.""" + + registry_name = "silent_viz" + sections: list[Section] = [] + + +@pytest.fixture(scope="module", autouse=True) +def _isolate_multiqc_config() -> Iterator[None]: + """Contain MultiQC's global config to this module. + + ``multiqc.reset()`` is ``config.reset()`` plus ``report.reset()``, and only the + first is expensive (~40ms, it re-reads MultiQC's packaged YAML defaults). Doing + it per test was a third of this file's runtime while no assertion here reads the + config, and ``create_report`` resets it itself before every build. So the config + is reset once at each end of the module and the per-test fixture below clears + only the report state the assertions actually read. + """ + multiqc.reset() + yield + multiqc.reset() + + +@pytest.fixture(autouse=True) +def _reset_multiqc() -> Iterator[None]: + """Isolate MultiQC report state and contain the leaderboard's rcParams edits.""" + multiqc.report.reset() + with plt.rc_context(): + yield + plt.close("all") + multiqc.report.reset() + + +@pytest.fixture(scope="module") +def experiment() -> ExperimentResult: + return make_experiment_result() + + +@pytest.fixture +def only_stub_is_applicable(monkeypatch) -> type[_StubViz]: + """Restrict the report to a single cheap visualization.""" + monkeypatch.setattr(visualization_registry, "applicable", lambda experiment: [_StubViz]) + return _StubViz + + +class _BuiltReport(NamedTuple): + """What a shared ``create_report`` build left behind. + + ``modules`` is a snapshot taken while the build was still current, because the + per-test fixture clears ``multiqc.report`` before the tests that read it run. + """ + + out: UPath + modules: list[Any] + records: list[logging.LogRecord] + + +def _build_report(out: UPath, result, **kwargs) -> _BuiltReport: + """Run ``create_report`` once, capturing its MultiQC modules and log records. + + Repeats the containment the per-test fixtures provide - a fresh MultiQC state, an + ``rc_context`` around the leaderboard's rcParams edits, and closing the figures - + because a module-scoped fixture is set up before any of them. + + MultiQC re-initialises root logging inside ``write_report``, so the records are + collected from the module logger directly rather than through ``caplog``. + """ + records: list[logging.LogRecord] = [] + handler = logging.Handler() + handler.emit = records.append # type: ignore[method-assign] + logger = logging.getLogger("drevalpy.visualization.report") + logger.addHandler(handler) + previous_level = logger.level + logger.setLevel(logging.INFO) + multiqc.reset() + try: + with plt.rc_context(): + create_report(result, out, **kwargs) + modules = list(multiqc.report.modules) + finally: + logger.setLevel(previous_level) + logger.removeHandler(handler) + plt.close("all") + return _BuiltReport(out, modules, records) + + +@pytest.fixture(scope="module") +def normalized_real_report(tmp_path_factory) -> _BuiltReport: + """One normalized report built from the real plots, shared read-only. + + ``normalize()`` recomputes metrics under their plain names, which the + leaderboard did not read; its PCC column came out all-NaN and matplotlib raised + ``Axis limits cannot be NaN or Inf`` before MultiQC ever ran. The pipeline always + passes ``--reference-model``, so a build failure here fails both readers below. + """ + return _build_report( + tmp_path_factory.mktemp("normalized_report"), + make_experiment_result(n_models=4, n_folds=3), + title="Normalized", + reference_model=REFERENCE_MODEL, + ) + + +@pytest.fixture(scope="module") +def logged_stub_report(experiment: ExperimentResult, tmp_path_factory) -> _BuiltReport: + """One stub-plot report build, shared by the tests that only read its log.""" + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr(visualization_registry, "applicable", lambda experiment: [_StubViz]) + return _build_report(tmp_path_factory.mktemp("logged"), experiment) + + +class TestEnsureExperiment: + def test_wraps_a_single_run(self): + run = make_run_result(model_name="ElasticNet") + + experiment = _ensure_experiment(run) + + assert isinstance(experiment, ExperimentResult) + assert experiment.model_names == ["ElasticNet"] + + def test_wraps_a_model_result_keeping_every_fold(self): + model = make_model_result(n_folds=3) + + experiment = _ensure_experiment(model) + + assert isinstance(experiment, ExperimentResult) + assert experiment.max_folds == 3 + + def test_passes_an_experiment_through_unchanged(self, experiment): + assert _ensure_experiment(experiment) is experiment + + +class TestAddModule: + def test_appends_one_module_carrying_the_sections(self): + _add_module([Section(name="S", anchor="s", content="x")], "My module", "my_module") + + assert [m.name for m in multiqc.report.modules] == ["My module"] + assert [s.name for s in multiqc.report.modules[0].sections] == ["S"] + + def test_sections_without_content_become_empty_strings(self): + _add_module([Section(name="S", anchor="s")], "My module", "my_module") + + assert multiqc.report.modules[0].sections[0].content == "" + + +class TestRunVisualization: + def test_experiment_plots_are_computed_once(self, experiment): + viz = _StubViz() + + _run_visualization(viz, experiment, "ExperimentResult") + + assert [result for result, _ in viz.computed] == [experiment] + + def test_experiment_plots_add_one_module_named_after_the_registry(self, experiment): + _run_visualization(_StubViz(), experiment, "ExperimentResult") + + assert [m.name for m in multiqc.report.modules] == ["stub_viz"] + + def test_model_plots_are_computed_once_per_model(self, experiment): + viz = _StubViz() + + _run_visualization(viz, experiment, "ModelResult") + + assert [result.model_name for result, _ in viz.computed] == experiment.model_names + + def test_model_plots_add_one_module_per_model(self, experiment): + _run_visualization(_StubViz(), experiment, "ModelResult") + + assert [m.name for m in multiqc.report.modules] == [f"stub_viz ({name})" for name in experiment.model_names] + + def test_the_dataset_is_forwarded_to_compute(self, experiment): + dataset = object() + viz = _StubViz() + + _run_visualization(viz, experiment, "ExperimentResult", dataset=dataset) + + assert viz.computed[0][1] is dataset + + def test_visualizations_without_sections_add_no_module(self, experiment): + _run_visualization(_SilentViz(), experiment, "ExperimentResult") + + assert multiqc.report.modules == [] + + def test_silent_model_plots_add_no_module(self, experiment): + _run_visualization(_SilentViz(), experiment, "ModelResult") + + assert multiqc.report.modules == [] + + +class TestCreateReport: + #: Extended tier: nine real ``create_report`` builds, ~3.2s. The shared + #: module-scoped build below means the cost only disappears when the whole + #: class goes, which is why the marker is here rather than on single tests. + pytestmark = pytest.mark.slow + + def test_writes_a_multiqc_html_report_for_every_applicable_plot(self, experiment, tmp_path): + out = tmp_path / "report" + + create_report(experiment, out, title="W4 Report") + + assert [p.name for p in out.glob("*.html")] == ["W4-Report_multiqc_report.html"] + + def test_creates_missing_output_directories(self, experiment, tmp_path, only_stub_is_applicable): + out = tmp_path / "deep" / "nested" + + create_report(experiment, out) + + assert out.is_dir() + + def test_accepts_a_string_output_directory(self, experiment, tmp_path, only_stub_is_applicable): + out = tmp_path / "as_string" + + create_report(experiment, str(out)) + + assert any(out.glob("*.html")) + + def test_wraps_a_bare_run_result(self, tmp_path, only_stub_is_applicable): + run = make_run_result(model_name="ElasticNet") + + create_report(run, tmp_path / "run_report") + + assert only_stub_is_applicable.last.computed[0][0].model_names == ["ElasticNet"] + + def test_normalizes_against_the_reference_model_when_asked(self, experiment, tmp_path, only_stub_is_applicable): + create_report(experiment, tmp_path / "normalized", reference_model=REFERENCE_MODEL) + + normalized = only_stub_is_applicable.last.computed[0][0] + assert normalized.normalized_by == REFERENCE_MODEL + assert REFERENCE_MODEL not in normalized.model_names + + def test_forwards_the_dataset_to_the_plots(self, experiment, tmp_path, only_stub_is_applicable): + dataset = object() + + create_report(experiment, tmp_path / "with_dataset", dataset=dataset) + + assert only_stub_is_applicable.last.computed[0][1] is dataset + + def test_releases_the_unnormalized_experiment(self, tmp_path, only_stub_is_applicable): + """The pre-normalization copy must not stay reachable through the argument.""" + sentinel: list[weakref.ref] = [] + + def built_inline() -> ExperimentResult: + experiment = make_experiment_result(n_models=2, n_folds=1) + sentinel.append(weakref.ref(experiment)) + return experiment + + create_report(built_inline(), tmp_path / "released", reference_model=REFERENCE_MODEL) + gc.collect() + + assert sentinel[0]() is None + + def test_every_real_plot_survives_normalization(self, normalized_real_report: _BuiltReport) -> None: + assert [p.name for p in normalized_real_report.out.glob("*.html")] == ["Normalized_multiqc_report.html"] + + def test_the_normalized_leaderboard_renders_with_real_values(self, normalized_real_report: _BuiltReport) -> None: + """A section is worthless if it is present but blank.""" + leaderboard = next(m for m in normalized_real_report.modules if m.name == "leaderboard") + assert "No data available" not in leaderboard.sections[0].content + assert "data:image/png;base64," in leaderboard.sections[0].content + + +class TestLogging: + """The report used to die with an empty ``Command output``; these lines are the fix. + + Extended tier: several tests here keep their own ``create_report`` build because + the call itself is the subject, so the class costs ~0.6s beyond the shared build + in ``TestCreateReport``. + """ + + pytestmark = pytest.mark.slow + + def test_logs_the_model_count_and_the_pair_count(self, logged_stub_report: _BuiltReport) -> None: + messages = [r.getMessage() for r in logged_stub_report.records] + assert any("3 models (3 model pairs)" in m for m in messages) + + def test_logs_the_reference_model_before_normalizing(self, experiment, tmp_path, only_stub_is_applicable, caplog): + # Keeps its own build: it is the ``reference_model`` argument that is on trial. + with caplog.at_level(logging.INFO, logger="drevalpy.visualization.report"): + create_report(experiment, tmp_path / "logged_ref", reference_model=REFERENCE_MODEL) + + messages = [r.getMessage() for r in caplog.records] + assert any(f"Normalizing against reference model '{REFERENCE_MODEL}'" in m for m in messages) + + def test_names_each_plot_before_computing_it(self, experiment, only_stub_is_applicable, caplog): + with caplog.at_level(logging.INFO, logger="drevalpy.visualization.report"): + _run_visualization(_StubViz(), experiment, "ExperimentResult") + + assert caplog.records[0].getMessage().startswith("plot stub_viz: computing | rss=") + + def test_reports_per_model_progress(self, experiment, caplog): + with caplog.at_level(logging.INFO, logger="drevalpy.visualization.report"): + _run_visualization(_StubViz(), experiment, "ModelResult") + + messages = [r.getMessage() for r in caplog.records] + assert any("model 1/3" in m for m in messages) + assert any("model 3/3" in m for m in messages) + + def test_reports_elapsed_time_and_memory_delta_after_each_plot(self, experiment, caplog): + with caplog.at_level(logging.INFO, logger="drevalpy.visualization.report"): + _run_visualization(_StubViz(), experiment, "ExperimentResult") + + assert "plot stub_viz: done in" in caplog.records[-1].getMessage() + + def test_logs_the_module_and_section_counts_before_writing(self, logged_stub_report: _BuiltReport) -> None: + messages = [r.getMessage() for r in logged_stub_report.records] + assert any("report: writing 1 modules / 1 sections" in m for m in messages) + + def test_confirms_the_report_was_written(self, logged_stub_report: _BuiltReport) -> None: + assert logged_stub_report.records[-1].getMessage().startswith("report: written | rss=") + + +class TestSaveAllPng: + def test_writes_one_png_per_experiment_level_plot(self, experiment, tmp_path, only_stub_is_applicable): + out = tmp_path / "pngs" + + save_all_png(experiment, out) + + assert [p.name for p in out.iterdir()] == ["stub_viz.png"] + + def test_writes_one_png_per_model_for_model_level_plots(self, experiment, tmp_path, monkeypatch): + monkeypatch.setattr(visualization_registry, "applicable", lambda exp: [_StubViz]) + monkeypatch.setitem(visualization_registry._result_types, "stub_viz", "ModelResult") + out = tmp_path / "per_model" + + save_all_png(experiment, out) + + assert sorted(p.name for p in out.iterdir()) == sorted( + f"stub_viz_{name}.png" for name in experiment.model_names + ) + + def test_creates_missing_output_directories(self, experiment, tmp_path, only_stub_is_applicable): + out = tmp_path / "deep" / "pngs" + + save_all_png(experiment, out) + + assert out.is_dir() + + def test_normalizes_against_the_reference_model_when_asked(self, experiment, tmp_path, only_stub_is_applicable): + save_all_png(experiment, tmp_path / "norm_pngs", reference_model=REFERENCE_MODEL) + + assert only_stub_is_applicable.last.computed[0][0].normalized_by == REFERENCE_MODEL + + def test_forwards_the_dataset_to_the_plots(self, experiment, tmp_path, only_stub_is_applicable): + dataset = object() + + save_all_png(experiment, tmp_path / "ds_pngs", dataset=dataset) + + assert only_stub_is_applicable.last.computed[0][1] is dataset diff --git a/tests/visualization/test_requirements.py b/tests/visualization/test_requirements.py new file mode 100644 index 000000000..b0040b607 --- /dev/null +++ b/tests/visualization/test_requirements.py @@ -0,0 +1,47 @@ +"""Tests for :mod:`drevalpy.visualization.requirements`. + +``PlotRequirement`` is the vocabulary plots use to declare what data they need; +``ExperimentResult.satisfies`` consumes it. Only the enum itself is asserted +here - the matching logic lives with the result types and the selection logic +lives with the visualization registry. +""" + +from __future__ import annotations + +import pytest + +from drevalpy.visualization.requirements import PlotRequirement + + +class TestPlotRequirement: + def test_declares_exactly_the_four_known_capabilities(self): + assert [r.name for r in PlotRequirement] == [ + "MULTIPLE_MODELS", + "MULTIPLE_FOLDS", + "RANDOMIZATION", + "ROBUSTNESS", + ] + + def test_values_are_distinct(self): + assert len({r.value for r in PlotRequirement}) == len(list(PlotRequirement)) + + @pytest.mark.parametrize( + "name", + [ + pytest.param("MULTIPLE_MODELS", id="multiple_models"), + pytest.param("MULTIPLE_FOLDS", id="multiple_folds"), + pytest.param("RANDOMIZATION", id="randomization"), + pytest.param("ROBUSTNESS", id="robustness"), + ], + ) + def test_members_are_reachable_by_name(self, name): + assert PlotRequirement[name].name == name + + def test_is_hashable_so_plots_can_declare_frozensets(self): + requirements = frozenset({PlotRequirement.MULTIPLE_MODELS, PlotRequirement.MULTIPLE_FOLDS}) + + assert PlotRequirement.MULTIPLE_MODELS in requirements + assert PlotRequirement.RANDOMIZATION not in requirements + + def test_members_are_singletons(self): + assert PlotRequirement.ROBUSTNESS is PlotRequirement["ROBUSTNESS"] diff --git a/tests/visualization/test_utils.py b/tests/visualization/test_utils.py deleted file mode 100644 index 2ba225737..000000000 --- a/tests/visualization/test_utils.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Tests for drevalpy.visualization.utils.""" - -from __future__ import annotations - -from pathlib import Path - -from drevalpy.datasets.splits import SplitParams, write_split_manifest -from drevalpy.visualization.utils import _discover_result_csv_files, _resolve_result_test_mode - - -def test_discover_result_csv_files_finds_custom_split_label_results(tmp_path: Path) -> None: - """ - Discover result CSVs under arbitrary split-label directories. - - :param tmp_path: Temporary path provided by pytest. - """ - pred = tmp_path / "GDSC1" / "scaling-lco" / "ElasticNet" / "predictions" / "predictions_split_0.csv" - pred.parent.mkdir(parents=True, exist_ok=True) - pred.write_text("cell_line_name,pubchem_id,response,predictions\n", encoding="utf-8") - - discovered = _discover_result_csv_files(tmp_path, "GDSC1") - assert discovered == [pred] - - -def test_discover_result_csv_files_skips_split_role_csvs(tmp_path: Path) -> None: - """ - Ignore split role CSVs stored under ``splits/``. - - :param tmp_path: Temporary path provided by pytest. - """ - split_csv = tmp_path / "GDSC1" / "LCO" / "splits" / "cv_split_0_train.csv" - split_csv.parent.mkdir(parents=True, exist_ok=True) - split_csv.write_text("cell_line_name,pubchem_id,response\n", encoding="utf-8") - - assert _discover_result_csv_files(tmp_path, "GDSC1") == [] - - -def test_discover_result_csv_files_includes_all_result_categories(tmp_path: Path) -> None: - """ - Collect CSVs from predictions, cross_study, randomization, and robustness folders. - - :param tmp_path: Temporary path provided by pytest. - """ - categories = { - "predictions": "predictions_split_0.csv", - "cross_study": "cross_study_GDSC2_split_0.csv", - "randomization": "randomization_SVCC_split_0.csv", - "robustness": "robustness_1_split_0.csv", - } - created: list[Path] = [] - for category, filename in categories.items(): - path = tmp_path / "GDSC1" / "LCO" / "ElasticNet" / category / filename - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("cell_line_name,pubchem_id,response,predictions\n", encoding="utf-8") - created.append(path) - - discovered = _discover_result_csv_files(tmp_path, "GDSC1") - assert discovered == created - - -def test_resolve_result_test_mode_uses_manifest(tmp_path: Path) -> None: - """ - Resolve semantic test mode from split manifests for custom result labels. - - :param tmp_path: Temporary path provided by pytest. - """ - split_dir = tmp_path / "GDSC1" / "scaling-lco" / "splits" - params = SplitParams( - test_mode="LCO", - n_cv_splits=1, - validation_ratio=0.1, - random_state=42, - split_early_stopping=True, - ) - write_split_manifest(split_dir, params=params, split_label="scaling-lco", splits=[{"split_index": 0}]) - assert _resolve_result_test_mode(tmp_path, "GDSC1", "scaling-lco") == "LCO" - - -def test_resolve_result_test_mode_falls_back_to_split_label(tmp_path: Path) -> None: - """ - Fall back to the result directory label when no manifest exists. - - :param tmp_path: Temporary path provided by pytest. - """ - assert _resolve_result_test_mode(tmp_path, "GDSC1", "LCO") == "LCO" diff --git a/tools/coverage_gate.py b/tools/coverage_gate.py new file mode 100644 index 000000000..c23339255 --- /dev/null +++ b/tools/coverage_gate.py @@ -0,0 +1,225 @@ +"""Enforce a per-module coverage floor on top of coverage.py's global ``fail_under``. + +``coverage.py`` can only fail a run on the aggregate percentage, which lets a +single untested module hide behind a well-tested package. This script reads the +``coverage.json`` report and fails if any module falls below its effective +floor. + +The floor is ``[tool.drevalpy.coverage_gate].min_file_coverage`` for every +module, except those listed in the ``exemptions`` table, which carry their own +lower floor. Every exemption is technical debt: delete the entry once the module +is properly tested. + +Usage:: + + uv run pytest -m "not network" --cov --cov-report=json + uv run python tools/coverage_gate.py +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from typing import NamedTuple, NoReturn + +from upath import UPath + +DEFAULT_COVERAGE_JSON = "coverage.json" +DEFAULT_PYPROJECT = "pyproject.toml" +DEFAULT_MIN_FILE_COVERAGE = 50.0 +RATCHET_MARGIN = 3.0 + + +class GateConfig(NamedTuple): + """Resolved ``[tool.drevalpy.coverage_gate]`` settings.""" + + min_file_coverage: float + exemptions: dict[str, float] + + +class FileCoverage(NamedTuple): + """One module's measured coverage and the floor it is held to.""" + + path: str + percent: float + floor: float + exempt: bool + + +def load_gate_config(pyproject: UPath) -> GateConfig: + """Read the coverage-gate configuration from ``pyproject.toml``. + + Args: + pyproject: Path to the ``pyproject.toml`` holding the config table. + + Returns: + The resolved configuration, with defaults applied when the table or any + of its keys are absent. + + Raises: + SystemExit: If ``pyproject`` does not exist. + """ + if not pyproject.is_file(): + _fail(f"{pyproject} not found; run the gate from the repository root.") + with pyproject.open("rb") as handle: + data = tomllib.load(handle) + table = data.get("tool", {}).get("drevalpy", {}).get("coverage_gate", {}) + exemptions = {_normalize(key): float(value) for key, value in table.get("exemptions", {}).items()} + return GateConfig( + min_file_coverage=float(table.get("min_file_coverage", DEFAULT_MIN_FILE_COVERAGE)), + exemptions=exemptions, + ) + + +def load_file_percentages(coverage_json: UPath) -> dict[str, float]: + """Read per-file coverage percentages from a ``coverage.json`` report. + + Args: + coverage_json: Path to the JSON report written by ``--cov-report=json``. + + Returns: + Mapping of normalized module path to percent covered. + + Raises: + SystemExit: If the report is missing or cannot be parsed. + """ + if not coverage_json.is_file(): + _fail( + f"{coverage_json} not found. Produce it first, for example:\n" + ' uv run pytest -m "not network" --cov --cov-report=json' + ) + try: + report = json.loads(coverage_json.read_text()) + except json.JSONDecodeError as exc: + _fail(f"{coverage_json} is not valid JSON: {exc}") + return { + _normalize(path): float(entry["summary"]["percent_covered"]) for path, entry in report.get("files", {}).items() + } + + +def evaluate(percentages: dict[str, float], config: GateConfig) -> list[FileCoverage]: + """Pair every measured module with the floor it is held to. + + Args: + percentages: Mapping of module path to percent covered. + config: Resolved gate configuration. + + Returns: + One entry per measured module, sorted by path. + """ + results = [] + for path in sorted(percentages): + exempt = path in config.exemptions + floor = config.exemptions[path] if exempt else config.min_file_coverage + results.append(FileCoverage(path=path, percent=percentages[path], floor=floor, exempt=exempt)) + return results + + +def find_violations(results: list[FileCoverage]) -> list[FileCoverage]: + """Select the modules that fall below their effective floor. + + Args: + results: Output of :func:`evaluate`. + + Returns: + The failing entries, in the order given. + """ + return [item for item in results if item.percent < item.floor] + + +def find_ratchetable(results: list[FileCoverage], min_file_coverage: float) -> list[FileCoverage]: + """Select exempted modules whose recorded floor is now needlessly low. + + Args: + results: Output of :func:`evaluate`. + min_file_coverage: The global floor exemptions are measured against. + + Returns: + Exempted entries that clear the global floor, or sit comfortably above + their own recorded floor. + """ + return [ + item + for item in results + if item.exempt and (item.percent >= min_file_coverage or item.percent >= item.floor + RATCHET_MARGIN) + ] + + +def render_report(results: list[FileCoverage], config: GateConfig) -> tuple[str, bool]: + """Build the human-readable gate report. + + Args: + results: Output of :func:`evaluate`. + config: Resolved gate configuration. + + Returns: + The report text and whether the gate passed. + """ + violations = find_violations(results) + lines: list[str] = [] + + ratchetable = find_ratchetable(results, config.min_file_coverage) + if ratchetable: + lines.append("Coverage gate: exemptions that can be lowered or deleted") + lines.extend(f" {item.path}: {item.percent:.1f}% (recorded floor {item.floor:.0f}%)" for item in ratchetable) + lines.append("") + + stale = sorted(set(config.exemptions) - {item.path for item in results}) + if stale: + lines.append("Coverage gate: exemptions for modules absent from the report (delete them)") + lines.extend(f" {path}" for path in stale) + lines.append("") + + if not violations: + lines.append( + f"Coverage gate passed: {len(results)} modules at or above their floor " + f"(global {config.min_file_coverage:.0f}%, {len(config.exemptions)} exemptions)." + ) + return "\n".join(lines), True + + lines.append(f"Coverage gate FAILED: {len(violations)} module(s) below their floor.") + lines.append(f"{'module':<70} {'actual':>8} {'floor':>8}") + lines.extend(f"{item.path:<70} {item.percent:>7.1f}% {item.floor:>7.0f}%" for item in violations) + lines.append("") + lines.append( + "Add tests, or - only if the module is genuinely untestable - record its current\n" + "floor in [tool.drevalpy.coverage_gate].exemptions in pyproject.toml." + ) + return "\n".join(lines), False + + +def main(argv: list[str] | None = None) -> int: + """Run the coverage gate. + + Args: + argv: Command-line arguments, defaulting to ``sys.argv[1:]``. + + Returns: + ``0`` if every module meets its floor, ``1`` otherwise. + """ + parser = argparse.ArgumentParser(description="Enforce a per-module coverage floor.") + parser.add_argument("--coverage-json", default=DEFAULT_COVERAGE_JSON, help="Path to the coverage JSON report.") + parser.add_argument("--pyproject", default=DEFAULT_PYPROJECT, help="Path to the pyproject.toml holding the config.") + args = parser.parse_args(argv) + + config = load_gate_config(UPath(args.pyproject)) + percentages = load_file_percentages(UPath(args.coverage_json)) + results = evaluate(percentages, config) + report, passed = render_report(results, config) + print(report) + return 0 if passed else 1 + + +def _normalize(path: str) -> str: + return path.replace("\\", "/") + + +def _fail(message: str) -> NoReturn: + print(f"coverage_gate: {message}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/size_gate.py b/tools/size_gate.py new file mode 100644 index 000000000..24f094fee --- /dev/null +++ b/tools/size_gate.py @@ -0,0 +1,256 @@ +"""Enforce a per-module statement ceiling, so split-up modules stay split up. + +The refactoring that produced ``models/mixins/`` and the featurizer mixins moved +behaviour out of files that had grown to carry four unrelated concerns each. This +gate is what keeps them from growing back: it counts AST statements per module and +fails when one exceeds its effective ceiling. + +Statements rather than lines, because the count is then independent of formatting +and of the Google-style docstrings this codebase requires - a docstring is one +``Expr`` statement whether it is one line or thirty. + +The ceiling is ``[tool.drevalpy.size_gate].max_module_statements`` for every +module, except those listed in the ``exemptions`` table, which carry their own +higher ceiling. Every exemption is technical debt: split the module and delete the +entry. A recorded ceiling may be lowered as a module shrinks, never raised to let +a regression through - the same rule ``tools/coverage_gate.py`` follows. + +This is deliberately *not* a code-health score. Repowise's ``hotspot_health`` +would be the richer signal, but two thirds of its impact is derived from commit +history, which makes it drift with repository activity rather than with the change +under review, and it reads a full 10.0 on the shallow checkout CI does by default. +What this gate measures instead is computable from the working tree alone, so it +means the same thing locally and in CI. + +Usage:: + + uv run python tools/size_gate.py +""" + +from __future__ import annotations + +import argparse +import ast +import sys +import tomllib +from typing import NamedTuple, NoReturn + +from upath import UPath + +DEFAULT_PACKAGE = "drevalpy" +DEFAULT_PYPROJECT = "pyproject.toml" +DEFAULT_MAX_MODULE_STATEMENTS = 150 +RATCHET_MARGIN = 10 + + +class GateConfig(NamedTuple): + """Resolved ``[tool.drevalpy.size_gate]`` settings.""" + + max_module_statements: int + exemptions: dict[str, int] + + +class ModuleSize(NamedTuple): + """One module's measured statement count and the ceiling it is held to.""" + + path: str + statements: int + ceiling: int + exempt: bool + + +def load_gate_config(pyproject: UPath) -> GateConfig: + """Read the size-gate configuration from ``pyproject.toml``. + + Args: + pyproject: Path to the ``pyproject.toml`` holding the config table. + + Returns: + The resolved configuration, with defaults applied when the table or any + of its keys are absent. + + Raises: + SystemExit: If ``pyproject`` does not exist. + """ + if not pyproject.is_file(): + _fail(f"{pyproject} not found; run the gate from the repository root.") + with pyproject.open("rb") as handle: + data = tomllib.load(handle) + table = data.get("tool", {}).get("drevalpy", {}).get("size_gate", {}) + exemptions = {_normalize(key): int(value) for key, value in table.get("exemptions", {}).items()} + return GateConfig( + max_module_statements=int(table.get("max_module_statements", DEFAULT_MAX_MODULE_STATEMENTS)), + exemptions=exemptions, + ) + + +def count_statements(source: str) -> int: + """Count the AST statement nodes in a module's source. + + Args: + source: Python source text. + + Returns: + The number of ``ast.stmt`` nodes, at every nesting depth. + + Raises: + SyntaxError: If ``source`` does not parse. + """ + return sum(1 for node in ast.walk(ast.parse(source)) if isinstance(node, ast.stmt)) + + +def measure_package(package_root: UPath) -> dict[str, int]: + """Count statements for every module in a package tree. + + Args: + package_root: Directory of the package to walk. + + Returns: + Mapping of module path to statement count. Paths are relative to + ``package_root``'s parent, so a key reads ``drevalpy/a/b.py`` whether the + gate was pointed at ``drevalpy`` or at an absolute path ending in it. + + Raises: + SystemExit: If ``package_root`` is not a directory, or a module in it + does not parse. + """ + if not package_root.is_dir(): + _fail(f"{package_root} is not a directory; run the gate from the repository root.") + sizes: dict[str, int] = {} + for path in sorted(package_root.rglob("*.py")): + key = f"{package_root.name}/{_normalize(str(path.relative_to(package_root)))}" + try: + sizes[key] = count_statements(path.read_text()) + except SyntaxError as exc: + _fail(f"{path} does not parse: {exc}") + return sizes + + +def evaluate(sizes: dict[str, int], config: GateConfig) -> list[ModuleSize]: + """Pair every measured module with the ceiling it is held to. + + Args: + sizes: Mapping of module path to statement count. + config: Resolved gate configuration. + + Returns: + One entry per measured module, sorted by path. + """ + results = [] + for path in sorted(sizes): + exempt = path in config.exemptions + ceiling = config.exemptions[path] if exempt else config.max_module_statements + results.append(ModuleSize(path=path, statements=sizes[path], ceiling=ceiling, exempt=exempt)) + return results + + +def find_violations(results: list[ModuleSize]) -> list[ModuleSize]: + """Select the modules that exceed their effective ceiling. + + Args: + results: Output of :func:`evaluate`. + + Returns: + The failing entries, in the order given. + """ + return [item for item in results if item.statements > item.ceiling] + + +def find_ratchetable(results: list[ModuleSize], max_module_statements: int) -> list[ModuleSize]: + """Select exempted modules whose recorded ceiling is now needlessly high. + + Args: + results: Output of :func:`evaluate`. + max_module_statements: The global ceiling exemptions are measured against. + + Returns: + Exempted entries that now clear the global ceiling, or have shrunk well + below their own recorded ceiling. + """ + return [ + item + for item in results + if item.exempt + and (item.statements <= max_module_statements or item.statements <= item.ceiling - RATCHET_MARGIN) + ] + + +def render_report(results: list[ModuleSize], config: GateConfig) -> tuple[str, bool]: + """Build the human-readable gate report. + + Args: + results: Output of :func:`evaluate`. + config: Resolved gate configuration. + + Returns: + The report text and whether the gate passed. + """ + violations = find_violations(results) + lines: list[str] = [] + + ratchetable = find_ratchetable(results, config.max_module_statements) + if ratchetable: + lines.append("Size gate: exemptions that can be lowered or deleted") + lines.extend( + f" {item.path}: {item.statements} stmts (recorded ceiling {item.ceiling})" for item in ratchetable + ) + lines.append("") + + stale = sorted(set(config.exemptions) - {item.path for item in results}) + if stale: + lines.append("Size gate: exemptions for modules that no longer exist (delete them)") + lines.extend(f" {path}" for path in stale) + lines.append("") + + if not violations: + lines.append( + f"Size gate passed: {len(results)} modules at or below their ceiling " + f"(global {config.max_module_statements} statements, {len(config.exemptions)} exemptions)." + ) + return "\n".join(lines), True + + lines.append(f"Size gate FAILED: {len(violations)} module(s) above their ceiling.") + lines.append(f"{'module':<70} {'actual':>8} {'ceiling':>8}") + lines.extend(f"{item.path:<70} {item.statements:>8} {item.ceiling:>8}" for item in violations) + lines.append("") + lines.append( + "Split the module - a mixin or a `_`-prefixed private helper beside it - or,\n" + "only if it is genuinely one indivisible unit, record its current statement\n" + "count in [tool.drevalpy.size_gate].exemptions in pyproject.toml with a reason." + ) + return "\n".join(lines), False + + +def main(argv: list[str] | None = None) -> int: + """Run the size gate. + + Args: + argv: Command-line arguments, defaulting to ``sys.argv[1:]``. + + Returns: + ``0`` if every module meets its ceiling, ``1`` otherwise. + """ + parser = argparse.ArgumentParser(description="Enforce a per-module statement ceiling.") + parser.add_argument("--package", default=DEFAULT_PACKAGE, help="Package directory to walk.") + parser.add_argument("--pyproject", default=DEFAULT_PYPROJECT, help="Path to the pyproject.toml holding the config.") + args = parser.parse_args(argv) + + config = load_gate_config(UPath(args.pyproject)) + sizes = measure_package(UPath(args.package)) + results = evaluate(sizes, config) + report, passed = render_report(results, config) + print(report) + return 0 if passed else 1 + + +def _normalize(path: str) -> str: + return path.replace("\\", "/") + + +def _fail(message: str) -> NoReturn: + print(f"size_gate: {message}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..eba7ddef3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,5908 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.14" +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", +] +conflicts = [[ + { package = "drevalpy", extra = "cpu" }, + { package = "drevalpy", extra = "cu126" }, + { package = "drevalpy", extra = "cu130" }, +]] + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3W" + +[manifest] +overrides = [{ name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'", specifier = ">=2.30.7" }] + +[[package]] +name = "aiobotocore" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/a7/bc31b7046c610471f0630819ca5d2a57ac4efa8d47135cb53e43f2785390/aiobotocore-3.8.0.tar.gz", hash = "sha256:80a1eb64ea915f3af3c1518669975bae74a17b2f37c14eb0fa2f83b915974670", size = 131368, upload-time = "2026-07-17T03:10:30.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl", hash = "sha256:8bc605132cadfe844a3f334635a0a64fa5e360a4a206e915d99d53db5b6deeba", size = 91169, upload-time = "2026-07-17T03:10:28.771Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/cc/58f26f118d8099f84e009ce560b9148a3f803e63fa8473b57feb67241875/aiohttp-3.14.2.tar.gz", hash = "sha256:f96821eb2ae2f12b0dfa799eafbf221f5621a9220b457b4744a269a63a5f3a6c", size = 7969860, upload-time = "2026-07-20T19:53:26.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/4a/a6c1426d2fb3cddd901575436803733caaaeb7ac0a49aa72d1930d2e3ab6/aiohttp-3.14.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:56432ee8f7abe47c97717cfbf5c32430463ea8a7138e12a87b7891fa6084c8ff", size = 764168, upload-time = "2026-07-20T19:50:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/88ae65afc3590719788cbff3b2e0f56e6850f53ca34114cbb371e677c543/aiohttp-3.14.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c244f7a65cbec04c830a301aae443c529d4dbca5fddfd4b19e5a179d896adfd", size = 516255, upload-time = "2026-07-20T19:50:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/8f/bc/ad9b767785b014f2f57497a2ccf67e3d4316d153f7ed1c7715fbbd7573fe/aiohttp-3.14.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c05afdd28ecacce5a1f63275a2e3dce09efddd3a63d143ee9799fda83989c8d", size = 514670, upload-time = "2026-07-20T19:50:10.325Z" }, + { url = "https://files.pythonhosted.org/packages/48/94/697aef63f15ef354f64723eaac6ccfb289d59b99699b3b7cb1ff663b2045/aiohttp-3.14.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a57f39d6ec155932853b6b0f130cbbafab3208240fa807f29a2c96ea52b77ae1", size = 1780650, upload-time = "2026-07-20T19:50:11.676Z" }, + { url = "https://files.pythonhosted.org/packages/99/f9/6a1994c1bf138403cf10171cb618571a330ae5683a5803b1ef29b5723d7f/aiohttp-3.14.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fc31339824ec922cb7424d624b5b6c11d8942d077b2585e5bd602ca1a1e27ed", size = 1737710, upload-time = "2026-07-20T19:50:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ba/abe8d5015148b2cd7189473789461058dcf88ae202d4cf1cbecaf291a7ef/aiohttp-3.14.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d93854e215dcc7c88e4f530827193c1a594e2662931d8dbe7cca3abf52a7082d", size = 1845568, upload-time = "2026-07-20T19:50:15.168Z" }, + { url = "https://files.pythonhosted.org/packages/c2/89/98a2688810f469f19f2d02a298426a37b6e1cc420230a8cc7d6c85af7a01/aiohttp-3.14.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87c9b03be0c18c3b3587be979149830381e37ac4a6ca8557dbe72e44fcad66c3", size = 1928485, upload-time = "2026-07-20T19:50:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fd/c980f64f5e5bff22f07968bcc6af1b64167dfd21dbbffda86f2d153fc802/aiohttp-3.14.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc1a0793dce8fa9bb6906411e57fb18a2f1c31357b04172541b92b30337362a7", size = 1786216, upload-time = "2026-07-20T19:50:18.598Z" }, + { url = "https://files.pythonhosted.org/packages/da/80/c710ce9c7f22fcbb83366aa64a5ccf03dced35ab09a6e37545656a6df01e/aiohttp-3.14.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2f1b9540d2d0f2f95590528a1effd0ba5370f6ec189ac925e70b5eecae02dc77", size = 1636921, upload-time = "2026-07-20T19:50:20.201Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b2/56ce1b0c535d188ef94c05bccbf3f5b1aaea1231fcb073318977bdd917e9/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c0a968b04fecf7c94e502015860ad1e2e112c6b761e97b6fdf65fbb374e22b73", size = 1753040, upload-time = "2026-07-20T19:50:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/61/4318a947139a21d4e1a40e7f5a92799d0fe28feffaca6640a4a165e62b0e/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2d2eedae227cd5cbd0bccc5e759f71e1af2cd77b7f74ce413bb9a2b87f94a272", size = 1760811, upload-time = "2026-07-20T19:50:23.623Z" }, + { url = "https://files.pythonhosted.org/packages/81/b0/2012165377d029ceb925c9e5f42b0b30a39d89cf7e494c4100f3ab8d27c6/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9d3f4c68b2c2cd282b65e558cebf4b27c8b440ab511f2b938a643d3598df2ddb", size = 1819760, upload-time = "2026-07-20T19:50:25.34Z" }, + { url = "https://files.pythonhosted.org/packages/16/e5/1de93ba3bc18edb87f71594326bedafdc38fe725da8118b7d36fd56610f8/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d32a70b8bf8836fd80d4169d9e34eb032cd2a7cbccb0b9cf00eac1f40732467c", size = 1628145, upload-time = "2026-07-20T19:50:26.992Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/adc9e8592449ee08f72fcce94b7f3f1a9bbf0591a46026e4d322e5a70d7a/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:386ce4e709b4cc40f9ef9a132ad8e672d2d164a65451305672df656e7794c68e", size = 1832577, upload-time = "2026-07-20T19:50:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/9b/7f/700500700513b1b63967a24c4e189aad377e18f9d67b7387b3418d6213cb/aiohttp-3.14.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc0ed30b942c3bd755583d74bb00b90248c067d20b1f8301e4489a53a33aa65f", size = 1774814, upload-time = "2026-07-20T19:50:30.144Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d6/580f9b2ab4c78d9fbf9d56c85fb7f7d4d22d667b27df8032ab9bf7140e37/aiohttp-3.14.2-cp311-cp311-win32.whl", hash = "sha256:b5ed2c7dacebf4950d6b4a1b22548e4d709bb15e0287e064a7cdb32ada65893a", size = 455968, upload-time = "2026-07-20T19:50:31.722Z" }, + { url = "https://files.pythonhosted.org/packages/96/76/33665ae48e24e01c6dfa15ed14d9d9eec1349577cef497f3b26210621f0a/aiohttp-3.14.2-cp311-cp311-win_amd64.whl", hash = "sha256:bf7951959a8e89f2d4a1e719e60d3ea4e8fc26f011ee3aed09598ad786b112f7", size = 480978, upload-time = "2026-07-20T19:50:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/00963031da42bd23716032f57b2a8414c5355eb1abc41805b2b41173040c/aiohttp-3.14.2-cp311-cp311-win_arm64.whl", hash = "sha256:c167127a3b6089ef78ac2e33582c38040d51688ee28474b5053acf55f192187b", size = 452900, upload-time = "2026-07-20T19:50:35.049Z" }, + { url = "https://files.pythonhosted.org/packages/e0/99/8737b10b6fa447371541a3b2cdbf9703c31ecf3afff4998f2f0633646a57/aiohttp-3.14.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:30e41662123806e4590a0440585122ac33c89a2465a8be81cc1b50656ca0e432", size = 754562, upload-time = "2026-07-20T19:50:36.672Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e5/fd624b8b46d99dfd8f7f75111569b5ee21850539e914eed04ce39e4455ea/aiohttp-3.14.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbc45e2773c66d14fbd337754e9bf23932beef539bd539716a721f5b5f372034", size = 509386, upload-time = "2026-07-20T19:50:38.268Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a8/473db85d08b862724c506b51af2b9c6327e9e95cbd297c4c6f33968be1a8/aiohttp-3.14.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:476cf7fac10619ad6d08e1df0225d07b5a8d57c04963a171ad845d5a349d47ef", size = 511905, upload-time = "2026-07-20T19:50:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/aa5ebff13ac5e39ffae8add143757e936d1c2ceb726b82786e5f5cb9912c/aiohttp-3.14.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40bedff39ea83185f3f98a41155dd9da28b365c432e5bd90e7be140bcef0b7f3", size = 1764968, upload-time = "2026-07-20T19:50:41.559Z" }, + { url = "https://files.pythonhosted.org/packages/f4/22/23ec6d3d3f24f5961c7be73d5127481c992d6c92ca213f336ed8f0ff1453/aiohttp-3.14.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a26f14006883fc7662e21041b4311eac1acbc977a5c43aacb27ff17f8a4c28b2", size = 1740635, upload-time = "2026-07-20T19:50:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7b/f8966d2d08f0582525ef02d5af007d2d2467cb733abf1d1f7849a02a6b98/aiohttp-3.14.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:673217cbc9370ebf8cd048b0889d7cbe922b7bb48f4e4c02d31cfefa140bd946", size = 1810447, upload-time = "2026-07-20T19:50:45.258Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/239f2a828b033ac244237074db7b560ad3279bb1e934bb462dbde19f3877/aiohttp-3.14.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b39dbdbe30a44958d63f3f8baa2af68f24ec8a631dcd18a33dd76dfa2a0eb917", size = 1905896, upload-time = "2026-07-20T19:50:47.022Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6e/fb84b8dafc521026355aadbeed484fc1ec44a05aae927de309f204c1f0af/aiohttp-3.14.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d15f618255fcbe5f54689403aa4c2a90b6f2e6ebc96b295b1cb0e868c1c12384", size = 1792052, upload-time = "2026-07-20T19:50:48.683Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c4/25b199009f164ee02599b9b0962d22e6533d212418e49df9f4fdb37fe2eb/aiohttp-3.14.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ae767b7dffd316cc2d0abf3e1f90132b4c1a2819a32d8bcb1ba749800ea6273", size = 1590939, upload-time = "2026-07-20T19:50:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f5/96d7540a52620433db3822267008a1697fc816979c109a4f535855ac893c/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3ec4b6501a076b2f73844256da17d6b7acb15bb74ee0e908a67feb9412371166", size = 1725039, upload-time = "2026-07-20T19:50:52.506Z" }, + { url = "https://files.pythonhosted.org/packages/f6/50/1a06abbeec14a2bcb46124ca6e281ed4ccb3e9006542ded6bc89a0ce5224/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e328d02fb46b9a8dbfa070d98967e8b7eaa1d9ee10ae03fb664bdf30d58ccf0", size = 1764748, upload-time = "2026-07-20T19:50:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/70/5d/b8821b362306627ea8ca11fe49ec47cba18e7c75d975fa5f6710ad93845c/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c0c7f2e5fe10910d5ab76438f269cc41bb7e499fd48ded978e926360ab1790c8", size = 1777119, upload-time = "2026-07-20T19:50:56.306Z" }, + { url = "https://files.pythonhosted.org/packages/81/e5/805d9d49e66c6358574ad70dd731031585d18fe95ae65b857bc378bccc9a/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:66de80888db2176655f8df0b705b817f5ae3834e6566cc2caa89360871d90195", size = 1580047, upload-time = "2026-07-20T19:50:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fa/1536f272d5ad93bf8b75043b02b94c24bf4e6b55a849d3287553ad6da97e/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f2f9950b2dd0fc896ab520ea2366b7df6484d3d164a65d5e9f28f7b0e5742d8a", size = 1796861, upload-time = "2026-07-20T19:51:00.373Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f3/86dfd6152bb706351cf5c6b45f0f6a1818a4e559d1e88d899c4c673c23a8/aiohttp-3.14.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cc4435b16dc246c5dfa7f2f8ee71b10a30765018a090ee36e99f356b1e9b75cc", size = 1768687, upload-time = "2026-07-20T19:51:02.214Z" }, + { url = "https://files.pythonhosted.org/packages/57/af/14b98e07fcd1a2b37ebb2f950309821eaa56e0eac3d32fc23dc55227e49b/aiohttp-3.14.2-cp312-cp312-win32.whl", hash = "sha256:4ca802547f1128008addfc21b24959f5cbf30a8952d365e7daa078a0d884b242", size = 451437, upload-time = "2026-07-20T19:51:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/38/20/8478c05702a369b6d0800e40e0c5f9a289ff482d0921faec1eb17727339d/aiohttp-3.14.2-cp312-cp312-win_amd64.whl", hash = "sha256:e5efff8bfd27c44ce1bfdf92ce838362d9316ed8b2ed2f89f581dbe0bbe05acf", size = 476776, upload-time = "2026-07-20T19:51:05.705Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d0/e550bb1cb4fc82de3bdbe2add061cc0cedb34f2e7f01ada78fba94493ca1/aiohttp-3.14.2-cp312-cp312-win_arm64.whl", hash = "sha256:0eb1c9fd51f231ac8dc9d5824d5c2efc45337d429db0123fa9d4c20f570fdfc3", size = 447928, upload-time = "2026-07-20T19:51:07.523Z" }, + { url = "https://files.pythonhosted.org/packages/96/3a/8b8779f8e813d3a33cf16bc836a7ea7bf3c27b6588d42efa36b0c32fcb58/aiohttp-3.14.2-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:17eecd6ee9bfc8e31b6003137d74f349f0ac3797111a2df87e23acb4a7a912ea", size = 506205, upload-time = "2026-07-20T19:51:09.215Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/2f45204b37ecc4ce2c54b8b67254d70a8ea3b629c4083d58f3e2d27991cb/aiohttp-3.14.2-cp313-cp313-android_21_x86_64.whl", hash = "sha256:ce8dfb58f012f76258f29951d38935ac928b32ae24a480f30761f2ed5036fa78", size = 515116, upload-time = "2026-07-20T19:51:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/f0/22/8f4424de1f50df4fbb74ea32006f9cff798ae0d346ffa6be7b6f0caca719/aiohttp-3.14.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4181d72e0e6d1735c1fae56381193c6ae211d584d06413980c00775b9b2a176a", size = 486244, upload-time = "2026-07-20T19:51:12.878Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/60de022260f5d2d31976bc5f50db708e10671f0ac5da515402adcc6b4d4d/aiohttp-3.14.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:0e56babe35076f69ec9327833b71439eeccd10f51fe56c1a533da8f24923f014", size = 492260, upload-time = "2026-07-20T19:51:14.555Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6f/d3ccc31c15cd33888670b8f1bf1dc06f3239b4edd156fc818078e87ae628/aiohttp-3.14.2-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6b63709e259e3b3d7922b235606564e91ed4c224e777cc0ca4cae04f5f559206", size = 502174, upload-time = "2026-07-20T19:51:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/40/20/52d21e485652f4708015ce27dd3029e63140e0bdb0dcc7bf72dc0bc3f4ad/aiohttp-3.14.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f7c10c4d0b33888a68c192d883d1390d4596c116a59bf689e6d352c6739b7940", size = 750878, upload-time = "2026-07-20T19:51:18.013Z" }, + { url = "https://files.pythonhosted.org/packages/2e/49/1220bdc73d568431cc3856667405d78c2e95eb57fed2b9ffa5e825932ab2/aiohttp-3.14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f7b19e27b78a3a927b1932af93af7645806153e8f541cee8fe856426142503f", size = 508459, upload-time = "2026-07-20T19:51:19.876Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f1/39177406ce0ae5cb2782b35e848c0edf6e09d6e082df636eeacdbdfa70bd/aiohttp-3.14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:18fcc3a5cc7dde1d8f7903e309055294c28894c9434588645817e374f3b83d03", size = 509179, upload-time = "2026-07-20T19:51:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/87/f7/6d081c2ee838458492c7ae1062399bdd68f149811d257d4502e79a25b306/aiohttp-3.14.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d1b0deec698d1198eb0b8f910dd9432d856985abbfea3f06be8b296a6619b4", size = 1761346, upload-time = "2026-07-20T19:51:23.437Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ed/0befea4ae533f09c3a60f0d10b923cd4a90027a1bf49dfafaa24eeee08b8/aiohttp-3.14.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cabaaecb4c6888bd9abafac151051377534dad4c3859a386b6325f39d3732f99", size = 1735057, upload-time = "2026-07-20T19:51:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/c1d89fcd94907f93a08d4aadbb4e1d9bd3d7d6c9f94bc9c17689de38107b/aiohttp-3.14.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:114299c08cce8ad4ebb21fafe766378864109e88ad8cf63cf6acb384ff844a57", size = 1800389, upload-time = "2026-07-20T19:51:27.585Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8b/edbac4d1a1cde3571588a1e5b40637df5acee43158efdf3ae9a161fde8ab/aiohttp-3.14.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6bea8451e26cd67645d9b2ee18232e438ddfc36cea35feecb4537f2359fc7030", size = 1895342, upload-time = "2026-07-20T19:51:29.794Z" }, + { url = "https://files.pythonhosted.org/packages/0c/31/2e9ca3b21c07ea8a001e2b142cb01401fd34fd07b248a0b95a04c3ebc4ec/aiohttp-3.14.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46b8887aa303075c1e5b24123f314a1a7bbfa03d0213dff8bb70503b2148c853", size = 1789175, upload-time = "2026-07-20T19:51:31.744Z" }, + { url = "https://files.pythonhosted.org/packages/6f/91/b57d3d13a80fbf765b19ae0c38d783a3a23f35b1cb82ae3b22f91abe6c24/aiohttp-3.14.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de3b04a3f7b40ad7f1bcd3540dd447cf9bd93d57a49969bca522cbcf01290f08", size = 1586845, upload-time = "2026-07-20T19:51:33.571Z" }, + { url = "https://files.pythonhosted.org/packages/69/e8/a2f5f9f6219cd21190e9b50e2cbc978d12a2aab748d0aa7c6ee930db15c3/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:42372e1f1a8dca0dcd5daf922849004ec1120042d0e24f14c926f97d2275ca79", size = 1724839, upload-time = "2026-07-20T19:51:35.518Z" }, + { url = "https://files.pythonhosted.org/packages/de/d4/a2b9442336051714dd8dbd419b9f4a1003c45bb850ee0f3af87f2743e867/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7871c94f3400358530ac4906dd7a526c5a24099cd5c48f53ffc4b1cb5037d7d7", size = 1756306, upload-time = "2026-07-20T19:51:37.459Z" }, + { url = "https://files.pythonhosted.org/packages/ab/93/22c94a599b2c4ea6ab88f1b912d90883f1fba5235b587ea9f13b148e9a0f/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f8f371794319a8185e61e15ba5e1be8407b986ebce1ade11856c02d24e090577", size = 1769133, upload-time = "2026-07-20T19:51:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/fe/db/a4e4c207f7023e83c281a7cf3579f227bade48f1899cb6588c0ed6ae54de/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:af63ac06bad85191e6a0c4a733cb3c55adb99f8105bc7ce9913391561159a49a", size = 1578671, upload-time = "2026-07-20T19:51:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6f/33746ba4947b8193652fc7ed0533e09f93b118e39777228f3029e5677c17/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8c2cdb684c153f377157e856257ee8535c75d8478343e4bb1e83ca73bdfa3d31", size = 1791778, upload-time = "2026-07-20T19:51:44.298Z" }, + { url = "https://files.pythonhosted.org/packages/06/41/323856cb0ec9076b1f9a135eb0684f5f984dd97989a087873768aa34ca2f/aiohttp-3.14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ceff4f84c1d928654faa6bcb0437ed095b279baae2a35fcfe5a3cbe0d8b9725d", size = 1768490, upload-time = "2026-07-20T19:51:46.279Z" }, + { url = "https://files.pythonhosted.org/packages/39/f0/09e29e457b6fb49253cbb61354ed2541fdf23ac192bddbb57bb34bc93d9c/aiohttp-3.14.2-cp313-cp313-win32.whl", hash = "sha256:15292b08ce7dd45e268fce542228894b4735102e8ee77163bd665b35fc2b5598", size = 451064, upload-time = "2026-07-20T19:51:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/0b/76/b44703eafcfe1446f4f975be1ef2406042955425eaf56450e71b50506c49/aiohttp-3.14.2-cp313-cp313-win_amd64.whl", hash = "sha256:fc2d8e7373ceba7e1c7e9dc00adac854c2701a6d443fd21d4af2e49342d727bd", size = 476140, upload-time = "2026-07-20T19:51:50.285Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/b095935c9e72a253439f80ec757b91d4733e0a9a98b63e108d84aaf39b08/aiohttp-3.14.2-cp313-cp313-win_arm64.whl", hash = "sha256:70570f50bda5037b416db8fcba595cf808ecf0fdce12d64e850b5ae1db7f64d4", size = 447585, upload-time = "2026-07-20T19:51:52.333Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "anndata" +version = "0.12.19" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", +] +dependencies = [ + { name = "array-api-compat", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "h5py", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "legacy-api-wrap", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "natsort", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "numpy", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "packaging", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "pandas", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scverse-misc", version = "0.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "zarr", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/61/ac062574bf53d1a52686ad8f40214cecb3870e16eeee9568e5cfc0d410c8/anndata-0.12.19.tar.gz", hash = "sha256:336ccc88463b56c22969634ca5256c3829af8b087a5a4ecfdac3504fedaa27cc", size = 2257928, upload-time = "2026-07-02T11:28:09.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/f7/cdd9ae9031234ae4a7a8ced1f062cae3f207df0bae8f52ef9d092e5bc4b7/anndata-0.12.19-py3-none-any.whl", hash = "sha256:5bd95f3b96a815b61de87020e755d42f1c62404e8c4808ab81cb0e8d63152ed7", size = 176142, upload-time = "2026-07-02T11:28:07.072Z" }, +] + +[[package]] +name = "anndata" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", +] +dependencies = [ + { name = "array-api-compat", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "h5py", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "legacy-api-wrap", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "natsort", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "numpy", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "packaging", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "pandas", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scverse-misc", version = "0.1.1", source = { registry = "https://pypi.org/simple" }, extra = ["settings"], marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "zarr", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2e/077f2dbd47a727793e0369b81377ceb3077f86e94d61850d3f4c21e28706/anndata-0.13.2.tar.gz", hash = "sha256:f40106369a08204bc89915759113124f12abb19c3d011f7503e627e9ef8f29c9", size = 2283406, upload-time = "2026-07-13T18:08:27.864Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/ba/75122318c4777678abc163d4db855176828003237267c4326947f8cfcb32/anndata-0.13.2-py3-none-any.whl", hash = "sha256:1930305dc3a32ab5433dee2bc441cd264f9eeed22cc5a224b9f9d69e74d4fdf0", size = 187532, upload-time = "2026-07-13T18:08:26.322Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "array-api-compat" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/12/65d297d3e425cb7fe5247b7ec14b4ed83539e9b5e89b65c15ea7ee274182/array_api_compat-1.15.0.tar.gz", hash = "sha256:53c5f922491bf15f62847afafc4e39eedfae57d218988fefb8cce39c2a9b3dea", size = 129305, upload-time = "2026-06-07T20:53:24.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/16/1a8fd2b19544b84575cf84ef7aa3ad4c173b756d5f087c91f85d1b295777/array_api_compat-1.15.0-py3-none-any.whl", hash = "sha256:7b1b9c53269061403fd5f45a8de349f16e7887653328bfa0c5f2d45299ff0a8e", size = 79113, upload-time = "2026-06-07T20:53:23.621Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "bleach" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "bokeh" +version = "3.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "jinja2" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "tornado", marker = "sys_platform != 'emscripten' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "xyzservices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/18/12d0d6024177ad18ba65deffc363046d0cbafe116f8b964a9efa85d2800f/bokeh-3.7.3.tar.gz", hash = "sha256:70a89a9f797b103d5ee6ad15fb7944adda115cf0da996ed0b75cfba61cb12f2b", size = 6366610, upload-time = "2025-05-12T12:13:29.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/48/08b2382e739236aa3360b7976360ba3e0c043b6234e25951c18c1eb6fa06/bokeh-3.7.3-py3-none-any.whl", hash = "sha256:b0e79dd737f088865212e4fdcb0f3b95d087f0f088bf8ca186a300ab1641e2c7", size = 7031447, upload-time = "2025-05-12T12:13:27.47Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/e7/976bf3dfe0aa5d7f31bec2f2cf57c79641620c910a39bc843a237aa9592d/boto3-1.43.46.tar.gz", hash = "sha256:66c0d943b049a46a492ec4ec2ebe73c930b1842c7137bee83aad6d93e95d4d96", size = 112654, upload-time = "2026-07-10T19:32:12.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl", hash = "sha256:69453e2c1bcb9fd9806527ab99950cacfc2826cb0dce9a3a0414d19270c06c3c", size = 140031, upload-time = "2026-07-10T19:32:11.129Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/f1/1917891851ac5ac09bb9f4862b8fc9252a009d7c24e8688bb67e4383d9e7/botocore-1.43.46.tar.gz", hash = "sha256:59f2e1ac3cdc66d191cae91c0804bc41847ce817dc8147cf43eaada8f76a5533", size = 15694635, upload-time = "2026-07-10T19:32:00.437Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl", hash = "sha256:cb673891e623ae6e6a1bf24d94ef169504f3eb02584adb5d5bee2f6aae819b60", size = 15380350, upload-time = "2026-07-10T19:31:57.616Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, +] + +[[package]] +name = "colorlog" +version = "6.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/bf/cb30a51af3aa8ce63735f77e23dcd4fc0720fe0339bcb04f77345659c277/colorlog-6.11.0.tar.gz", hash = "sha256:9d90fb53fa906c8970c18fbe46506bae1fb5f86b513b8f867db37e4ace9be7ae", size = 17734, upload-time = "2026-07-17T12:16:46.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/a1/6b71004ab0fea510230be9ce05a4059029ac847c009fcc80b1b73d6fa5ab/colorlog-6.11.0-py3-none-any.whl", hash = "sha256:f1e27d75aa2cb138f3f640c0e305b65b680ccbef6ecc034eba7e03494ffcd2a1", size = 12016, upload-time = "2026-07-17T12:16:45.3Z" }, +] + +[[package]] +name = "colormath2" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "networkx" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/d2/ffc1354bdb2e5aa2772782bd34351caf2e98ef2a0850080feda4216178d9/colormath2-3.0.3.tar.gz", hash = "sha256:e797613f4f0b86c6c218a1c7dc50f1259e6934e391581969b689ae27379c2ffa", size = 51301, upload-time = "2024-11-02T16:16:38.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/10/175f2b97c2e29fc66b6190db6effabe4ae2c4b2030b214db7e3e37439e11/colormath2-3.0.3-py3-none-any.whl", hash = "sha256:2098e9cdb4923225cfae8966b1d51abfe4517059ead2ff4e55103d6375fa7e51", size = 40663, upload-time = "2024-11-02T16:16:35.971Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "complexipy" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/fd/dfaa263faf6f866552ae34c7ad475c79dde8e8a2d10c8a45483bd9bf5c0a/complexipy-6.1.0.tar.gz", hash = "sha256:2e95b556e1d1c5e6bebce84918df4e1f491230dd7753ef6a93057fa0009763b6", size = 353448, upload-time = "2026-07-21T02:57:55.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/21/96083b64f58a545dc103fc7b2846ae9f5207980f770c299609b948972ea5/complexipy-6.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7cc1e04299dcf84799c96531cc31499f963194130d32d9d397fa01114f58bf65", size = 2315207, upload-time = "2026-07-21T02:55:25.584Z" }, + { url = "https://files.pythonhosted.org/packages/88/d2/e36a07617f18c350d8016025f2d65e534e36606ba0401224506c5c75656a/complexipy-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e445d5b72a2158d298f1bab629ca9f0f86f0177cea99607b60770109e5b673e2", size = 2263655, upload-time = "2026-07-21T02:55:27.022Z" }, + { url = "https://files.pythonhosted.org/packages/4a/49/c310a7335323801c0e65582c169fb602b543b95e2eae890b02edf9dc5118/complexipy-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9234b0d55b08cf741698d42aaa4795dcaf0c62363c6e99dc96f174a34c4825e", size = 2457989, upload-time = "2026-07-21T02:55:28.413Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/14e783483f67d39c9fe81cadf64071b762a180727090ba18905cd82e6bbf/complexipy-6.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0a18dd788f7d6ecd7a7c60e3d13caf1654ac09671457b5324982abfa2740b14", size = 2397580, upload-time = "2026-07-21T02:55:29.737Z" }, + { url = "https://files.pythonhosted.org/packages/81/7e/8ba7c591098aa8e8a8d807a17198d099383d58407624ebb603792417b022/complexipy-6.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed4d2bfb2530108a2c989ac10025fb14c7ba061a9d145b30df3c35011dec4e75", size = 2613362, upload-time = "2026-07-21T02:55:31.17Z" }, + { url = "https://files.pythonhosted.org/packages/c1/41/dcd6a3bde786d8e23c7c5957cd3e89f9e1d43ba4ec7dd01c77dfaacb458c/complexipy-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:151aa0c93face042cceacdc796ea8245ef6680e87c5fc6b7aa2806338ffb054f", size = 2846506, upload-time = "2026-07-21T02:55:33.963Z" }, + { url = "https://files.pythonhosted.org/packages/78/1c/3581b78f37f8e265f6959439cf5f5bfe24ed3a1d4c2bb3ea4318a0ba7b93/complexipy-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7a3c26a80cd3596c43e21bad4e70a9c3539a3b2074ac5fa59aa27fc78526b7c", size = 2520110, upload-time = "2026-07-21T02:55:35.428Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8e/ca1c5450babc85e5229dd167f61f95ce066a5e6970327d27b917cb2e9eba/complexipy-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59b06e40f0cde9537612d680efc7261a9fef2361a16042d07564dcb015648349", size = 2482385, upload-time = "2026-07-21T02:55:37.01Z" }, + { url = "https://files.pythonhosted.org/packages/a3/56/02d5c2bed94920b88be369bff7447307ac4aa7e7d1c4d7950fff4b6c2f22/complexipy-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:62e87fbb39a4d3f8cecd15566b9b72ca8c7dba49669d10c9735ae46399fb5507", size = 2636515, upload-time = "2026-07-21T02:55:38.603Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c4/561d63b6932b011ad37e16daca0b521193d2bb599de5188fcc2f2cc8ee08/complexipy-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ad2b6f1c66740b6d22f4aab50c2ce632b8adfb27696ccb7f013127dd8e392d2b", size = 2735997, upload-time = "2026-07-21T02:55:40.2Z" }, + { url = "https://files.pythonhosted.org/packages/1d/06/10bd96aa2eab67b1da603e32cf198cb682f70fccc0b096702216fac3b612/complexipy-6.1.0-cp311-cp311-win32.whl", hash = "sha256:0b26c5d8996173638ab7f6c1c24d853037aa868bd5dcd31dead6c06ba945de5c", size = 2032700, upload-time = "2026-07-21T02:55:42.031Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a7/c15db11299a29cfbc57ea5d23064fd2ed19f6a4cc85bc97203bd28ce73e0/complexipy-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:839120b4ca623b333ccd870be8615b29e191512139208009d841c9a2119ddbbc", size = 2185944, upload-time = "2026-07-21T02:55:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e3/e3b635c9d7a6ce7a3306a0e054c305f41d693968addef683fe1bd4873ada/complexipy-6.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7049f7b863c64dff197cb61a9b4f5a811131f7342b02f0bef85dd2f756cbdfa0", size = 2314990, upload-time = "2026-07-21T02:55:45.006Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/fa707ac54354781f40acb90a0be05609a943947346ef90a8c62a7a0fc302/complexipy-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:005bb5cf503085ed4c6eb71f05b382955cff04da3ca82e6ae5a4cec8931a2684", size = 2257525, upload-time = "2026-07-21T02:55:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d0/24af023a8dc52186197f8267c5388b87d1cd23bf0fd5bf200a95d444c42e/complexipy-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca54e08c608ac2bdf193f2964810885a94e8d635162c797f4181e3f02616fe8b", size = 2459966, upload-time = "2026-07-21T02:55:49.508Z" }, + { url = "https://files.pythonhosted.org/packages/e6/51/5ddfd26ad1ea17ae18394cae2a26fb855d5dc76cdab02a9b69978a177e18/complexipy-6.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5d0d99cbe66827c6a65e5e87ac9c8c1c0c59b6341664e14dbae28347fb583ffd", size = 2390309, upload-time = "2026-07-21T02:55:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/5b/9f/14ab20e6dd5ab373a7eed7b05bdbb7115227578f84547fe34cb7c20f2e4f/complexipy-6.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25841eebeae79a9bece43b4a0fc6122daf2754ae38c1592ec40cfdd79abf06a1", size = 2607095, upload-time = "2026-07-21T02:55:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/9a69dde479f84e60eea8e25c86f85b9f739149e182e360014c0cbdbd10ee/complexipy-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60971fa1af9d733dde5e3b74f5754601cfbd0a80a8a79ed6ad611d9cdbbc8ebd", size = 2845207, upload-time = "2026-07-21T02:55:54.479Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1b/3c3ad1f0975ef37a01983f0f4c2b7d97683dde0b05eaa4a5266d1a6d606a/complexipy-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbbca8d21be855d96922182567a7f960d3e43261624c4bf3d75e681caefd3a52", size = 2521940, upload-time = "2026-07-21T02:55:56.19Z" }, + { url = "https://files.pythonhosted.org/packages/2e/81/e911ac8ef33bbe9488c1a8b8672be221239ca654753cc65532647c4e9e79/complexipy-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da550255d1e98410d1c9a4b7fbc317c55a6d01e0abd00a39a4a987440e29eac0", size = 2483373, upload-time = "2026-07-21T02:55:57.699Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8b/50719500a3a029db1e5b8eb7c850ebda0d1c88ae02a7b875fc57a30ea9e4/complexipy-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:145985d41fc6228d1ee8d32fac482d616389b83589607167d5432645077252d8", size = 2639286, upload-time = "2026-07-21T02:55:59.139Z" }, + { url = "https://files.pythonhosted.org/packages/27/36/63506e1e20a406bb920460497a2d08a6b6b69046d2d385271b982963e838/complexipy-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a8fbb647981341c333c3c5eb8f8d196827113c50556a8b11e7c9516de94050b", size = 2735436, upload-time = "2026-07-21T02:56:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/a7/cf/5cda5d4d85ef805e7417f6171447fbf2300849fd6d5a44363b9be6b09940/complexipy-6.1.0-cp312-cp312-win32.whl", hash = "sha256:727868a526e9b6e523b51f382c0da106c91be1f418a3eef33651ae63c6601ee2", size = 2033169, upload-time = "2026-07-21T02:56:02.361Z" }, + { url = "https://files.pythonhosted.org/packages/5d/24/06c765985735c38f34d537cb94fc5ecf0a49102db9e62e8db7516e352add/complexipy-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:8695daf039d4bb13ed0acba2dd5d5cd94a247e84ff9a9f632a8c136f3ebe2154", size = 2185281, upload-time = "2026-07-21T02:56:03.763Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/1f26fd7fcc5f360b71dbb2824870856cc87446f5116907145b49ee5a56a1/complexipy-6.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:32474dd260fd4de9f447e5d78ebd9e31ff80f8bff97b697fd999f07c307fd0bf", size = 2314632, upload-time = "2026-07-21T02:56:05.247Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/2d5fa2554df3ef38cafa1e0744126e4c6aa419a6f1f907cbe068b3b06618/complexipy-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:945a6bd6d4d12072da33463dbde5e1c535e7f110007aca2d27cb0c9ef7355e11", size = 2257417, upload-time = "2026-07-21T02:56:06.822Z" }, + { url = "https://files.pythonhosted.org/packages/15/47/d4f5387d51684854f6d5efd1c5cd8978ede18736f1781c051210621426b9/complexipy-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53a503610a8ebef922fa4f5a54255514e7d9be4a9f0cc696d65d37fd6454b21f", size = 2460017, upload-time = "2026-07-21T02:56:08.443Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ac/a4bf72c9f8b0d8708de47bac2aaa25dc8e0a177ee34f0b8bfe3efe7f3840/complexipy-6.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a8f0b2a4a78639ebce52f4539781b7462359aec00168d174070e0cb9fe441bb", size = 2390581, upload-time = "2026-07-21T02:56:10.055Z" }, + { url = "https://files.pythonhosted.org/packages/d6/18/df45eff4f8888e502f638560f426d2f1f9401cf49a2186a0486284794dd7/complexipy-6.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31a0f75eb9352356576c46cc291ff838f877be88d0e674d0c33e64057076e513", size = 2607052, upload-time = "2026-07-21T02:56:11.899Z" }, + { url = "https://files.pythonhosted.org/packages/1e/56/a1200b84deaf74a23b891d0b079d56efde80c1eaa3efd5e81a80025c7d95/complexipy-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2b8548df29ccbfa5faa280ba90a53412867e361406d8450fabe0ec0de3b565e", size = 2845262, upload-time = "2026-07-21T02:56:13.57Z" }, + { url = "https://files.pythonhosted.org/packages/50/60/954e14049dfe1fa40c404da84fa1ea31470217ae16ad8eb23f4dfd87e57c/complexipy-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:979ae781453ab4aedfb80005215a33c7f686dbc0cca2fbbc73f7cd661bd395e9", size = 2521277, upload-time = "2026-07-21T02:56:15.344Z" }, + { url = "https://files.pythonhosted.org/packages/76/4d/ae43187c7e14a92f71a79a053f2373d4ea1e441474eab4348c3e85a3af56/complexipy-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42f9d640b4d32a4b8890538c1a926542cb5863a872789e4b87000b0fd143fa16", size = 2483279, upload-time = "2026-07-21T02:56:16.882Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a5/b6aad3edb95763e9532c8ba50a7d892720fea11a6755c8016cd1355d8c25/complexipy-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23586c2d4fb6b6ab55ed91acfdf541b6984f037ea1b5e30e85a5d9f8e34df029", size = 2639142, upload-time = "2026-07-21T02:56:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/8f/af/b0ca0b3d8ce5d2f5f39eac16774b5efcf3655070daba1e06d151621c3efc/complexipy-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b9acb8d2e9ff6991d3c2a5323c4f93c8dead654ccf233d4bdf9642ad0d7919e", size = 2735164, upload-time = "2026-07-21T02:56:20.358Z" }, + { url = "https://files.pythonhosted.org/packages/87/84/12a41b0d3ee4c0cd063687e493d4773ece1c3f9227185ad0f1302ba37295/complexipy-6.1.0-cp313-cp313-win32.whl", hash = "sha256:723b505b8a29e9a168f6fed9f4ff72b6c9442e7f5242b65245b2587af3d75c5d", size = 2032817, upload-time = "2026-07-21T02:56:21.951Z" }, + { url = "https://files.pythonhosted.org/packages/2d/89/3ceb15bb33d3424c97deb5be8c10a927436df96cd4ae6b7edd849ea80f74/complexipy-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:2a764e69fb3af86c5af966083fd3f182bd7b611229e0c14041817941b4d88104", size = 2184842, upload-time = "2026-07-21T02:56:23.505Z" }, + { url = "https://files.pythonhosted.org/packages/52/1a/354d3c8a6778ed496c205ca67b973cdfc515a248b9b753a2d683ca75a4e6/complexipy-6.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c64f8ce17782a893fdea9e3c4c34ceaaefb5ef8e4395cbdf9d6611e96c01df52", size = 2459143, upload-time = "2026-07-21T02:57:42.457Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a5/3dcf1b3d3a80f08301a9012077003162cf15baf42f40f2cdccc553e74b3b/complexipy-6.1.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12cc9c21f71ab4c7353397d9f302625b1b93542dba656e3055add91b052eafeb", size = 2398491, upload-time = "2026-07-21T02:57:44.455Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8b/ed693e31cb5cb6193719838db3ee1c9bf240189641085411687173088149/complexipy-6.1.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b2b13e11b1e48b69cde0590ba4e72d01aa1501ee842ac2610dec2e9de207ba4", size = 2615006, upload-time = "2026-07-21T02:57:46.215Z" }, + { url = "https://files.pythonhosted.org/packages/06/d7/184b311dba0dde14fbf58ca1447b3db1af08d272ab5879b7556be84a81a1/complexipy-6.1.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ec7c471de442fcbbd92ecc3ffa336baf42af478ed26f29c8ab18685d8be18f5", size = 2848972, upload-time = "2026-07-21T02:57:47.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/78/fd1a349962a9cf25cedae77ad5fca4fa123a0e98fe8d1d666969ce45cc46/complexipy-6.1.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1ade4758f448d35aea9911da1dd3fc4359225614cd5b222087d9ef256936e01", size = 2520243, upload-time = "2026-07-21T02:57:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/db2d356dce2971b5b0eab75481bdeb7c12f23bc7d71785c4223b4bf8f3c5/complexipy-6.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0bb39162eb2fa0a26877a2432d0300c6e9d8fbbfc1809eb519dfcc62b566b162", size = 2483322, upload-time = "2026-07-21T02:57:51.083Z" }, + { url = "https://files.pythonhosted.org/packages/23/da/be6e3582b6775772f6544441289ab990be19659ca8e1137fbbab5d223179/complexipy-6.1.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:a0d51f816bf83617288a21f5d7bed64b9b7b10d0cb2cef5f9ef080aa295e21a9", size = 2638624, upload-time = "2026-07-21T02:57:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/7a/85/522ec3cabeea5118126f08ab716e839ee1d864de272742c6bb9e03012b7c/complexipy-6.1.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:cf3116bf7345d888eadd932ffd67a7e6b2ce01d83b742561104fb5a83bbb69c2", size = 2736621, upload-time = "2026-07-21T02:57:54.382Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "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", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] + +[[package]] +name = "cuda-bindings" +version = "12.9.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform != 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten'", +] +dependencies = [ + { name = "cuda-pathfinder", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/f3/f9d1095f90d2a4df24cfcafe7487fd9444c6dacb94e3722be6fedd8ac26c/cuda_bindings-12.9.7-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16043ef5b15ab88fe9954c5c2061b1d8007591b27f2c916331056de0ebc6187e", size = 7114834, upload-time = "2026-05-27T18:44:07.746Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8a/1251e1794b69865aacd5629936006b18ea0816a495de4ecea9a825556eb3/cuda_bindings-12.9.7-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6496a88d84b1209d6651b0370c19c26319e157c22f6d018bf9a358cd8049041", size = 7647147, upload-time = "2026-05-27T18:44:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/158392f6572e6e0def70ca39029c46b75e02ea4a43c63ff7320b3d180a29/cuda_bindings-12.9.7-cp311-cp311-win_amd64.whl", hash = "sha256:c392ffa5010ef4073bfd9dfff4d1ae56032094ed52d3d732014f8e41a73e6b59", size = 7218081, upload-time = "2026-05-27T18:44:11.104Z" }, + { url = "https://files.pythonhosted.org/packages/32/45/557d4ed1fa54f0c7db8aee083229f624990d69f7d00f55477eed5c7e169a/cuda_bindings-12.9.7-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0666d3c082ef8f4b2d670950589373550e9f3bf564d635dd883f24a0b40402ff", size = 7071026, upload-time = "2026-05-27T18:44:13.356Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/e3c6e58ece26a053419ba0a18444b5443cfc64451bbf37f84e8143b8bdca/cuda_bindings-12.9.7-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c7ef48c5e13ae90f3b2ecfb72f8e99ac43c8f4c43e67e1325b8aae331453687", size = 7611059, upload-time = "2026-05-27T18:44:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/afaa3de4d491a55af8961081e0b69c08d51bfbe471c359a7bddb4a28ca41/cuda_bindings-12.9.7-cp312-cp312-win_amd64.whl", hash = "sha256:3c089aaf4f5f570ec50244c68f5a2b00a2c9a8e01e04219fd2e36e340be0d88b", size = 7400841, upload-time = "2026-05-27T18:44:17.164Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7b/f1575e41e1a17dc2f2a408b2e8e864c9324e41e3e23f6401e5efc54c152a/cuda_bindings-12.9.7-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:266379e4942051f544a8e7ea1a30ead8d7e8199b6b30fcdc8917cae2bf614e61", size = 6978549, upload-time = "2026-05-27T18:44:18.839Z" }, + { url = "https://files.pythonhosted.org/packages/9d/dc/62d62eb4f91eb721bcf46da51b13e9872ccd8fa7e60eb8ba7b7baeac72c6/cuda_bindings-12.9.7-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59cf4a37b0d662ba15037c9ceebe1a306ebf2c01a8235a09be13cd07094fdb74", size = 7457675, upload-time = "2026-05-27T18:44:20.637Z" }, + { url = "https://files.pythonhosted.org/packages/43/b2/753fe88151001d0dc23f56a8e119fe06b991b0d1a885fa02f9852b12f523/cuda_bindings-12.9.7-cp313-cp313-win_amd64.whl", hash = "sha256:5bd89dcb78475a6d8a4620ea94b74edf0cbbeacee6d1622d8f94452c1e8d3f15", size = 7360097, upload-time = "2026-05-27T18:44:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/f9/77/94d9b85f26add6fe9c9cb7c4ec3b96bc598f7ea5cfbd7490cc0a36adf5be/cuda_bindings-12.9.7-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dbcd4801954eb3508f4dc2fa0d0c8eb93eb3f45326fd61be2731418c371e7a0", size = 6870886, upload-time = "2026-05-27T18:44:24.164Z" }, + { url = "https://files.pythonhosted.org/packages/04/dd/3ec34b569e1b990b11276feba306bf8f446656cc38e8ed0f49b5facfeffa/cuda_bindings-12.9.7-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3747ea132642416786a8e31bf229032df3a7856911ae5426a7be53d032df183d", size = 7345663, upload-time = "2026-05-27T18:44:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c8/d79a20ba396e7ab2dfdd4b72b62356972b25b88aee2ded49a70c797ddea1/cuda_bindings-12.9.7-cp313-cp313t-win_amd64.whl", hash = "sha256:64f7ade7a7a3b69001489753acc21706d9dbda32db8deb68a767a0a0aab30b68", size = 7780136, upload-time = "2026-05-27T18:44:28.121Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform != 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten'", +] +dependencies = [ + { name = "cuda-pathfinder", marker = "(sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, + { url = "https://files.pythonhosted.org/packages/93/f7/0e35987a21914f84068061dcf4b61466ccbce1c62ddc9727596d5ed0c26f/cuda_bindings-13.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:507b0e19e7f934c5e30f30f0244ad70a75812619a7d3a0d742543caae1bd50f1", size = 5664286, upload-time = "2026-05-29T23:11:47.719Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/7c/95/872a0392122f1fb43fcb06869790ef3171f37beee9f7db8f441739113570/cuda_bindings-13.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:b134dd8c5c66ae4c4ad814f7aee88fd215353c077010cbc47e3b55ed35ec9eff", size = 5875099, upload-time = "2026-05-29T23:11:54.635Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2f/6a0dd496550c6fafbf6aeb1bf40242eeabb2fd138a43892aabb4be8224c2/cuda_bindings-13.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:18c8c167c8907b8f02531ca810534315c458dabef31f7965095619bf647b9202", size = 5830027, upload-time = "2026-05-29T23:12:01.205Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b4/d088047afe39827556df21118cac9ffd20cc3f968c99a7681494d1eb333c/cuda_pathfinder-1.6.0-py3-none-any.whl", hash = "sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51", size = 54591, upload-time = "2026-07-21T15:03:56.224Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "12.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform != 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/88/2dbc37975fffb874418b14380418a1b99cb36f2101fd1d08c54e06ee8c95/cuda_toolkit-12.6.3-py2.py3-none-any.whl", hash = "sha256:79d8605baeb6c2f695761e0efb54bc62dbc3c9e32eb0742df7669c07befaa8f7", size = 2288, upload-time = "2025-08-13T02:03:05.283Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +cudart = [ + { name = "nvidia-cuda-runtime-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +cufft = [ + { name = "nvidia-cufft-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +cufile = [ + { name = "nvidia-cufile-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +cupti = [ + { name = "nvidia-cuda-cupti-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +curand = [ + { name = "nvidia-curand-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +cusolver = [ + { name = "nvidia-cusolver-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +cusparse = [ + { name = "nvidia-cusparse-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +nvtx = [ + { name = "nvidia-nvtx-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform != 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten'", +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "(platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (platform_machine != 'aarch64' and platform_machine != 'x86_64' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'aarch64' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (platform_machine == 'aarch64' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (platform_machine == 'aarch64' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (platform_machine == 'x86_64' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (platform_machine == 'x86_64' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +curand = [ + { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'x86_64' and sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (platform_machine == 'AMD64' and sys_platform == 'win32' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] + +[[package]] +name = "curve-curator" +version = "0.6.0" +source = { git = "https://github.com/nictru/curve_curator.git#e98d052edee051732c23377f2974eb3db6ee0339" } +dependencies = [ + { name = "bokeh" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pytest" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "statsmodels" }, + { name = "tqdm" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264", size = 2203120, upload-time = "2026-06-01T19:30:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc", size = 3059958, upload-time = "2026-06-01T19:30:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e", size = 5236515, upload-time = "2026-06-01T19:30:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7", size = 5256138, upload-time = "2026-06-01T19:30:49.113Z" }, + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "donfig" +version = "0.8.1.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/71/80cc718ff6d7abfbabacb1f57aaa42e9c1552bfdd01e64ddd704e4a03638/donfig-0.8.1.post1.tar.gz", hash = "sha256:3bef3413a4c1c601b585e8d297256d0c1470ea012afa6e8461dc28bfb7c23f52", size = 19506, upload-time = "2024-05-23T14:14:31.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592, upload-time = "2024-05-23T14:13:55.283Z" }, +] + +[[package]] +name = "drevalpy" +version = "1.5.1" +source = { editable = "." } +dependencies = [ + { name = "anndata", version = "0.12.19", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "anndata", version = "0.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "curve-curator" }, + { name = "editables" }, + { name = "filelock" }, + { name = "fsspec", extra = ["http", "s3"] }, + { name = "gensim" }, + { name = "gseapy" }, + { name = "joblib" }, + { name = "lightgbm" }, + { name = "matplotlib" }, + { name = "mudata" }, + { name = "multiqc" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "optuna" }, + { name = "pandas" }, + { name = "platformdirs" }, + { name = "plotly" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "pyparsing" }, + { name = "pytorch-lightning" }, + { name = "pyyaml" }, + { name = "rdkit" }, + { name = "rich" }, + { name = "scikit-learn" }, + { name = "scikit-posthocs" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "subword-nmt" }, + { name = "torch-geometric" }, + { name = "transformers" }, + { name = "typer" }, + { name = "universal-pathlib" }, + { name = "wandb" }, + { name = "xgboost" }, +] + +[package.optional-dependencies] +cpu = [ + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +cu126 = [ + { name = "torch", version = "2.13.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" } }, +] +cu130 = [ + { name = "torch", version = "2.13.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" } }, +] + +[package.dev-dependencies] +dev = [ + { name = "complexipy" }, + { name = "coverage" }, + { name = "flaky" }, + { name = "jupyter" }, + { name = "prek" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, + { name = "types-pyyaml" }, + { name = "vulture" }, + { name = "xdoctest" }, +] +docs = [ + { name = "sphinx" }, + { name = "sphinx-autobuild" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-design" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinxcontrib-mermaid" }, +] + +[package.metadata] +requires-dist = [ + { name = "anndata" }, + { name = "curve-curator", git = "https://github.com/nictru/curve_curator.git" }, + { name = "editables", specifier = ">=0.3" }, + { name = "filelock", specifier = ">=3.15.2" }, + { name = "fsspec", extras = ["s3", "http"] }, + { name = "gensim", specifier = ">=4.4.0" }, + { name = "gseapy", specifier = ">=1.1.0" }, + { name = "joblib" }, + { name = "lightgbm", specifier = ">=4.0.0" }, + { name = "matplotlib" }, + { name = "mudata", specifier = ">=0.3.10" }, + { name = "multiqc", specifier = ">=1.35" }, + { name = "networkx" }, + { name = "numpy", specifier = ">=1.20" }, + { name = "optuna" }, + { name = "pandas" }, + { name = "platformdirs", specifier = ">=4.0" }, + { name = "plotly" }, + { name = "pyarrow" }, + { name = "pydantic", specifier = ">=2.5" }, + { name = "pyparsing", specifier = ">=3" }, + { name = "pytorch-lightning", specifier = ">=2.5" }, + { name = "pyyaml" }, + { name = "rdkit", specifier = ">=2026.3.4" }, + { name = "rich", specifier = ">=15.0.0" }, + { name = "scikit-learn", specifier = ">=1.4" }, + { name = "scikit-posthocs" }, + { name = "scipy" }, + { name = "subword-nmt", specifier = ">=0.3.8" }, + { name = "torch", marker = "extra == 'cpu'", specifier = ">=2.1", index = "https://download.pytorch.org/whl/cpu", conflict = { package = "drevalpy", extra = "cpu" } }, + { name = "torch", marker = "extra == 'cu126'", specifier = ">=2.1", index = "https://download.pytorch.org/whl/cu126", conflict = { package = "drevalpy", extra = "cu126" } }, + { name = "torch", marker = "extra == 'cu130'", specifier = ">=2.1", index = "https://download.pytorch.org/whl/cu130", conflict = { package = "drevalpy", extra = "cu130" } }, + { name = "torch-geometric" }, + { name = "transformers", specifier = ">=5.14.0" }, + { name = "typer", specifier = ">=0.26,<0.27" }, + { name = "universal-pathlib" }, + { name = "wandb", specifier = ">=0.24.0" }, + { name = "xgboost", specifier = ">=3.2.0" }, +] +provides-extras = ["cpu", "cu126", "cu130"] + +[package.metadata.requires-dev] +dev = [ + { name = "complexipy" }, + { name = "coverage" }, + { name = "flaky" }, + { name = "jupyter", specifier = ">=1.1.1" }, + { name = "prek" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff", specifier = ">=0.15.8" }, + { name = "ty" }, + { name = "types-pyyaml" }, + { name = "vulture", specifier = ">=2.16" }, + { name = "xdoctest" }, +] +docs = [ + { name = "sphinx", specifier = ">=4.0.2" }, + { name = "sphinx-autobuild", specifier = ">=2021.3.14" }, + { name = "sphinx-autodoc-typehints" }, + { name = "sphinx-design", specifier = ">=0.7.0" }, + { name = "sphinx-rtd-theme", specifier = ">=1.0.0,<3.0.3" }, + { name = "sphinxcontrib-mermaid", specifier = ">=2.1.0" }, +] + +[[package]] +name = "editables" +version = "0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f8/02a4e9a0cb961b0feeb431ac96b231c157ecdcbbeacafe9e2fdb4b1dde39/editables-0.6.tar.gz", hash = "sha256:1163834902381c4613787951c5914800fdf155ae08848a373b8ea5006780977c", size = 16763, upload-time = "2026-04-14T10:39:04.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/0e/d095533037fc9468f947b19f2da53f869804632b508808de41e86e801797/editables-0.6-py3-none-any.whl", hash = "sha256:d70e4698078a1d033e7786d9c64e5be070d058a67c21417024d38a58ac20aa43", size = 5438, upload-time = "2026-04-14T10:39:03.663Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, +] + +[[package]] +name = "flaky" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/c5/ef69119a01427204ff2db5fc8f98001087bcce719bbb94749dcd7b191365/flaky-3.8.1.tar.gz", hash = "sha256:47204a81ec905f3d5acfbd61daeabcada8f9d4031616d9bcb0618461729699f5", size = 25248, upload-time = "2024-03-12T22:17:59.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b8/b830fc43663246c3f3dd1ae7dca4847b96ed992537e85311e27fa41ac40e/flaky-3.8.1-py2.py3-none-any.whl", hash = "sha256:194ccf4f0d3a22b2de7130f4b62e45e977ac1b5ccad74d4d48f3005dcc38815e", size = 19139, upload-time = "2024-03-12T22:17:51.59Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] +s3 = [ + { name = "s3fs" }, +] + +[[package]] +name = "gensim" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "smart-open" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/80/fe9d2e1ace968041814dbcfce4e8499a643a36c41267fa4b6c4f54cce420/gensim-4.4.0.tar.gz", hash = "sha256:a3f5b626da5518e79a479140361c663089fe7998df8ba52d56e1ded71ac5bdf5", size = 23260095, upload-time = "2025-10-18T02:06:45.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/7b/81b6c74b32700ee63f6720a60ca0c89ab59b12933257b47572c8af017658/gensim-4.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7590e7313848ca8f3ff064898bcd6ecf6ec71c752cf4d3ec83f7ac992bc7c088", size = 24463159, upload-time = "2025-10-18T01:51:09.7Z" }, + { url = "https://files.pythonhosted.org/packages/38/7c/18d40f341276a7461962512ca1fb716d5982db57615dfa272f651ecb96d7/gensim-4.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a027238b5eb544a17afe73ec227d6a7e0c6b4e2108b1131c0b8f291a0e0e2e", size = 24453170, upload-time = "2025-10-18T01:51:58.42Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/6bd6919d31bdd473472ce1c18c24fcab5869b8b15166a424d11ce33a5eab/gensim-4.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e110e2d3533f5b35239850a96cb2016a586ecd85671d655079b3048332b7169", size = 27760793, upload-time = "2025-10-18T01:52:47.866Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fa/85531b39c1beb5a4203929ba83d94d886cec40d0fb0bef8ca05fd1cc7a38/gensim-4.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91a7fa5e814e7b1bad4b2dffa8d62c1e55410d5cbdf930714c1997ffb4404db8", size = 27809988, upload-time = "2025-10-18T01:53:36.978Z" }, + { url = "https://files.pythonhosted.org/packages/10/c3/7e22d6f7d88c4ea6a3a84481f00538252659d285713c3b7e2e1537b0e7e1/gensim-4.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5e2c1d584d1c7d16b2a0fe7d2f6f59a451422df7b5edb7e3ca46c8e462782127", size = 24396172, upload-time = "2025-10-18T01:54:25.711Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/d5285865ca54b93d41ccd8683c2d79952434957c76b411283c7a6c66ca69/gensim-4.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0845b2fa039dbea5667fb278b5414e70f6d48fd208ef51f33e84a78444288d8d", size = 24467245, upload-time = "2025-10-18T01:55:09.924Z" }, + { url = "https://files.pythonhosted.org/packages/32/59/f0ea443cbfb3b06e1d2e060217bb91f954845f6df38cbc9c5468b6c9c638/gensim-4.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1853fc5be730f692c444a826041fef9a2fc8d74c73bb59748904b2e3221daa86", size = 24455775, upload-time = "2025-10-18T01:55:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b8/9b0ba15756e41ccfdd852f9c65cd2b552f240c201dc3237ad8c178642e80/gensim-4.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23a2a4260f01c8f71bae5dd0e8a01bb247a2c789480c033e0eaba100b0ad4239", size = 27771345, upload-time = "2025-10-18T01:56:41.448Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/c29701826c963b04a43d5d7b87573a74040387ab9219e65b10f377d22b5b/gensim-4.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b73ff30af6ddd0d2ddf9473b1eb44603cd79ec14c87d93b75291802b991916c", size = 27864118, upload-time = "2025-10-18T01:57:32.428Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f2/9ec6863143888bf390cdc5261f6d9e71d79bc95d98fb815679dba478d5f6/gensim-4.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b3a3f9bc8d4178b01d114e1c58c5ab2333f131c7415fb3d8ec8f1ecfe4c5b544", size = 24400277, upload-time = "2025-10-18T01:58:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/80/6c/4e522973e07ca491d33cc7829996b9e8c8663a16b3f87f580cbdc2732d97/gensim-4.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8961b7a2bb5190b46bc6cd26c29d5bfea22f99123ed5f506ebd0aaf65996758", size = 24460186, upload-time = "2025-10-18T01:59:01.904Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/593107ee98331128ed20e5d074865587558a0766659be787a40550ab66df/gensim-4.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:59d0d29099a76dd97d4563e002f3488a43e51f99d46387025da38007ebfeeff9", size = 24448880, upload-time = "2025-10-18T01:59:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ef/1675e1a3a04f7d0293a21082f57f4a6a8bf0a9e387da58b71db648b663de/gensim-4.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3bec3e6a1ecaa6439b21a3e42ceb0ca67ffabc114b646f89b1aab5fe69a39ffc", size = 27736031, upload-time = "2025-10-18T02:00:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ee43ef9c391857232603a9ee281e9c5953f7922d70c98c2296a037d1c0b7/gensim-4.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9033b18920b7774e68eafacdbd87252ffa29382ec465ddb88bd036e00fc86365", size = 27826360, upload-time = "2025-10-18T02:01:26.166Z" }, + { url = "https://files.pythonhosted.org/packages/82/f3/4f8f4d478ce69af812c6002b513c5ad3242976923d172dbe5814903be22f/gensim-4.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:6ecb7aed37fb92d24e15a6adbabe693074003263db0fd9ce97c9f4234a9edc1b", size = 24396932, upload-time = "2025-10-18T02:02:11.568Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, + { url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, +] + +[[package]] +name = "gseapy" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "requests" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/d0/5430ceec2657b95f5c00e7a359caefaab44c531c207fd095a90c0757cf48/gseapy-1.3.0.tar.gz", hash = "sha256:bc5daa80376751c42c0beb5cfbb42131ad2de3c9426f97a415df37d713ceaae3", size = 163682, upload-time = "2026-06-21T23:28:07.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/be/4268fe912c76ae446e683744451599028350a5dcbc9018ec243101262f83/gseapy-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:01aad664f7abe37d6a2d735e36f13cea77f0738f79adb60ab7b49a1f191c0ec3", size = 590570, upload-time = "2026-06-21T23:27:12.424Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/ef75fb9f5b61cd464967ac222f151a89715fc20aa248dc3b484f66c92318/gseapy-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4bb82b7b8101a7560a1846287f141b6cb0a397fe0e679ab2d6fbb32ad19ed0d", size = 646012, upload-time = "2026-06-21T23:27:14.538Z" }, + { url = "https://files.pythonhosted.org/packages/71/c7/aa97bc9ab22c3c1dab9e6ef64a0e38fa102a0dc210a048e3c35fd2d31c5d/gseapy-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1175da16fe4cc4b04f06c26f6553727ddf4b1d1bace473790dfa14768080e125", size = 665900, upload-time = "2026-06-21T23:27:16.723Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ee/82ec85ce7380e306a28b7d63d0cc9140331596058b94fec122a36590e7a8/gseapy-1.3.0-cp311-cp311-win32.whl", hash = "sha256:2329f7c1647b1012a5ac2692322a976da568024dfb6b983b1bb45685e1382522", size = 468555, upload-time = "2026-06-21T23:27:18.655Z" }, + { url = "https://files.pythonhosted.org/packages/ce/70/967780193e86542195208ffcb413b234fdfabefe1d83320720e5ea42a3f9/gseapy-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:5a9b6763e2510f0d8b7c9fe743697dbb3230b05e1124658f1cfd4611d9d214eb", size = 487167, upload-time = "2026-06-21T23:27:20.594Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a0/8af33d4ed3df54f4e128244408e14b40f365dc19f012ef62b35fac385f6f/gseapy-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ae3dcbd7ad3d16250b92be7d28e8a346ed3fb051b438387b4cdd1c48400432e1", size = 589900, upload-time = "2026-06-21T23:27:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/42/b8/31644264f8b4b933e08aa1f24ed24b2f74df094168ffec4b74424c662b2d/gseapy-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6f34bd01509bd629c0012dcb638e08e053cc06e4c84490d74ace4312554e1f3", size = 641025, upload-time = "2026-06-21T23:27:24.308Z" }, + { url = "https://files.pythonhosted.org/packages/92/83/6624a14a37dcf1e514806605ce65a0b4ee65ab0c586f72e5e6cf2bd55c17/gseapy-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:218057c8c11ceca3a260fde536416495364528785a3a0e599930853cfe47c61f", size = 664648, upload-time = "2026-06-21T23:27:26.197Z" }, + { url = "https://files.pythonhosted.org/packages/05/1c/61c07976624e035accceb3fa468b71e67faa68fb03d8261b45db40081395/gseapy-1.3.0-cp312-cp312-win32.whl", hash = "sha256:43af0dfb4b7fc4eea7c9e291371b7e2c773d7b3c50c18b6ee5dd582e9dac2de9", size = 467541, upload-time = "2026-06-21T23:27:27.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/7ed420466f95e7b3c7ea0c18b70dfb17a9dd5e5845e0021acd4154f99bae/gseapy-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:87cbdda670d515b5ff2d945f6dcdfcd82bc12272e416b2a47f81ab5959503f27", size = 485247, upload-time = "2026-06-21T23:27:29.606Z" }, + { url = "https://files.pythonhosted.org/packages/53/d9/4edb901b0e8463e657d43c4b1ed68bc4f0ef0fe3cb181c2753ce44b18d01/gseapy-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b180fd519522851a73540c2765fac3943ff659717bfad5fc2d14663d0c20783b", size = 589179, upload-time = "2026-06-21T23:27:31.252Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4f/b6a2d3b5c6abe1b8073f837fbacf2bb1e8d63ffd8a8a97b3560fb90496c4/gseapy-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7a0c0f9daff5ffbe10f7dd8b6a147c9ca47ba6d66fee1eb75f776369d241fa94", size = 640632, upload-time = "2026-06-21T23:27:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d7/9f8c46f619b9e38a60fb453d67c1d522f3be5cb8ab8476e100eb50750443/gseapy-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7500991530d90558f4c60c293a905f3804b398e6b65909190026e12fbfe30f8e", size = 663690, upload-time = "2026-06-21T23:27:35.039Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b4/d0b83a14197a9b6f32598ea3c13220038621a02f7ae04f06b0f2737ac12a/gseapy-1.3.0-cp313-cp313-win32.whl", hash = "sha256:59bb951ed0634e36674432cb554e893cbe90be742d5bdbf548d505eee8c34b16", size = 467192, upload-time = "2026-06-21T23:27:36.855Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7c/81d99dae5c8c5b2601720d6ebc297391564774c6011f6675d993e60c67d6/gseapy-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:c6cb42eb900228f4f079aa8d8a05590b5dea76025664916cdb6d25bec55dfea9", size = 484849, upload-time = "2026-06-21T23:27:38.535Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h5py" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526, upload-time = "2026-03-06T13:49:08.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663, upload-time = "2026-03-06T13:47:49.599Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630, upload-time = "2026-03-06T13:47:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472, upload-time = "2026-03-06T13:47:53.085Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150, upload-time = "2026-03-06T13:47:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544, upload-time = "2026-03-06T13:47:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013, upload-time = "2026-03-06T13:47:59.01Z" }, + { url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673, upload-time = "2026-03-06T13:48:00.626Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834, upload-time = "2026-03-06T13:48:02.579Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c0/5d4119dba94093bbafede500d3defd2f5eab7897732998c04b54021e530b/h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d", size = 3685604, upload-time = "2026-03-06T13:48:04.198Z" }, + { url = "https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d", size = 3061940, upload-time = "2026-03-06T13:48:05.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/84/06281c82d4d1686fde1ac6b0f307c50918f1c0151062445ab3b6fa5a921d/h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527", size = 5198852, upload-time = "2026-03-06T13:48:07.482Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e", size = 5405250, upload-time = "2026-03-06T13:48:09.628Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/9790c1655eabeb85b92b1ecab7d7e62a2069e53baefd58c98f0909c7a948/h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794", size = 5190108, upload-time = "2026-03-06T13:48:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/51/d7/ab693274f1bd7e8c5f9fdd6c7003a88d59bedeaf8752716a55f532924fbb/h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074", size = 5419216, upload-time = "2026-03-06T13:48:13.322Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6", size = 3182868, upload-time = "2026-03-06T13:48:15.759Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286, upload-time = "2026-03-06T13:48:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9e/6142ebfda0cb6e9349c091eae73c2e01a770b7659255248d637bec54a88b/h5py-3.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:370a845f432c2c9619db8eed334d1e610c6015796122b0e57aa46312c22617d9", size = 3671808, upload-time = "2026-03-06T13:48:19.737Z" }, + { url = "https://files.pythonhosted.org/packages/b0/65/5e088a45d0f43cd814bc5bec521c051d42005a472e804b1a36c48dada09b/h5py-3.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42108e93326c50c2810025aade9eac9d6827524cdccc7d4b75a546e5ab308edb", size = 3045837, upload-time = "2026-03-06T13:48:21.854Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/6172269e18cc5a484e2913ced33339aad588e02ba407fafd00d369e22ef3/h5py-3.16.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:099f2525c9dcf28de366970a5fb34879aab20491589fa89ce2863a84218bb524", size = 5193860, upload-time = "2026-03-06T13:48:24.071Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/ef2b6fe2903e377cbe870c3b2800d62552f1e3dbe81ce49e1923c53d1c5c/h5py-3.16.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9300ad32dea9dfc5171f94d5f6948e159ed93e4701280b0f508773b3f582f402", size = 5400417, upload-time = "2026-03-06T13:48:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/bc/81/5b62d760039eed64348c98129d17061fdfc7839fc9c04eaaad6dee1004e4/h5py-3.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:171038f23bccddfc23f344cadabdfc9917ff554db6a0d417180d2747fe4c75a7", size = 5185214, upload-time = "2026-03-06T13:48:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/28/c4/532123bcd9080e250696779c927f2cb906c8bf3447df98f5ceb8dcded539/h5py-3.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e420b539fb6023a259a1b14d4c9f6df8cf50d7268f48e161169987a57b737ff", size = 5414598, upload-time = "2026-03-06T13:48:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/a27997f84341fc0dfcdd1fe4179b6ba6c32a7aa880fdb8c514d4dad6fba3/h5py-3.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:18f2bbcd545e6991412253b98727374c356d67caa920e68dc79eab36bf5fedad", size = 3175509, upload-time = "2026-03-06T13:48:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/bb8647521d4fd770c30a76cfc6cb6a2f5495868904054e92f2394c5a78ff/h5py-3.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:656f00e4d903199a1d58df06b711cf3ca632b874b4207b7dbec86185b5c8c7d4", size = 2647362, upload-time = "2026-03-06T13:48:33.411Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/9b/d3bb4e7d792835daf34dd7091bbc7d7b4e0437d9388f1ea7239cce49f478/huggingface_hub-1.24.0.tar.gz", hash = "sha256:18431ff4daae0749aa9ba102fc952e314c98e1d30ebdec5319d85ca0a83e1ae5", size = 921848, upload-time = "2026-07-17T09:54:01.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl", hash = "sha256:6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59", size = 771904, upload-time = "2026-07-17T09:53:59.106Z" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, +] + +[[package]] +name = "humanize" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/ea/13a1ef3c12d12662905801495283530251918b70d62d368f1d2e0272c70d/humanize-4.16.0.tar.gz", hash = "sha256:7dc2244a2f84a4bfb1d36c37bac80cd78e35cdc5c119206d87b018e1445f3a3f", size = 89515, upload-time = "2026-06-30T16:17:29.859Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/aa/0b7365d30fed43e7a3449aba1fe20a0a7174d9cf13e282af4e69ac825441/humanize-4.16.0-py3-none-any.whl", hash = "sha256:353eb2f34c09d098b2880eee8bef21832eae6d174f48c5762fff7e5fcb74d01d", size = 137209, upload-time = "2026-06-30T16:17:28.36Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, +] + +[[package]] +name = "ipython" +version = "9.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "(sys_platform != 'emscripten' and sys_platform != 'win32') or (sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'win32' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "(sys_platform != 'cygwin' and sys_platform != 'emscripten') or (sys_platform == 'cygwin' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'cygwin' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'cygwin' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "json5" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/7d/05c46a96a78147ae3bf99c2f4169ce144a70220b8d6fcd56f6ec368b8ce9/json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71", size = 53278, upload-time = "2026-06-19T20:08:27.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/be/59527c99478aade6bb33a68d72e6e18dd4e6ff6eacfc7d01bdb15bc76912/json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618", size = 36570, upload-time = "2026-06-19T20:08:26.748Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupyter-console" }, + { name = "jupyterlab" }, + { name = "nbconvert" }, + { name = "notebook" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/f3/af28ea964ab8bc1e472dba2e82627d36d470c51f5cd38c37502eeffaa25e/jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a", size = 5714959, upload-time = "2024-08-30T07:15:48.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83", size = 2657, upload-time = "2024-08-30T07:15:47.045Z" }, +] + +[[package]] +name = "jupyter-builder" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/61/47f7ae054f5cd3983c10e1d65a6eb7fcd4b87ebb1056e190ef7d63ff4f19/jupyter_builder-1.1.1.tar.gz", hash = "sha256:1a13977912b08deda77fce2c803940131c27cf77a27ed64b9ffca25aa0ed7e6c", size = 971667, upload-time = "2026-07-17T13:14:47.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/cc/f6a12de1c890ea5dd2816c5c76d5ac6d3ed52db3c37f78691328207d13b9/jupyter_builder-1.1.1-py3-none-any.whl", hash = "sha256:f9c14bc55c0488a073f62af12d468936fcf9ecb7e9dd802f6f9c33de46ad70db", size = 913264, upload-time = "2026-07-17T13:14:45.857Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-console" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "pyzmq" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/2d/e2fd31e2fc41c14e2bcb6c976ab732597e907523f6b2420305f9fc7fdbdb/jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539", size = 34363, upload-time = "2023-03-06T14:13:31.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485", size = 24510, upload-time = "2023-03-06T14:13:28.229Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6", size = 55677, upload-time = "2026-04-02T08:10:06.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81", size = 77513, upload-time = "2026-04-02T08:10:01.753Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "(os_name == 'nt' and sys_platform != 'darwin') or (os_name == 'nt' and extra != 'extra-8-drevalpy-cpu') or (os_name != 'nt' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (os_name != 'nt' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (os_name != 'nt' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "(os_name == 'nt' and sys_platform != 'darwin') or (os_name == 'nt' and extra != 'extra-8-drevalpy-cpu') or (os_name != 'nt' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (os_name != 'nt' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (os_name != 'nt' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-builder" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/51c0c856ab286bdaf5709cf61ed13584ed9d4bee906479707da45b11b353/jupyterlab-4.6.2.tar.gz", hash = "sha256:e18ce8b34f3de350e93cd5b2c4f3ae884cbe266eb76bf5d6825a4ed34c13bcff", size = 28183650, upload-time = "2026-07-21T12:05:24.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/1f/e39b248c76bb3736bc05a9491a6aa1315414c32dea12f548ecc1b24c758e/jupyterlab-4.6.2-py3-none-any.whl", hash = "sha256:5964447036629adfcd3fc0969effc1da6f47d2cbd0a60b2c2eea7c31be0ec6a8", size = 17166703, upload-time = "2026-07-21T12:05:19.818Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kaleido" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f7/0ccaa596ec341963adbb4f839774c36d5659e75a0812d946732b927d480e/kaleido-0.2.1-py2.py3-none-macosx_10_11_x86_64.whl", hash = "sha256:ca6f73e7ff00aaebf2843f73f1d3bacde1930ef5041093fe76b83a15785049a7", size = 85153681, upload-time = "2021-03-08T10:27:34.202Z" }, + { url = "https://files.pythonhosted.org/packages/45/8e/4297556be5a07b713bb42dde0f748354de9a6918dee251c0e6bdcda341e7/kaleido-0.2.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb9a5d1f710357d5d432ee240ef6658a6d124c3e610935817b4b42da9c787c05", size = 85808197, upload-time = "2021-03-08T10:27:46.561Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b3/a0f0f4faac229b0011d8c4a7ee6da7c2dca0b6fd08039c95920846f23ca4/kaleido-0.2.1-py2.py3-none-manylinux1_x86_64.whl", hash = "sha256:aa21cf1bf1c78f8fa50a9f7d45e1003c387bd3d6fe0a767cfbbf344b95bdc3a8", size = 79902476, upload-time = "2021-03-08T10:27:57.364Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2b/680662678a57afab1685f0c431c2aba7783ce4344f06ec162074d485d469/kaleido-0.2.1-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:845819844c8082c9469d9c17e42621fbf85c2b237ef8a86ec8a8527f98b6512a", size = 83711746, upload-time = "2021-03-08T10:28:08.847Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/4b6f8bb3f9ab036fd4ad1cb2d628ab5c81db32ac9aa0641d7b180073ba43/kaleido-0.2.1-py2.py3-none-win32.whl", hash = "sha256:ecc72635860be616c6b7161807a65c0dbd9b90c6437ac96965831e2e24066552", size = 62312480, upload-time = "2021-03-08T10:28:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/f7/9a/0408b02a4bcb3cf8b338a2b074ac7d1b2099e2b092b42473def22f7b625f/kaleido-0.2.1-py2.py3-none-win_amd64.whl", hash = "sha256:4670985f28913c2d063c5734d125ecc28e40810141bdb0a46f15b76c1d45f23c", size = 65945521, upload-time = "2021-03-08T10:28:26.823Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "legacy-api-wrap" +version = "1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/49/f06f94048c8974205730d40beca879e43b6eee08efb0101cfb8623e60f41/legacy_api_wrap-1.5.tar.gz", hash = "sha256:b41ba6532f3ebfe3a897a35a7f97dec3be04b92a450f6c2bcf89f1b91c9cadf2", size = 11610, upload-time = "2025-11-03T13:21:12.437Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5b/058db09c45ba58a7321bdf2294cae651b37d6fec68117265af90cde043b0/legacy_api_wrap-1.5-py3-none-any.whl", hash = "sha256:5a8ea50e3e3bcbcdec3447b77034fd0d32cb2cf4089db799238708e4d7e0098d", size = 10182, upload-time = "2025-11-03T13:21:11.102Z" }, +] + +[[package]] +name = "lightgbm" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/8e/4db5e29290d7e619c307fdb8dab0a0514090af2ce3ec483050e024ec6126/lightgbm-4.7.0.tar.gz", hash = "sha256:f8e20f682c9aabd000bcf4a7ed8aa6f473c1adfecccae34ec24e823d156f4af0", size = 1792896, upload-time = "2026-07-18T21:00:56.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/05/7213965863cba1ed0150ad045bceed6276a1afaaaedbaeff4699ec4f0ccb/lightgbm-4.7.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:dfc1cfe8e760387be1e7ba7a214688be21fdff96e4ed9749188f83e1877c2477", size = 1877851, upload-time = "2026-07-18T21:00:35.225Z" }, + { url = "https://files.pythonhosted.org/packages/b2/86/f4fe714f2e0bf3941705a20d7f6849dc476276d71236e82ea6b0d6539b86/lightgbm-4.7.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:129535462686f274df179133643118c5c5c5667167fe6c3a28d955f0b3c8e868", size = 1498914, upload-time = "2026-07-18T21:00:36.549Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/b29580948b92e8c2f84dea70118ac702ff067dc52ec4ffb5d73c953536a5/lightgbm-4.7.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4529acec5c6fefe4768302a529707d0ead90f6a6f42df694b856212e09695b8", size = 3349492, upload-time = "2026-07-18T21:00:37.943Z" }, + { url = "https://files.pythonhosted.org/packages/15/eb/837ea3b40cc36e22eeebb9785c01e42b2c255d033eea1d2d9ee8e2540e55/lightgbm-4.7.0-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d23e922acd891e77212e4d0fbcee9ba973c96dee479491341d05ba595357ebb7", size = 3476028, upload-time = "2026-07-18T21:00:39.331Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0b/c5c17d862b12ce292f24cd85d40f2f8f8981668fbdbd43fdc2625eccbc79/lightgbm-4.7.0-py3-none-win_amd64.whl", hash = "sha256:f42d1e5b32b6f170e606d7c689c6165671da98d7bf37f1addec2623efc8740c9", size = 1360833, upload-time = "2026-07-18T21:00:40.865Z" }, +] + +[[package]] +name = "lightning-utilities" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "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", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/d0/791aa183dd88491555cf7d4be0b52b0bcf6c3c2a2c22c815a2e819bf53e2/matplotlib-3.11.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b7cf158e7add54a8d51ac9b5a84abd6d4e13ed4951b4f25f1c5139f41c2addb2", size = 9440302, upload-time = "2026-07-18T03:38:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/82bbdf683a301f4478384c8aaba6903631a2ca18294b2d7655c9a542bffb/matplotlib-3.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2ace7273b9a5061a3b420918a16fae1f2dc5dfee1abcc13aba71b5d94b1820c", size = 9268549, upload-time = "2026-07-18T03:38:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/9b4298911303f74e6d83e64a81d996c0616405ec95046fac7f17e4258b9e/matplotlib-3.11.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83", size = 10024922, upload-time = "2026-07-18T03:38:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/84/6f/0bc3c3d05b021db44c14bc379a7c0df7d57302aa15380c16fd4e63fd6a9b/matplotlib-3.11.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f4bdeea33a8d15a071dbfe6d119451b1d719c733ac666d65357082901a9099", size = 10832170, upload-time = "2026-07-18T03:38:10.276Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e375f39acdb2af5a9342730618608e39790ec842e6f1b392863028781459/matplotlib-3.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b4c78ceb2f11bcac7389d305cda17aeb1f4586a857854ab5780bd3dd8dbfc407", size = 10916701, upload-time = "2026-07-18T03:38:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/bc/be/fa26ed085b41298f64a8f9b7592c671bbf1acc8b0df124c1c5de96b859f8/matplotlib-3.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f33a781e12b1e53b278deb2f5373c2e55ec4f10727be3440c0cfb5cda9f944f", size = 9315331, upload-time = "2026-07-18T03:38:14.949Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eb5bdf3b6e191b200db298b08bbc1638b7f3c82cdc8680f9d88bf72559ae/matplotlib-3.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:67e4c3cd578c65ebd81bdc09a1b6592ceafee6dfafe116dc85dfcb647b5bbb18", size = 9003475, upload-time = "2026-07-18T03:38:17.205Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/4798363b7fb5644e309fe1fac30216e9146c9f70859d80d588c18caf5317/matplotlib-3.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6771b0cd7838c6a857a7209814158c0ad09bfef878db3033dd82d70ad101f191", size = 9454341, upload-time = "2026-07-18T03:38:35.001Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/6acadbe7f98df19d274bc107ac58bb439fa75df82c33dc110d71a4a8501f/matplotlib-3.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2abdee5ffa2fe11b2d19f7a5c63b785fb7c28cc46c7bc1814156341d9d1a33e1", size = 9283627, upload-time = "2026-07-18T03:38:37.061Z" }, + { url = "https://files.pythonhosted.org/packages/24/ea/65cec46fe241390ccea1b1754207ee28eb71c5ab866bd5f22fe47e538fa4/matplotlib-3.11.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0a19dcf73406d3746d25a5ed42d713604c9a3e024d129b102852b0d941cb9f3", size = 10035860, upload-time = "2026-07-18T03:38:39.663Z" }, + { url = "https://files.pythonhosted.org/packages/c7/10/63fdccccbabe002fb0960876baabc5e3f24d9c1bb4cfb25651457f74b3a0/matplotlib-3.11.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7389b77ed2ab0552f46d9a90b81b7b8e6dfcdc42adc36c37a0865799843e0e3e", size = 10843594, upload-time = "2026-07-18T03:38:42.144Z" }, + { url = "https://files.pythonhosted.org/packages/98/51/a1155945bff7b91381875022ac1522c5dfdac0d006be8e7df389b3134eae/matplotlib-3.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c90be0b73568da4f662afac580956a76e308437e641b4a45aa08925eeb67d95f", size = 10927962, upload-time = "2026-07-18T03:38:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3a/3d5e1f42dc761bf53401a62a83ff93389b37de9d2c093b2a3aa49ac34f1b/matplotlib-3.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:68408341f2312836fbbdf6b3c78047f65b2d8752f5fd221c3e72d348f5b34f8b", size = 9334074, upload-time = "2026-07-18T03:38:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/3f5ea5a5b64060ef5e1ff60a19170423e41ce21b8497a6fe15a36e0b43e3/matplotlib-3.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c1f44890d435c1b4ef52f701ad5828cb450ea97bcc83918fda6be74965d6cd2", size = 9007662, upload-time = "2026-07-18T03:38:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/c7ae5e0531425b69c0826b00ebbc264c85cab853f1cd6e096c9983c2cdc1/matplotlib-3.11.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5e510088c27a89d53580a752f959146893563e63c330e161d159b0fee652af6f", size = 9503790, upload-time = "2026-07-18T03:38:51.527Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/15be162e0a2ed546939674e2e97d0e33ec2447d86d4d4e611fa295bb178c/matplotlib-3.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1524e2bdd48a93557aa47ddcfe9c225dfdd57d5a01a5c49128c20f0632980ee1", size = 9336148, upload-time = "2026-07-18T03:38:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7f/36ffe144fc4aacfe0e3ed2318f72b6755d1e73b041d619b4d393e60f5a66/matplotlib-3.11.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:11664c551345553db92e61cae6cf1376f138f8c47cafdf13b64b18f3e3e9e464", size = 10049244, upload-time = "2026-07-18T03:38:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/55812d68c0a840d3a463638f48c00ab1fe338518ec49a640cb6473b444af/matplotlib-3.11.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e1f8922ba31959cf6a9dfb51be64b7f7bc582801a3957dc0c2f3afcd3537adf", size = 10860798, upload-time = "2026-07-18T03:38:58.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/64/cca444b4eb5e6c768c44fc5e1f0b5211f20ca2b282778051996e996a2bdf/matplotlib-3.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83235693abde86e5e0129998f80ee39fc7f58e6d56a88fafb28a9278833e9d5f", size = 10943282, upload-time = "2026-07-18T03:39:00.465Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0f/a49c329d394f2e9ef38506982107e8b04ecf94dd41a9d8423ff82cc737c7/matplotlib-3.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:9a076f4fc5cdc43fdf510f5981418d25c2db4973418d9f22d8bb3dc8045ada78", size = 9383532, upload-time = "2026-07-18T03:39:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/e4/50/103e86afb806d8f64d04ede14e4cfc09dbfc25f512421ff85fdd6ebd59cf/matplotlib-3.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:216fbb93a74add02ddb4cb38ef5348f59ac00b3e84567eaf16598772d40e150a", size = 9059665, upload-time = "2026-07-18T03:39:04.607Z" }, + { url = "https://files.pythonhosted.org/packages/ee/38/ceb1d637c4db6d06141f3739e93af3321e7caaabe69b57ae48ffe3ee95b1/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:427258425f9a3fc4ed79a91f9e9b9aaf5a82cb6571e85dc14063cc6fbb993741", size = 9438045, upload-time = "2026-07-18T03:39:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/72ad8b58602d3a6ef1dfc4b65ecd01634ab65a2bdf494c9fe0e966dbf081/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1ac697e591c11b6ad04679a73c2d2f9980fe9d9f0311fb414a2e329706343dfb", size = 9266127, upload-time = "2026-07-18T03:39:41.597Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/69552382fcc8e93d1f2763ef2665980a900a48b7f3a4c57ed290726d1cbc/matplotlib-3.11.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99", size = 10019439, upload-time = "2026-07-18T03:39:43.78Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mistune" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" }, +] + +[[package]] +name = "mock" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/8c/14c2ae915e5f9dca5a22edd68b35be94400719ccfa068a03e0fb63d0f6f6/mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0", size = 92796, upload-time = "2025-03-03T12:31:42.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/d9/617e6af809bf3a1d468e0d58c3997b1dc219a9a9202e650d30c2fc85d481/mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f", size = 31617, upload-time = "2025-03-03T12:31:41.518Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "mudata" +version = "0.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anndata", version = "0.12.19", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "anndata", version = "0.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "h5py" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scverse-misc", version = "0.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scverse-misc", version = "0.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "session-info2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/73/7a5dd213f74d3df0e433966c09b76d79991e1f2596743641a107390a6d31/mudata-0.3.10.tar.gz", hash = "sha256:2765fc4b732ba61c849c3d0af4046a2069c05296c8ac70f7f235385a080be248", size = 328799, upload-time = "2026-07-07T14:26:02.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/15/bdda076de129fce9fa06993d3f1a6b7a68fcd3d48d32e970c6f18be6e88c/mudata-0.3.10-py3-none-any.whl", hash = "sha256:28f9ed72346b6157c6de5bc1b1b3d735fc64a8d1dd6f778fb1d126e6ba790b65", size = 43984, upload-time = "2026-07-07T14:26:01.294Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "multiqc" +version = "1.35" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "click" }, + { name = "coloredlogs" }, + { name = "humanize" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "kaleido" }, + { name = "markdown" }, + { name = "natsort" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "plotly" }, + { name = "polars" }, + { name = "polars", extra = ["rtcompat"], marker = "sys_platform != 'emscripten' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "spectra" }, + { name = "tiktoken" }, + { name = "tqdm" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/a2/90e1b19ee65ec2619a4ff1767deefe32ba16c87f1363864778bd97ee5800/multiqc-1.35.tar.gz", hash = "sha256:5a4aa6480e6def2f9c0af2893358bf7ec5c304d606ecf613cd25ddcd0e244e77", size = 5451760, upload-time = "2026-05-13T01:01:55.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/85/ea19a97a83eddf81ab645d44ea219211e65961c60a93c1a62e8751efe2ab/multiqc-1.35-py3-none-any.whl", hash = "sha256:818ad8aa75572f13be55b66c0bafd7877c1e119a0903e004855ec04385a94dc6", size = 5779740, upload-time = "2026-05-13T01:01:52.411Z" }, +] + +[[package]] +name = "narwhals" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, +] + +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, +] + +[[package]] +name = "nbclient" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "notebook" +version = "7.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-builder" }, + { name = "jupyter-server" }, + { name = "jupyterlab" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/44/d5c65783f490298473bb1c05722e05ee2256231389559c2c5ae0a3e5d975/notebook-7.6.0.tar.gz", hash = "sha256:ea13e79e601bf273074895fdfb17dd3f2da916d3c045e0b9c47d18b16ab62481", size = 5497344, upload-time = "2026-06-18T16:18:55.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d1/e617c40db57ff40e75f43a7d4d1c305e3a54c053ab5cb0534a6c314664f9/notebook-7.6.0-py3-none-any.whl", hash = "sha256:98aa2811b54ac191321d5dfce12ca700f8a511a33a26e4de2fa106a357c43d6a", size = 5544575, upload-time = "2026-06-18T16:18:52.551Z" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + +[[package]] +name = "numcodecs" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/8a391e7c356366224734efd24da929cc4796fff468bfb179fe1af6548535/numcodecs-0.16.5.tar.gz", hash = "sha256:0d0fb60852f84c0bd9543cc4d2ab9eefd37fc8efcc410acd4777e62a1d300318", size = 6276387, upload-time = "2025-11-21T02:49:48.986Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/85/1ac101a40ead81eaa1c7dc49a8827a30e2e436211b43ebdc63c590eb1347/numcodecs-0.16.5-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:78382dcea50622f2ef1e6e7a71dbe7f861d8fe376b27b7c297c26907304fef1e", size = 1621795, upload-time = "2025-11-21T02:49:17.418Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cc/0d97ef55dda48cb0f93d7b92d761208e7a99bd2eea6b0e859426e6a99a21/numcodecs-0.16.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2d04a19cb57a3c519b4127ac377cca6471aee1990d7c18f5b1e3a4fe1306689", size = 1153030, upload-time = "2025-11-21T02:49:19.089Z" }, + { url = "https://files.pythonhosted.org/packages/5e/41/e120ee1b390730ac5987cde2afd82e2b8442cec315ab40b94b0373e93e73/numcodecs-0.16.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c043af648eb280cd61785c99c22ff5c3c3460f906eb51a8511327c4f5111b283", size = 8510503, upload-time = "2025-11-21T02:49:20.324Z" }, + { url = "https://files.pythonhosted.org/packages/54/4b/195ac84cc8f6077b4f0f421e8daee21b7f1bd88cb7716414234379fe68ec/numcodecs-0.16.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c398919ef2eb0e56b8e97456f622640bfd3deed06de3acc976989cbcb22628a3", size = 9123428, upload-time = "2025-11-21T02:49:22.328Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5b/af02c417954f46e5c7bd5163ac251f535877d909fce54861c99ae197f6f6/numcodecs-0.16.5-cp311-cp311-win_amd64.whl", hash = "sha256:3820860ed302d4d84a1c66e70981ff959d5eb712555be4e7d8ced49888594773", size = 801542, upload-time = "2025-11-21T02:49:24.265Z" }, + { url = "https://files.pythonhosted.org/packages/75/cc/55420f3641a67f78392dc0bc5d02cb9eb0a9dcebf2848d1ac77253ca61fa/numcodecs-0.16.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:24e675dc8d1550cd976a99479b87d872cb142632c75cc402fea04c08c4898523", size = 1656287, upload-time = "2025-11-21T02:49:25.755Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6c/86644987505dcb90ba6d627d6989c27bafb0699f9fd00187e06d05ea8594/numcodecs-0.16.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:94ddfa4341d1a3ab99989d13b01b5134abb687d3dab2ead54b450aefe4ad5bd6", size = 1148899, upload-time = "2025-11-21T02:49:26.87Z" }, + { url = "https://files.pythonhosted.org/packages/97/1e/98aaddf272552d9fef1f0296a9939d1487914a239e98678f6b20f8b0a5c8/numcodecs-0.16.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b554ab9ecf69de7ca2b6b5e8bc696bd9747559cb4dd5127bd08d7a28bec59c3a", size = 8534814, upload-time = "2025-11-21T02:49:28.547Z" }, + { url = "https://files.pythonhosted.org/packages/fb/53/78c98ef5c8b2b784453487f3e4d6c017b20747c58b470393e230c78d18e8/numcodecs-0.16.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad1a379a45bd3491deab8ae6548313946744f868c21d5340116977ea3be5b1d6", size = 9173471, upload-time = "2025-11-21T02:49:30.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/20/2fdec87fc7f8cec950d2b0bea603c12dc9f05b4966dc5924ba5a36a61bf6/numcodecs-0.16.5-cp312-cp312-win_amd64.whl", hash = "sha256:845a9857886ffe4a3172ba1c537ae5bcc01e65068c31cf1fce1a844bd1da050f", size = 801412, upload-time = "2025-11-21T02:49:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/38/38/071ced5a5fd1c85ba0e14ba721b66b053823e5176298c2f707e50bed11d9/numcodecs-0.16.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25be3a516ab677dad890760d357cfe081a371d9c0a2e9a204562318ac5969de3", size = 1654359, upload-time = "2025-11-21T02:49:33.673Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/5f84ba7525577c1b9909fc2d06ef11314825fc4ad4378f61d0e4c9883b4a/numcodecs-0.16.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0107e839ef75b854e969cb577e140b1aadb9847893937636582d23a2a4c6ce50", size = 1144237, upload-time = "2025-11-21T02:49:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/0b/00/787ea5f237b8ea7bc67140c99155f9c00b5baf11c49afc5f3bfefa298f95/numcodecs-0.16.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:015a7c859ecc2a06e2a548f64008c0ec3aaecabc26456c2c62f4278d8fc20597", size = 8483064, upload-time = "2025-11-21T02:49:36.454Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e6/d359fdd37498e74d26a167f7a51e54542e642ea47181eb4e643a69a066c3/numcodecs-0.16.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84230b4b9dad2392f2a84242bd6e3e659ac137b5a1ce3571d6965fca673e0903", size = 9126063, upload-time = "2025-11-21T02:49:38.018Z" }, + { url = "https://files.pythonhosted.org/packages/27/72/6663cc0382ddbb866136c255c837bcb96cc7ce5e83562efec55e1b995941/numcodecs-0.16.5-cp313-cp313-win_amd64.whl", hash = "sha256:5088145502ad1ebf677ec47d00eb6f0fd600658217db3e0c070c321c85d6cf3d", size = 799275, upload-time = "2025-11-21T02:49:39.558Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc", marker = "(sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/45/9e/2f562daf80eb8f7a685fb7bea4fda71f6048e4f359d6fdd1b6e70206cb2f/nvidia_cublas-13.1.1.3-py3-none-win_amd64.whl", hash = "sha256:b6cdce694e47ff6aadf0a69df1cab6628d696f5ff56e8d16af50309d855fa20f", size = 404358158, upload-time = "2026-04-08T18:47:26.987Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.6.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/eb/ff4b8c503fa1f1796679dce648854d58751982426e4e4b37d6fce49d259c/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08ed2686e9875d01b58e3cb379c6896df8e76c75e0d4a7f7dace3d7b6d9ef8eb", size = 393138322, upload-time = "2024-11-20T17:40:25.65Z" }, + { url = "https://files.pythonhosted.org/packages/97/0d/f1f0cadbf69d5b9ef2e4f744c9466cb0a850741d08350736dfdb4aa89569/nvidia_cublas_cu12-12.6.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:235f728d6e2a409eddf1df58d5b0921cf80cfa9e72b9f2775ccb7b4a87984668", size = 390794615, upload-time = "2024-11-20T17:39:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/84/f7/985e9bdbe3e0ac9298fcc8cfa51a392862a46a0ffaccbbd56939b62a9c83/nvidia_cublas_cu12-12.6.4.1-py3-none-win_amd64.whl", hash = "sha256:9e4fa264f4d8a4eb0cdbd34beadc029f453b3bafae02401e999cf3d5a5af75f8", size = 434535301, upload-time = "2024-11-20T17:50:41.681Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/ad/df/b74b10025c1205695c5676373f2edd3e87a7202cc62ead0dfbc373b0f6ea/nvidia_cuda_cupti-13.0.85-py3-none-win_amd64.whl", hash = "sha256:683f58d301548deeefcb8f6fac1b8d907691b9d8b18eccab417f51e362102f00", size = 7736776, upload-time = "2025-09-04T08:38:08.38Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.6.80" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/8b/2f6230cb715646c3a9425636e513227ce5c93c4d65823a734f4bb86d43c3/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:166ee35a3ff1587f2490364f90eeeb8da06cd867bd5b701bf7f9a02b78bc63fc", size = 8236764, upload-time = "2024-11-20T17:35:41.03Z" }, + { url = "https://files.pythonhosted.org/packages/25/0f/acb326ac8fd26e13c799e0b4f3b2751543e1834f04d62e729485872198d4/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_aarch64.whl", hash = "sha256:358b4a1d35370353d52e12f0a7d1769fc01ff74a191689d3870b2123156184c4", size = 8236756, upload-time = "2024-10-01T16:57:45.507Z" }, + { url = "https://files.pythonhosted.org/packages/49/60/7b6497946d74bcf1de852a21824d63baad12cd417db4195fc1bfe59db953/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6768bad6cab4f19e8292125e5f1ac8aa7d1718704012a0e3272a6f61c4bce132", size = 8917980, upload-time = "2024-11-20T17:36:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/120ee57b218d9952c379d1e026c4479c9ece9997a4fb46303611ee48f038/nvidia_cuda_cupti_cu12-12.6.80-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a3eff6cdfcc6a4c35db968a06fcadb061cbc7d6dde548609a941ff8701b98b73", size = 8917972, upload-time = "2024-10-01T16:58:06.036Z" }, + { url = "https://files.pythonhosted.org/packages/1c/81/7796f096afaf726796b1b648f3bc80cafc61fe7f77f44a483c89e6c5ef34/nvidia_cuda_cupti_cu12-12.6.80-py3-none-win_amd64.whl", hash = "sha256:bbe6ae76e83ce5251b56e8c8e61a964f757175682bbad058b170b136266ab00a", size = 5724175, upload-time = "2024-10-01T17:09:47.955Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/4a/af/345fedb9f4c76c84ab4fa445b36bd4048a4d9db60e6bc76b4f913ff4b852/nvidia_cuda_nvrtc-13.0.88-py3-none-win_amd64.whl", hash = "sha256:6bcd4e7f8e205cbe644f5a98f2f799bef9556fefc89dd786e79a16312ce49872", size = 76807835, upload-time = "2025-09-04T08:39:15.274Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.6.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/31/ffb400c5ae99daf09687aa6c42831c5d824f71c4851363ed2a4a1ac52bab/nvidia_cuda_nvrtc_cu12-12.6.85-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:800927308ccc5dd6246d3f61f7fcef2ed7ec4e59e199090d360d3293f78bd5a2", size = 23649944, upload-time = "2024-11-20T17:38:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/48/ab/476146f59ff5ef5bd6e62c187097d859ea78b5752d19c6c3f9be5f90dafc/nvidia_cuda_nvrtc_cu12-12.6.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f3134f50963882373063901657554f230bedf6039d30b09f6be55c64c993a37", size = 23162872, upload-time = "2024-11-20T17:37:42.967Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/472414aee887d626373d0b2140a59ac4308e3eaed815060e5410fc83305a/nvidia_cuda_nvrtc_cu12-12.6.85-py3-none-win_amd64.whl", hash = "sha256:a419e2c95e75b88b602f8bb66f82a6c5651e8475a509841c958486b1b71510bf", size = 39026436, upload-time = "2024-11-20T17:49:13.633Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/b7/94/6b867483bec07da24ffa32736c79fabb94ef3a7af4d787a9d4a974868576/nvidia_cuda_runtime-13.0.96-py3-none-win_amd64.whl", hash = "sha256:f79298c8a098cec150a597c8eba58ecdab96e3bdc4b9bc4f9983635031740492", size = 2927037, upload-time = "2025-10-09T09:04:23.782Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.6.77" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/590b2ac00d772a8abd1c387a92b46486d2679ca6622fd25c18ff76265663/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6116fad3e049e04791c0256a9778c16237837c08b27ed8c8401e2e45de8d60cd", size = 908052, upload-time = "2024-11-20T17:35:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/b7/3d/159023799677126e20c8fd580cca09eeb28d5c5a624adc7f793b9aa8bbfa/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d461264ecb429c84c8879a7153499ddc7b19b5f8d84c204307491989a365588e", size = 908040, upload-time = "2024-10-01T16:57:22.221Z" }, + { url = "https://files.pythonhosted.org/packages/e1/23/e717c5ac26d26cf39a27fbc076240fad2e3b817e5889d671b67f4f9f49c5/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba3b56a4f896141e25e19ab287cd71e52a6a0f4b29d0d31609f60e3b4d5219b7", size = 897690, upload-time = "2024-11-20T17:35:30.697Z" }, + { url = "https://files.pythonhosted.org/packages/f0/62/65c05e161eeddbafeca24dc461f47de550d9fa8a7e04eb213e32b55cfd99/nvidia_cuda_runtime_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a84d15d5e1da416dd4774cb42edf5e954a3e60cc945698dc1d5be02321c44dc8", size = 897678, upload-time = "2024-10-01T16:57:33.821Z" }, + { url = "https://files.pythonhosted.org/packages/fa/76/4c80fa138333cc975743fd0687a745fccb30d167f906f13c1c7f9a85e5ea/nvidia_cuda_runtime_cu12-12.6.77-py3-none-win_amd64.whl", hash = "sha256:86c58044c824bf3c173c49a2dbc7a6c8b53cb4e4dca50068be0bf64e9dab3f7f", size = 891773, upload-time = "2024-10-01T17:09:26.362Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.10.2.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/41/e79269ce215c857c935fd86bcfe91a451a584dfc27f1e068f568b9ad1ab7/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c9132cc3f8958447b4910a1720036d9eff5928cc3179b0a51fb6d167c6cc87d8", size = 705026878, upload-time = "2025-06-06T21:52:51.348Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, + { url = "https://files.pythonhosted.org/packages/3d/90/0bd6e586701b3a890fd38aa71c387dab4883d619d6e5ad912ccbd05bfd67/nvidia_cudnn_cu12-9.10.2.21-py3-none-win_amd64.whl", hash = "sha256:c6288de7d63e6cf62988f0923f96dc339cea362decb1bf5b3141883392a7d65e", size = 692992268, upload-time = "2025-06-06T21:55:18.114Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.20.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "(sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/78/39/21507455b1bca8b5702a9e9fc6ce73735f216f558dac2c9ede58e4d456b8/nvidia_cudnn_cu13-9.20.0.48-py3-none-win_amd64.whl", hash = "sha256:af8139732b99c0118be65ea5aac97f0d46018f8c552889e49d2fb0c6261a4a24", size = 350712614, upload-time = "2026-03-09T19:31:11.398Z" }, +] + +[[package]] +name = "nvidia-cufft" +version = "12.0.0.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "(sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/f8af21a2ed1beed337a6a02c5a28aeb85441f4d578ec3d529543c775ea4b/nvidia_cufft-12.0.0.61-py3-none-win_amd64.whl", hash = "sha256:2abce5b39d2f5ae12730fb7e5db6696533e36c26e2d3e8fd1750bdd2853364eb", size = 213342123, upload-time = "2025-09-04T08:40:51.145Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/37/c50d2b2f2c07e146776389e3080f4faf70bcc4fa6e19d65bb54ca174ebc3/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d16079550df460376455cba121db6564089176d9bac9e4f360493ca4741b22a6", size = 200164144, upload-time = "2024-11-20T17:40:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f5/188566814b7339e893f8d210d3a5332352b1409815908dad6a363dcceac1/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8510990de9f96c803a051822618d42bf6cb8f069ff3f48d93a8486efdacb48fb", size = 200164135, upload-time = "2024-10-01T17:03:24.212Z" }, + { url = "https://files.pythonhosted.org/packages/8f/16/73727675941ab8e6ffd86ca3a4b7b47065edcca7a997920b831f8147c99d/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ccba62eb9cef5559abd5e0d54ceed2d9934030f51163df018532142a8ec533e5", size = 200221632, upload-time = "2024-11-20T17:41:32.357Z" }, + { url = "https://files.pythonhosted.org/packages/60/de/99ec247a07ea40c969d904fc14f3a356b3e2a704121675b75c366b694ee1/nvidia_cufft_cu12-11.3.0.4-py3-none-manylinux2014_x86_64.whl", hash = "sha256:768160ac89f6f7b459bee747e8d175dbf53619cfe74b2a5636264163138013ca", size = 200221622, upload-time = "2024-10-01T17:03:58.79Z" }, + { url = "https://files.pythonhosted.org/packages/b4/38/36fd800cec8f6e89b7c1576edaaf8076e69ec631644cdbc1b5f2e2b5a9df/nvidia_cufft_cu12-11.3.0.4-py3-none-win_amd64.whl", hash = "sha256:6048ebddfb90d09d2707efb1fd78d4e3a77cb3ae4dc60e19aab6be0ece2ae464", size = 199356881, upload-time = "2024-10-01T17:13:01.861Z" }, +] + +[[package]] +name = "nvidia-cufile" +version = "1.15.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.11.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/66/cc9876340ac68ae71b15c743ddb13f8b30d5244af344ec8322b449e35426/nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc23469d1c7e52ce6c1d55253273d32c565dd22068647f3aa59b3c6b005bf159", size = 1142103, upload-time = "2024-11-20T17:42:11.83Z" }, + { url = "https://files.pythonhosted.org/packages/17/bf/cc834147263b929229ce4aadd62869f0b195e98569d4c28b23edc72b85d9/nvidia_cufile_cu12-1.11.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:8f57a0051dcf2543f6dc2b98a98cb2719c37d3cee1baba8965d57f3bbc90d4db", size = 1066155, upload-time = "2024-11-20T17:41:49.376Z" }, +] + +[[package]] +name = "nvidia-curand" +version = "10.4.0.35" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, + { url = "https://files.pythonhosted.org/packages/99/27/72103153b1ffc00e09fdc40ac970235343dcd1ea8bd762e84d2d73219ffa/nvidia_curand-10.4.0.35-py3-none-win_amd64.whl", hash = "sha256:65b1710aa6961d326b411e314b374290904c5ddf41dc3f766ebc3f1d7d4ca69f", size = 55242481, upload-time = "2025-08-04T10:30:41.831Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.7.77" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/ac/36543605358a355632f1a6faa3e2d5dfb91eab1e4bc7d552040e0383c335/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:6e82df077060ea28e37f48a3ec442a8f47690c7499bff392a5938614b56c98d8", size = 56289881, upload-time = "2024-10-01T17:04:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/73/1b/44a01c4e70933637c93e6e1a8063d1e998b50213a6b65ac5a9169c47e98e/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a42cd1344297f70b9e39a1e4f467a4e1c10f1da54ff7a85c12197f6c652c8bdf", size = 56279010, upload-time = "2024-11-20T17:42:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/4a/aa/2c7ff0b5ee02eaef890c0ce7d4f74bc30901871c5e45dee1ae6d0083cd80/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:99f1a32f1ac2bd134897fc7a203f779303261268a65762a623bf30cc9fe79117", size = 56279000, upload-time = "2024-10-01T17:04:45.274Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/5362a9396f23f7de1dd8a64369e87c85ffff8216fc8194ace0fa45ba27a5/nvidia_curand_cu12-10.3.7.77-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:7b2ed8e95595c3591d984ea3603dd66fe6ce6812b886d59049988a712ed06b6e", size = 56289882, upload-time = "2024-11-20T17:42:25.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/0cd0cec757bd4b4b4ef150fca62ec064db7d08a291dced835a0be7d2c147/nvidia_curand_cu12-10.3.7.77-py3-none-win_amd64.whl", hash = "sha256:6d6d935ffba0f3d439b7cd968192ff068fafd9018dbf1b85b37261b13cfc9905", size = 55783873, upload-time = "2024-10-01T17:13:30.377Z" }, +] + +[[package]] +name = "nvidia-cusolver" +version = "12.0.4.66" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas", marker = "(sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cusparse", marker = "(sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nvjitlink", marker = "(sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, + { url = "https://files.pythonhosted.org/packages/99/ef/332a0101260ca78a1daef046bf0b06199e8ed4dac1d2aa698289c358169c/nvidia_cusolver-12.0.4.66-py3-none-win_amd64.whl", hash = "sha256:16515bd33a8e76bb54d024cfa068fa68d30e80fc34b9e1090813ea9362e0cb65", size = 193551444, upload-time = "2025-09-04T08:41:46.813Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cusparse-cu12", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nvjitlink-cu12", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/17/dbe1aa865e4fdc7b6d4d0dd308fdd5aaab60f939abfc0ea1954eac4fb113/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0ce237ef60acde1efc457335a2ddadfd7610b892d94efee7b776c64bb1cac9e0", size = 157833628, upload-time = "2024-10-01T17:05:05.591Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6e/c2cf12c9ff8b872e92b4a5740701e51ff17689c4d726fca91875b07f655d/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9e49843a7707e42022babb9bcfa33c29857a93b88020c4e4434656a655b698c", size = 158229790, upload-time = "2024-11-20T17:43:43.211Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/baba53585da791d043c10084cf9553e074548408e04ae884cfe9193bd484/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6cf28f17f64107a0c4d7802be5ff5537b2130bfc112f25d5a30df227058ca0e6", size = 158229780, upload-time = "2024-10-01T17:05:39.875Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5f/07d0ba3b7f19be5a5ec32a8679fc9384cfd9fc6c869825e93be9f28d6690/nvidia_cusolver_cu12-11.7.1.2-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dbbe4fc38ec1289c7e5230e16248365e375c3673c9c8bac5796e2e20db07f56e", size = 157833630, upload-time = "2024-11-20T17:43:16.77Z" }, + { url = "https://files.pythonhosted.org/packages/d4/53/fff50a0808df7113d77e3bbc7c2b7eaed6f57d5eb80fbe93ead2aea1e09a/nvidia_cusolver_cu12-11.7.1.2-py3-none-win_amd64.whl", hash = "sha256:6813f9d8073f555444a8705f3ab0296d3e1cb37a16d694c5fc8b862a0d8706d7", size = 149287877, upload-time = "2024-10-01T17:13:49.804Z" }, +] + +[[package]] +name = "nvidia-cusparse" +version = "12.6.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink", marker = "(sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, + { url = "https://files.pythonhosted.org/packages/02/b0/b043d6f3480f102f885cf87fc3ffd3edcb5e23b855025a50e2ef4d059185/nvidia_cusparse-12.6.3.3-py3-none-win_amd64.whl", hash = "sha256:cbcf42feb737bd7ec15b4c0a63e62351886bd3f975027b8815d7f720a2b5ea79", size = 143783033, upload-time = "2025-09-04T08:42:12.391Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "(sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/eb/6681efd0aa7df96b4f8067b3ce7246833dd36830bb4cec8896182773db7d/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d25b62fb18751758fe3c93a4a08eff08effedfe4edf1c6bb5afd0890fe88f887", size = 216451147, upload-time = "2024-11-20T17:44:18.055Z" }, + { url = "https://files.pythonhosted.org/packages/d3/56/3af21e43014eb40134dea004e8d0f1ef19d9596a39e4d497d5a7de01669f/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7aa32fa5470cf754f72d1116c7cbc300b4e638d3ae5304cfa4a638a5b87161b1", size = 216451135, upload-time = "2024-10-01T17:06:03.826Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/b8b7c2f4099a37b96af5c9bb158632ea9e5d9d27d7391d7eb8fc45236674/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7556d9eca156e18184b94947ade0fba5bb47d69cec46bf8660fd2c71a4b48b73", size = 216561367, upload-time = "2024-11-20T17:44:54.824Z" }, + { url = "https://files.pythonhosted.org/packages/43/ac/64c4316ba163e8217a99680c7605f779accffc6a4bcd0c778c12948d3707/nvidia_cusparse_cu12-12.5.4.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:23749a6571191a215cb74d1cdbff4a86e7b19f1200c071b3fcf844a5bea23a2f", size = 216561357, upload-time = "2024-10-01T17:06:29.861Z" }, + { url = "https://files.pythonhosted.org/packages/45/ef/876ad8e4260e1128e6d4aac803d9d51baf3791ebdb4a9b8d9b8db032b4b0/nvidia_cusparse_cu12-12.5.4.2-py3-none-win_amd64.whl", hash = "sha256:4acb8c08855a26d737398cba8fb6f8f5045d93f82612b4cfd84645a2332ccf20", size = 213712630, upload-time = "2024-10-01T17:14:23.779Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b9/598f6ff36faaece4b3c50d26f50e38661499ff34346f00e057760b35cc9d/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8878dce784d0fac90131b6817b607e803c36e629ba34dc5b433471382196b6a5", size = 283835557, upload-time = "2025-02-26T00:16:54.265Z" }, + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d8/a6b0d0d0c2435e9310f3e2bb0d9c9dd4c33daef86aa5f30b3681defd37ea/nvidia_cusparselt_cu12-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f67fbb5831940ec829c9117b7f33807db9f9678dc2a617fbe781cac17b4e1075", size = 271020911, upload-time = "2025-02-26T00:14:47.204Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/31/83/f3647ce26916c94a6ca4ff1810623e2c405cff2dea6e78d29516b2514df9/nvidia_cusparselt_cu13-0.8.1-py3-none-win_amd64.whl", hash = "sha256:dccbd362f91a7b9024d1f55ee9f548ac065027ff15d8c8b0db889ab3a8f31215", size = 156885108, upload-time = "2025-09-05T18:51:35.958Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.30.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8c/554bb020501d6c04ad8127d83f728137f8f9123f991666efbdcf9095a221/nvidia_nccl_cu12-2.30.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:03ecd776fd1d58fd2c9a0a687dcf8db9ecd0057382dba646fa3d65786d4a9ea1", size = 303277471, upload-time = "2026-06-09T03:24:16.327Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/e7ffa9c324ae260e5dbb4af2cd557bf7a8d155c8ac7b79a785fe1796fb92/nvidia_nccl_cu12-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:8ce1b8213f61f2bfac132e6df890af6450b77cbd140c6ce4e98cb0c2d8e678c9", size = 303361239, upload-time = "2026-06-09T03:24:53.816Z" }, +] + +[[package]] +name = "nvidia-nccl-cu13" +version = "2.29.7" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, + { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, +] + +[[package]] +name = "nvidia-nvjitlink" +version = "13.3.33" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/ee/580ca6f29dcab0221db8706badca1bbbb084f1975c4d4e83329c3a7e31f0/nvidia_nvjitlink-13.3.33-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5", size = 40742423, upload-time = "2026-05-26T16:54:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/69/30/45414e35ff2eee7db3da037e5707037ccf9d2b5218ffbdb055ea4d5aa98a/nvidia_nvjitlink-13.3.33-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e", size = 39168635, upload-time = "2026-05-26T16:54:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/ec9c05a108095828dfc58840978c627b3c313fdf2a567c6de9ffbbb46901/nvidia_nvjitlink-13.3.33-py3-none-win_amd64.whl", hash = "sha256:4297ee49639b4f2e07255a1d69b3acc7ab2d011bb892b403e91ac98368962e3b", size = 37766359, upload-time = "2026-05-26T17:11:28.96Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.6.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/d7/c5383e47c7e9bf1c99d5bd2a8c935af2b6d705ad831a7ec5c97db4d82f4f/nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:eedc36df9e88b682efe4309aa16b5b4e78c2407eac59e8c10a6a47535164369a", size = 19744971, upload-time = "2024-11-20T17:46:53.366Z" }, + { url = "https://files.pythonhosted.org/packages/31/db/dc71113d441f208cdfe7ae10d4983884e13f464a6252450693365e166dcf/nvidia_nvjitlink_cu12-12.6.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cf4eaa7d4b6b543ffd69d6abfb11efdeb2db48270d94dfd3a452c24150829e41", size = 19270338, upload-time = "2024-11-20T17:46:29.758Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/93c1467b1387387440a4d25102d86b7794535449b689f8e2dc22c1c8ff7f/nvidia_nvjitlink_cu12-12.6.85-py3-none-win_amd64.whl", hash = "sha256:e61120e52ed675747825cdd16febc6a0730537451d867ee58bee3853b1b13d1c", size = 161908572, upload-time = "2024-11-20T17:52:40.124Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu12" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6a/03aa43cc9bd3ad91553a88b5f6fb25ed6a3752ae86ce2180221962bc2aa5/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b48363fc6964dede448029434c6abed6c5e37f823cb43c3bcde7ecfc0457e15", size = 138936938, upload-time = "2025-09-06T00:32:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu13" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, +] + +[[package]] +name = "nvidia-nvtx" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, + { url = "https://files.pythonhosted.org/packages/d2/50/0e2220f8620a177de994211186ffc5bfa9f2ce1e1282797f8f90096f9f88/nvidia_nvtx-13.0.85-py3-none-win_amd64.whl", hash = "sha256:d66ea44254dd3c6eacc300047af6e1288d2269dd072b417e0adffbf479e18519", size = 137066, upload-time = "2025-09-04T08:39:25.649Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.6.77" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/93/80f8a520375af9d7ee44571a6544653a176e53c2b8ccce85b97b83c2491b/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f44f8d86bb7d5629988d61c8d3ae61dddb2015dee142740536bc7481b022fe4b", size = 90549, upload-time = "2024-11-20T17:38:17.387Z" }, + { url = "https://files.pythonhosted.org/packages/2b/53/36e2fd6c7068997169b49ffc8c12d5af5e5ff209df6e1a2c4d373b3a638f/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_aarch64.whl", hash = "sha256:adcaabb9d436c9761fca2b13959a2d237c5f9fd406c8e4b723c695409ff88059", size = 90539, upload-time = "2024-10-01T17:00:27.179Z" }, + { url = "https://files.pythonhosted.org/packages/56/9a/fff8376f8e3d084cd1530e1ef7b879bb7d6d265620c95c1b322725c694f4/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b90bed3df379fa79afbd21be8e04a0314336b8ae16768b58f2d34cb1d04cd7d2", size = 89276, upload-time = "2024-11-20T17:38:27.621Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/0d0c945463719429b7bd21dece907ad0bde437a2ff12b9b12fee94722ab0/nvidia_nvtx_cu12-12.6.77-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6574241a3ec5fdc9334353ab8c479fe75841dbe8f4532a8fc97ce63503330ba1", size = 89265, upload-time = "2024-10-01T17:00:38.172Z" }, + { url = "https://files.pythonhosted.org/packages/f7/cd/98a447919d4ed14d407ac82b14b0a0c9c1dbfe81099934b1fc3bfd1e6316/nvidia_nvtx_cu12-12.6.77-py3-none-win_amd64.whl", hash = "sha256:2fb11a4af04a5e6c84073e6404d26588a34afd35379f0855a99797897efa75c0", size = 56434, upload-time = "2024-10-01T17:11:13.124Z" }, +] + +[[package]] +name = "optuna" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "colorlog" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "sqlalchemy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/aa/05f5e3f662cc96a4c478fc3446b8ed6359825a2b504ecb614a9ac84e4a4d/optuna-4.9.0.tar.gz", hash = "sha256:b322e5cbdf1655fb84c37646c4a7a1f391de1b47806bbe222e015825d0a82b87", size = 485834, upload-time = "2026-06-01T06:23:30.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/f3/e5fcd5d9b15771ed6dc10e3a7eeddc672e418f4f4c4653d216cc1d857e2d/optuna-4.9.0-py3-none-any.whl", hash = "sha256:f52f3be6148654850c92a5860d398fd88ec6b2c84ab68d9c3d07dcff02e7afee", size = 425553, upload-time = "2026-06-01T06:23:28.804Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "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", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pathlib-abc" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/cb/448649d7f25d228bf0be3a04590ab7afa77f15e056f8fa976ed05ec9a78f/pathlib_abc-0.5.2.tar.gz", hash = "sha256:fcd56f147234645e2c59c7ae22808b34c364bb231f685ddd9f96885aed78a94c", size = 33342, upload-time = "2025-10-10T18:37:20.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/29/c028a0731e202035f0e2e0bfbf1a3e46ad6c628cbb17f6f1cc9eea5d9ff1/pathlib_abc-0.5.2-py3-none-any.whl", hash = "sha256:4c9d94cf1b23af417ce7c0417b43333b06a106c01000b286c99de230d95eefbb", size = 19070, upload-time = "2025-10-10T18:37:19.437Z" }, +] + +[[package]] +name = "patsy" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/44/ed13eccdd0519eff265f44b670d46fbb0ec813e2274932dc1c0e48520f7d/patsy-1.0.2.tar.gz", hash = "sha256:cdc995455f6233e90e22de72c37fcadb344e7586fb83f06696f54d92f8ce74c0", size = 399942, upload-time = "2025-10-20T16:17:37.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/70/ba4b949bdc0490ab78d545459acd7702b211dfccf7eb89bbc1060f52818d/patsy-1.0.2-py2.py3-none-any.whl", hash = "sha256:37bfddbc58fcf0362febb5f54f10743f8b21dd2aa73dec7e7ef59d1b02ae668a", size = 233301, upload-time = "2025-10-20T16:17:36.563Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "sys_platform != 'emscripten' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "plotly" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/07/795c79dbce40c39bece88e69d049babbd23ffa95b5d117f248db8ea03abb/plotly-6.9.0.tar.gz", hash = "sha256:967ad33e8c704fed051800d11d985eb206a9c795c14206b30a6f463ed9c67d0d", size = 6919903, upload-time = "2026-07-09T14:55:59.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/18/d8544811ab076f876c4892b3714f5b0dad335e1dc33aef826df431b8325d/plotly-6.9.0-py3-none-any.whl", hash = "sha256:36bebe2f1bb13884774fe61689c329071446f6ce4a8927fb1f0d6fb24f581236", size = 9909646, upload-time = "2026-07-09T14:55:55.421Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polars" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/99/fe77f10a13a778705ef05b499fc708c9a0b0a3680d9eb6bc6e1b6a6b9914/polars-1.42.1.tar.gz", hash = "sha256:2fe94f3059334650bd850ae19a9c165dcd5d9cb12cd95ea04de2201662e70e8a", size = 741532, upload-time = "2026-06-30T04:57:51.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/6a/edd939cc6fa04b6415aaa9bf19720fc74ead81234b3d38542e0005816d4d/polars-1.42.1-py3-none-any.whl", hash = "sha256:3c0c65cdfa21a621650c4bdcbbccf93964d052fd766c3e70e84a55d961c259fd", size = 837622, upload-time = "2026-06-30T04:56:34.686Z" }, +] + +[package.optional-dependencies] +rtcompat = [ + { name = "polars-runtime-compat", marker = "sys_platform != 'emscripten' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/59/15bcc4dac380c6d63efa5446d8317f22671cbd6c9dadd576bd17a334c45a/polars_runtime_32-1.42.1.tar.gz", hash = "sha256:4d4809e1c1b9a6611f6944f27b24abea902b5159e6b6fa262fd716e947af5afd", size = 3045460, upload-time = "2026-06-30T04:57:52.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/29/16ff6e4e91d71e530d3581f45e342a9cc35072ac6b31dcbc2fa33de2569e/polars_runtime_32-1.42.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bbdc26d68ee5b23b0ce227fa0599220aa35b77c826b6b0a6b2d8e7f6c1c36974", size = 53117325, upload-time = "2026-06-30T04:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/04/8e/4f8296fcfd1347f1351342fecf13bf2430d7efbae2f1f45964ec7930a99e/polars_runtime_32-1.42.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f6c0288be940b607dc4a7476c01e67fb6bbee93f5f1dd42c64970274c71008ba", size = 47446251, upload-time = "2026-06-30T04:56:41.459Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/0d66a7deadc453b890c3391034ca8ab4b05d0beaebbb92a7d65199fba61b/polars_runtime_32-1.42.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635d9dbcae2302ae223afb395d5cd220bffa61a53d0ab6871d17c8bc830101cf", size = 51359402, upload-time = "2026-06-30T04:56:44.595Z" }, + { url = "https://files.pythonhosted.org/packages/5d/10/ffb85fa380bc9c9000dc35f40f44954dde49023018501c54faab94b3a39e/polars_runtime_32-1.42.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d059e8e53cc114ff82f9bd791fd341dc53534a2c745e6f6aa37594c3a93f01fe", size = 57302723, upload-time = "2026-06-30T04:56:47.609Z" }, + { url = "https://files.pythonhosted.org/packages/c2/63/ca50adc62e44224ca5c622a842ba6f35ee87d1d40ef0df7ea2ed6c6edb08/polars_runtime_32-1.42.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f91f0b13588324905682809d270e1de5f1990c908721c8527657d77a044c9919", size = 51515673, upload-time = "2026-06-30T04:56:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/63/c3/08fbbf38deaa17bf34a601d327cb7451074098673c78b7c1a8538dde9794/polars_runtime_32-1.42.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b8bf972d99d48aaaa2582e2bce966a6f43bc815bd8725d15f5cab9e2fb15d17", size = 55217259, upload-time = "2026-06-30T04:56:53.834Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0e/51db89361668fe077a835fc579277f824ba526e7daf7b94d23d25439e0d0/polars_runtime_32-1.42.1-cp310-abi3-win_amd64.whl", hash = "sha256:e9364c26da389a8b7339e4d29e20a3d12af730247e6ed3b7804bddce2477f428", size = 52715432, upload-time = "2026-06-30T04:56:57.109Z" }, + { url = "https://files.pythonhosted.org/packages/76/c5/2fb8592d691bd114de25d9c84300b23541dca7060eac11d7b4bed0327786/polars_runtime_32-1.42.1-cp310-abi3-win_arm64.whl", hash = "sha256:7051226e6b42ffc395a7a9190377cd28649fbfb991b8f85c6271f4e1cfb736fb", size = 46718300, upload-time = "2026-06-30T04:56:59.855Z" }, +] + +[[package]] +name = "polars-runtime-compat" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/cf/7f8ec2d4675a7a44c7c6a9ad6a1d9071642841fe7e49ec7f59af708be2a3/polars_runtime_compat-1.42.1.tar.gz", hash = "sha256:2c54337f1a5c069bb24c7d2f1cf8d1724400d0b6b07193b7f95c198640c887f8", size = 3046649, upload-time = "2026-06-30T04:57:56.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/cc/22a9ae14f8e01d761c2b38ef62fec070e28d025d8aa3440e85ee6ee799bd/polars_runtime_compat-1.42.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:09c1e1a2686228a90d37e9ac4a9f4ff0ec15fed3c97fe5046088de81263011e8", size = 53125053, upload-time = "2026-06-30T04:57:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/21/fa/078262f6ed043e069327fac2dbf39e8525082deb5621fba9f44e038ccdbd/polars_runtime_compat-1.42.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:320efdecb85c04ad553af21aa0cbf704cbc5f7024e4ae7e269953e5a6aee1ced", size = 47339982, upload-time = "2026-06-30T04:57:30.742Z" }, + { url = "https://files.pythonhosted.org/packages/f8/21/ae6bf5f9de5c004061f1d850ce13cceaef44b71107e7e49661cb41871bf4/polars_runtime_compat-1.42.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b19e3d186b88a30829310aa617d244ec9a8c2a81c48d0d34918a8dc5039b1d0", size = 51231390, upload-time = "2026-06-30T04:57:33.772Z" }, + { url = "https://files.pythonhosted.org/packages/8a/16/bd72d61b36c927f01c6e20e43edc67951f52906ef4242e793fd34444219c/polars_runtime_compat-1.42.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e59fd9eee92664fbf8c1c0869adcc3f113a81ba055116793ddaa732ec2c26a0c", size = 56969211, upload-time = "2026-06-30T04:57:36.91Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d7/40d3e8ec226048f74476cab4172683805919662c9d7d288003328f8790d5/polars_runtime_compat-1.42.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fdcb22818688ef82e9a467eda4cb391089fac36d7711fe21e7b79c470ee51d2", size = 51407593, upload-time = "2026-06-30T04:57:40.088Z" }, + { url = "https://files.pythonhosted.org/packages/6c/84/2596c3b56b4f8e8884d5ce39ab147c0f3cf6515c1a7b16792c2f0cd521f5/polars_runtime_compat-1.42.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:26fd6cd1f5f2017296ebea00a860453bd521ca1a7c03b4e6ae3e98bcae864790", size = 54870480, upload-time = "2026-06-30T04:57:42.971Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/a4ee7034448ca10c754b65c007a3ec7e69f9628a6bb576a83b5976da6fb8/polars_runtime_compat-1.42.1-cp310-abi3-win_amd64.whl", hash = "sha256:b9a969680655e41d92ffdc298a41861eb1983e0689ae7f1c17231e35f4800090", size = 52629271, upload-time = "2026-06-30T04:57:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f1/10f3e595e0d8739475bc8de7893a70a6653776021bbf2068b262e705f87b/polars_runtime_compat-1.42.1-cp310-abi3-win_arm64.whl", hash = "sha256:9437b4799f77d7be7ff3fc93d7ed811157b81e2808772cfd0a291cd10d369eb3", size = 46600771, upload-time = "2026-06-30T04:57:48.85Z" }, +] + +[[package]] +name = "prek" +version = "0.4.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/54/edc21e275f9fa3540d4d98cf349c2de11621d6729cc401bb7aedf563609e/prek-0.4.10.tar.gz", hash = "sha256:db3122f4e780eb4587635e6a83df881caf2dbb1eb7799d1cca51158216d6f33b", size = 502565, upload-time = "2026-07-16T10:13:00.788Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/e7/5a63528ba7b95b64f38db3e253aed49ee8e5e8ba16589889d2b7f809edb7/prek-0.4.10-py3-none-linux_armv6l.whl", hash = "sha256:023f302741d79301346c3088ba43a9592aff0ecdbe5ddc3019fa9b1183319c5e", size = 5694609, upload-time = "2026-07-16T10:12:26.352Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ef/ee9e6bf9a5ce242e9e4e66ac4e2e9042a0f6fd9f367cee18ad404456e93d/prek-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:72adc707e16f97564bbae08d22b222ac3bb2491f8fbfb5a0754f80d472c28a71", size = 6044037, upload-time = "2026-07-16T10:12:28.539Z" }, + { url = "https://files.pythonhosted.org/packages/68/7e/da08cc39e5348ccb9234e63a21ee56861f72e8497d6a78f0db1ccae6515d/prek-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:04c9321957e1b32e1fc7cf60bb4f90bba3761f8659d5551ed04f96e25596de49", size = 5535983, upload-time = "2026-07-16T10:12:30.691Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/0486a35bb687a9beac7a5810bd1104c6da56d469b30b1eeaeefd03c99da2/prek-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e66ccf6c5e4ebadd05cd98cb338d7f553e4d27aa243cf91279c5a569b3cdccc7", size = 5862085, upload-time = "2026-07-16T10:12:33.042Z" }, + { url = "https://files.pythonhosted.org/packages/52/39/277fe17ae1f121e532e3942456f5a6d01ddacfbc550e481dcb359be7a1b0/prek-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63f9061d75a50ef0ca92c4b596ad352937a845df80758244950e513b27e9e18f", size = 5605697, upload-time = "2026-07-16T10:12:35.498Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/08354af3e000f2656fad086690d834eab6c04631ff41313a219ea6232199/prek-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c2ff7110e4bfaafbbab13c2893a337081aca61ed797f14b6b224d2ea9741eef", size = 6034111, upload-time = "2026-07-16T10:12:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/e4/74/4702396c8d486132e5ce009ab56a0b37f50cb6866830d371f2617b7bdfdc/prek-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b696a05542e79aa27bcce68d1792e77f4fe6f9c6b012b34d74d62f964f3c72d", size = 6787203, upload-time = "2026-07-16T10:12:40.031Z" }, + { url = "https://files.pythonhosted.org/packages/90/29/b5d5d6fb87ebd64b37471e3e79761de9983f85e14d69c522efe7af6620ce/prek-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:431b44d6054e72815b4b05e1173596dfd02a7f7461211d40a2e3117e414642ad", size = 6261333, upload-time = "2026-07-16T10:12:42.216Z" }, + { url = "https://files.pythonhosted.org/packages/94/d6/54ba696d19f7efdc184093353cce713a850aef9c3556e23faeecafa22e94/prek-0.4.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ccbd2b4fd1df790087ba18b4506f680471922a5f13714f19801568434a040dee", size = 5867761, upload-time = "2026-07-16T10:12:44.329Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/3975098aa2baaabfc10f99f9fcf78045c4f10851beed8e9812b6a2688eab/prek-0.4.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:479e7480b447191aa5c6ed67e80f081d0f5ee4e878b140f4d2cee44165395f1c", size = 5714412, upload-time = "2026-07-16T10:12:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/97/c0/3e0aac190fe95fdef98526343559b61d4d9fd54444c8c9137ba02412afe1/prek-0.4.10-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0bb7451025cbd2b68e480a13cf665d7a5c87c8b87bf18549a78985c17df817ed", size = 5578145, upload-time = "2026-07-16T10:12:48.261Z" }, + { url = "https://files.pythonhosted.org/packages/d7/44/7b26035534204b8b8a9d5e625479201e616413d287262f557cb32e1f8d77/prek-0.4.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4fb047e5776676805794574b2d7b178cb3ab536793aadf172419fcda56b34a57", size = 5889245, upload-time = "2026-07-16T10:12:50.818Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6c/178a9d768876b4211a1bf63907fe308ae02d173639bcf41cea3c5eed35c1/prek-0.4.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:08318818d19caf79643babb89f872c92fda134a622b4731df1d6ed61e29d2d26", size = 6372849, upload-time = "2026-07-16T10:12:52.952Z" }, + { url = "https://files.pythonhosted.org/packages/4d/84/d5f5ac8193602883f9dd1d675d9d4084e34fbe3ed2ef50a0c336d8a53d8f/prek-0.4.10-py3-none-win32.whl", hash = "sha256:092872714dcde480a662bbdd98b980b248c2d3e10543d4d53a3a58cc9e5b35b0", size = 5413005, upload-time = "2026-07-16T10:12:55.113Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/9e648fda10bc02c9b6ba305f93b6a6e4fd37d23d13a269a9d2d6bb44eaa1/prek-0.4.10-py3-none-win_amd64.whl", hash = "sha256:3d323a18d0f8c50e474a8fa29fb93bd2db680116d8afb19b76e72ad4667f58e6", size = 5799075, upload-time = "2026-07-16T10:12:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/22/74/b34d8c80cec8dccc7b922c75b9dca62b18b603b5ed2eea93c9d7c2928d2d/prek-0.4.10-py3-none-win_arm64.whl", hash = "sha256:5e93865ef96756c4a26f37ece04ad514abbc19ae6a23ed1a507b6314e6a0d2fb", size = 5563955, upload-time = "2026-07-16T10:12:59.07Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pyarrow" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/98/ae2b5acf9876dbeffa6f320776242c52caab062df55c8ac5501ed2679e74/pyarrow-25.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:2e3b6544e26e393fe2cd530f523e36c1c8d3c345bbbb60cca3fd866be8322517", size = 35939080, upload-time = "2026-07-10T08:26:04.53Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/3de2a968edbd496c86cb8b932cdbee2d4b08c4a28e9884a15e5c705a646b/pyarrow-25.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:b724d127783b4c19f088fcdfc844cbc318809246a30307bcabd5ed02045e890e", size = 37633420, upload-time = "2026-07-10T08:26:10.354Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/8399243a4ce080426ec37db18d5e29148b7ec960a8a8c7f9059a7bf6ef0a/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:244f98a595f70fa4fd35faa7508c4ae67e14a173397a4b3b49d2b3c360fb0062", size = 46861050, upload-time = "2026-07-10T08:26:16.397Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/72d704b02bc5fc6d06954d76a0208c1e79cad3ab370f6d6a91ffe5078870/pyarrow-25.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0222f0071d13313962a88d21bf28b80d355ac39d81bfa6ff3fe00eeaf748e4be", size = 50056458, upload-time = "2026-07-10T08:26:23.271Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/3c31a60b6403d63cad2e0f829096f5fc5763a129ead4207a5d4690b96448/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b58726f118c079f9d4ed7e904975d4f15fd69d0741ba511a4e2dcaa4ef16354f", size = 49957793, upload-time = "2026-07-10T08:26:30.232Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/8f8a019061f9863a831915329264372a87ed25eaf9109ce56eb0e84012c5/pyarrow-25.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:38a2c887cb3883e241b70201688db34133b6dfadd04f03c8f9213df53770c18e", size = 53100544, upload-time = "2026-07-10T08:26:36.414Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e2/738071e95c5ddad7b3dfc12f569ffa992db89d7d7b4a95258fd184191249/pyarrow-25.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:161649d60a7a46c613a19fd795763ea8a88c36ba997dd99d9bc66e6794ee36e8", size = 27848311, upload-time = "2026-07-10T08:26:41.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/fdd3a4377807b7dcabe2d4b5aa99dbbc98e2e5df3f1ca4e7f0aec492d987/pyarrow-25.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:149730a3d1f0fb59d663a0b8aa210adfd9c17c27cd94a0d143e60daea8320d4e", size = 35850884, upload-time = "2026-07-10T08:26:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/bf/71/9f053177a7709b8c90abb00a2375b916286f9f0d6cfb21a5cadd4ef811e8/pyarrow-25.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:0721332c30fdd453fdd1fc203b2ac1f4c9db5aea28fa38d41f2574c4b068b9ec", size = 37616197, upload-time = "2026-07-10T08:26:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/1a/22bfb6597dcdc861fa83c39c06e1457cb56f698940eff42fbb25de30e8e5/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fa1482b3da10cac2d4db6e26b81da543e237616af2ef6d466018b31ca586496f", size = 46841966, upload-time = "2026-07-10T08:27:07.685Z" }, + { url = "https://files.pythonhosted.org/packages/55/0e/cd705c042bc4fe7022478db577fcab4abdcfabb9bc37ab7a75556b3fcb2b/pyarrow-25.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5d1dbf24e151042f2fa3c129563f65d66674128868496fb008c4272b16bdf778", size = 50088993, upload-time = "2026-07-10T08:27:14.268Z" }, + { url = "https://files.pythonhosted.org/packages/98/ee/d822e1ee31fe31ec5d057210e0605c950b975dcd8d9a332976cc859a9df8/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20887a762dd61dcc530f93a140840ab1f6aa7836b33270e42d627ab3cf11e537", size = 49941005, upload-time = "2026-07-10T08:27:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/33/1b/207a90cc64619a095eb75a263ae069735f2810056d43c667befd573ec083/pyarrow-25.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58d1ab556b0cea1c93fdb799b24ad58adb2f2a2788dbce782a94f64ae1a5cc9b", size = 53112355, upload-time = "2026-07-10T08:27:27.911Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fe/81d1e5f8beed15c01e98649d5c6e2167b67fd395884a2488f18bf1cf0dba/pyarrow-25.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f356afe61186395c861d5cd63dc21ff7d5fa335012a4668d979257df7fea0f5", size = 27945954, upload-time = "2026-07-10T08:27:32.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549, upload-time = "2026-07-10T08:27:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397, upload-time = "2026-07-10T08:27:43.803Z" }, + { url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701, upload-time = "2026-07-10T08:27:49.741Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118, upload-time = "2026-07-10T08:27:56.051Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559, upload-time = "2026-07-10T08:28:01.927Z" }, + { url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238, upload-time = "2026-07-10T08:28:08.594Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162, upload-time = "2026-07-10T08:28:12.975Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "python-dotenv", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-inspection", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, +] + +[[package]] +name = "pytest" +version = "7.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116, upload-time = "2023-12-31T12:00:18.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287, upload-time = "2023-12-31T12:00:13.963Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, +] + +[[package]] +name = "pytorch-lightning" +version = "2.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130')" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "torch", version = "2.13.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "extra == 'extra-8-drevalpy-cu126' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "torch", version = "2.13.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/2c/8e73a3929b4c4bd600cafd38a97aaf7242a8cf518fb9f33d27c274ec898f/pytorch_lightning-2.6.5.tar.gz", hash = "sha256:1c32cefa76a1a9c4c5250338272d961d1e48b180e68396849efe128538ddb28e", size = 661673, upload-time = "2026-05-27T14:33:41.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/4d/5740c27110b83634d8491c3b5facf0111b3e554c3164f4fb953be9bddaf6/pytorch_lightning-2.6.5-py3-none-any.whl", hash = "sha256:62d9c8549b2278fedc3364f0a5607a56c6063d18635008f8cf3fae8d802b0d76", size = 852407, upload-time = "2026-05-27T14:33:39.856Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pywinpty" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b", size = 16223484, upload-time = "2026-06-11T00:11:58.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/5c/31feb3dd82d1b33ae0bd09ca601edb993d9da1b7f0226b3336d4b4c39e1e/pywinpty-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:af7a8720c78776ddd6259b71dd567944f766a6cd67f8d2887fbc4973967bacda", size = 2092466, upload-time = "2026-06-10T23:44:24.453Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fe/fe23e2229ffec0c10190cef5964f5c9b2dba179d23b69ae537b7ea90bcab/pywinpty-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:c2406f54f699eab75953fb75ce805f2ae55a33a957cd070890abd454fb4b7680", size = 818395, upload-time = "2026-06-10T23:41:56.93Z" }, + { url = "https://files.pythonhosted.org/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23", size = 2090915, upload-time = "2026-06-10T23:43:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376", size = 815934, upload-time = "2026-06-10T23:41:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c", size = 2090471, upload-time = "2026-06-10T23:42:11.071Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5", size = 815518, upload-time = "2026-06-10T23:42:02.363Z" }, + { url = "https://files.pythonhosted.org/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598", size = 2089920, upload-time = "2026-06-10T23:44:13.395Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c", size = 814520, upload-time = "2026-06-10T23:43:28.588Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "rdkit" +version = "2026.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/3c/5a2362430ab0115f189df5de910d3333c3916e6ec502ddad6157272bac23/rdkit-2026.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cee9a591c4f315b9e10138d9e7a69033abc4b229a960b45fef5cd179f2f168fc", size = 30168941, upload-time = "2026-07-16T10:12:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/49/b0/4a8a66208b00aee621362a0817db5c0d57bd5213bef56d75bb6f760965f1/rdkit-2026.3.4-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1d6f75e5b853eba73f7be1832a4b6f4caff4ff22fa4ba078c53352c9483c3f48", size = 35991860, upload-time = "2026-07-16T10:12:22.636Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/6478651b0055d3df6c386702e4e5c0d3bd3fc32f25bc1c82f6ad46ddba1c/rdkit-2026.3.4-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a41dde42ecb24e7d93d62b89e8e5d267b02bbac764f965f3968296c99234c685", size = 37485408, upload-time = "2026-07-16T10:12:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/7c/64/2ffbcce727ef2433635c1ac1b212d5fc42f571f974571d8d64c5e4a4b190/rdkit-2026.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:68f27f12063091115df76d0adbd3d1ec7d7ae8a0ddf103cfb88a1fee4670f2cd", size = 24710676, upload-time = "2026-07-16T10:12:33.432Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c1/18e4f420fc339613b7419cdaab1ddb60c2a5cd6e74274b5497dcfb0af0e0/rdkit-2026.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d5eeb9212ff64410fd37c4b561603da52193f9cdbe40a787a5159e1076c0981", size = 30214636, upload-time = "2026-07-16T10:12:38.137Z" }, + { url = "https://files.pythonhosted.org/packages/77/99/49dd69488abc60434f812eb2d5355f6b26dd34d7d79948434b661b31929e/rdkit-2026.3.4-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d63ac1384a7a6af9742cafa23139f9b1ce7639e48d2a6b1b793600065442a133", size = 35870281, upload-time = "2026-07-16T10:12:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/26/7f/90254c3c774598cc790cb45910568820bb6eac0dade74762dcb5d0714e59/rdkit-2026.3.4-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bec605934ad781e91aa10d434ac986ee18e191ae9dfa65fcabb1784aadf33edb", size = 37414333, upload-time = "2026-07-16T10:12:46.619Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/b20d54cc905a8a088de30e042521306b2f3f6448fb0b4c9af993acec2511/rdkit-2026.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6d25f79fe7c22ab0b6172f33417dba83518e4ad098bcf5c84f7d22ca08b1f18", size = 24730873, upload-time = "2026-07-16T10:12:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/62215f9345648b2186161addb5772b18df3f8ddb3d75bd7a8a2277cc42a3/rdkit-2026.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df1ec162c226c177e37a8325aa217e89fb0e4d4db68f919e53b9fce144c13dc9", size = 30213702, upload-time = "2026-07-16T10:12:53.745Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b4/157e9c1dd85ad8d9da1f386b6a1a819b2f39e480821e08e0d09a54d2fba4/rdkit-2026.3.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:19634f932126c300d0fb0e02287d9eea601f43262a8980645df16c7dd55ba1a7", size = 35868890, upload-time = "2026-07-16T10:12:57.545Z" }, + { url = "https://files.pythonhosted.org/packages/be/d9/f5ea74bc526f66f4d30da562eb76d69a56b6286aa4f775650d124cfec0f4/rdkit-2026.3.4-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:98cb809bc8e3f7168c7909670a418096830c95d5b386e4b124d1381f9730e5c1", size = 37413646, upload-time = "2026-07-16T10:13:01.489Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/48b6935e235fb09b6e36fcd12a5c3056dac9affbc051cb994f4e67eb6531/rdkit-2026.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f6fd3e9ee43211b9458268a0a21a5c3db7d27d2aa2eef9cb837dc4302d4426d", size = 24730115, upload-time = "2026-07-16T10:13:05.241Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, + { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, + { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, + { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, + { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, + { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, + { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/ea/21e4867ea0ef881ffd4c0550fc21a061435e50d6324bcd034396633cbc18/rich_click-1.9.8.tar.gz", hash = "sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371", size = 75363, upload-time = "2026-05-28T19:54:59.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "roman-numerals-py" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "roman-numerals" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl", hash = "sha256:553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780", size = 4547, upload-time = "2025-12-17T18:25:40.136Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +] + +[[package]] +name = "s3fs" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore" }, + { name = "aiohttp" }, + { name = "fsspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/00/6677343dc919d6c072bb04d80210afdd22c16838a8d16b3315c122dc728f/s3fs-2026.6.0.tar.gz", hash = "sha256:b28de7082d0a4f72392884bdc497e34a4a1582f675d214c7da0acf6e950a0083", size = 87358, upload-time = "2026-06-16T02:05:48.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/0b/f68a968b49876eae0f2a515387093cebb2eb9451380a96741cc20efac0d0/s3fs-2026.6.0-py3-none-any.whl", hash = "sha256:60576e31bb31193c1f643f32b4c6439548720ea6918ac702e21cd757c80b5db8", size = 32573, upload-time = "2026-06-16T02:05:47.608Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/da/4bef7ce7bb989b222aa4785a413896dbec53306dfc59c6ce7d16a7ffbd6a/s3transfer-0.19.1.tar.gz", hash = "sha256:d3d6371dc3f1e5c5427b2b457bcf13bcf87bec334c95aed18642eae61f6926f3", size = 165354, upload-time = "2026-07-10T19:32:04.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/23/e84c64ad0e8bc59cd1b2ef98def848deff0ef3456c542afe74d51e9e8c85/s3transfer-0.19.1-py3-none-any.whl", hash = "sha256:d5fd7005ee39307455ad5f310b5ea67f4b1960d7fed5b3671ee50c249de675de", size = 90072, upload-time = "2026-07-10T19:32:03.673Z" }, +] + +[[package]] +name = "safetensors" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/06/f955dbbb1859e3bd23c8ac6141af5106e7ad5fedec4a3a6e3d60f94b7001/safetensors-0.8.0.tar.gz", hash = "sha256:fabaf3e0f18a6618d9b36560682562157f77c2b71fcffc7b432be2baed9d753d", size = 325846, upload-time = "2026-06-09T07:52:25.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/a0/f718cda65b05407d228f97602cf60dca269c979867aa5beb25410de26cd3/safetensors-0.8.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c554f85858e05226d3c2828e32395e677434685d6d94594a41643361c5e837f0", size = 473568, upload-time = "2026-06-09T07:52:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b1/fa7c600e7dceae12e9606c7578cbc9ff1e1ed55844883ee5c92205e86226/safetensors-0.8.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c80201d22cbf405b80647a60ada77bba06c8fba2da2743ba1e89cdcc39a81f25", size = 484562, upload-time = "2026-06-09T07:52:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/65a7de0af421317bb36a067241e4235fff194eed60b961ed6d3f59a3fc60/safetensors-0.8.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a46e5ff292c356d6991e60942ba7f79817682d3a2cef0702136448cb9c4d235", size = 502844, upload-time = "2026-06-09T07:52:07.624Z" }, + { url = "https://files.pythonhosted.org/packages/91/4f/3175c9d75634e0e0dda0082794193521035edd7c70a6f212bf33ca06ddf4/safetensors-0.8.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4124502b78f03534117c848f87a39b8f31e577b15eff423bf8bfb95f2a8c30d0", size = 511823, upload-time = "2026-06-09T07:52:09.565Z" }, + { url = "https://files.pythonhosted.org/packages/20/87/846c289e7aa2299eff406335717cf43ce8777194ece8aad75772e0411615/safetensors-0.8.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc0a787ba8a35be368ee3574edfa2b1ad389eebd0a72e482ae275490e3f6c98", size = 633461, upload-time = "2026-06-09T07:52:11.128Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/8d64d9df2c45d5ded401df889d0ad90882804ca172d79ec4f0df8f727fe0/safetensors-0.8.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:040070828e36dc8e122178bbbd5830ff9e97920affb84cbe0f46442497bed358", size = 545148, upload-time = "2026-06-09T07:52:13.603Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/f203ff3a3ddfe19308efc83c5a3a29ed02bf786732ec35e68bf9162f3365/safetensors-0.8.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd6f3f93c9a0a7cc2788ee63fb763353d4bd2e89b0751bc78fcf7dda00bea774", size = 516040, upload-time = "2026-06-09T07:52:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/46/fb/cdaed17ceb2948784fd9c36b6fd3e951b608547cea81a48e8ee6f8cfdfcb/safetensors-0.8.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:fcdd41ec4628fee5799f807c73c353629130fbd942aa23d83c623dd6c9d52d78", size = 513832, upload-time = "2026-06-09T07:52:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/0d/49/1e15de264dcc3b77943d2d0c56a95809956883b1c2d6d585c792523f180b/safetensors-0.8.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e9f537aa183a38ace122d27303dcd986b26bd2a7591f9181d7f0c396f4677ca", size = 559930, upload-time = "2026-06-09T07:52:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/2a/43/bf38443278eab4b1be1fce2931e2b012ad9cb7df52ada751d0aab8f7659a/safetensors-0.8.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87eec7ffed2b809f05a398a8becb7d013f19f7837cd15d9748580d6cf30dbaf4", size = 678670, upload-time = "2026-06-09T07:52:20.032Z" }, + { url = "https://files.pythonhosted.org/packages/72/e3/68cd3fa5b48488e84add63e04cb12f3bc28ae4638c06d4508c6e88823d0e/safetensors-0.8.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:4a95ae2b05d7726d751da4ebf626a2ca782b706e101bd894c95bc2450b1cffcc", size = 786679, upload-time = "2026-06-09T07:52:21.322Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/1c19c509d56e01f4fbb3d0a2e597450f6cc04d1d56cf52defb0a62dfd715/safetensors-0.8.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae091f16662658bdc019a4ff6cb4c085bb7d725eb5978b183ffd265863b6d2d", size = 765683, upload-time = "2026-06-09T07:52:22.594Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/41c1621732edd934d868a00d1b891584c892a7b62a9aab82ea5a0a5623ee/safetensors-0.8.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8e080062fcde23be189565e1c3305d16751a218ecf9412c8601e64204eb6f846", size = 722361, upload-time = "2026-06-09T07:52:23.924Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/73ccf82579412b4a71c4ca673f10b5f1f888d7cf5af7fe24f27d30307be4/safetensors-0.8.0-cp310-abi3-win32.whl", hash = "sha256:2ddf52eac562eda224f99acfa7889d02968c1fd59a5b011ae7d8137c37e9c02d", size = 342401, upload-time = "2026-06-09T07:52:28.895Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6d/3fba214c1e5e0f69991677ec3bc17023f0421776975e1de0c682dca475e2/safetensors-0.8.0-cp310-abi3-win_amd64.whl", hash = "sha256:096ec1a98435df7beb08853bb5aa9081a84f23d0adc67ed1a0a10550f608373f", size = 355540, upload-time = "2026-06-09T07:52:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fc/7eedc3510d97878876e32774eebbeb61c43f148a96e915c84229a3e967aa/safetensors-0.8.0-cp310-abi3-win_arm64.whl", hash = "sha256:f7838e5135a406ad3e02efdcb8cf2e5397d368b0154537c4fec682dbc544d452", size = 340500, upload-time = "2026-06-09T07:52:26.745Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767, upload-time = "2026-06-02T11:54:32.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686, upload-time = "2026-06-02T11:53:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782, upload-time = "2026-06-02T11:53:08.904Z" }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419, upload-time = "2026-06-02T11:53:11.551Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411, upload-time = "2026-06-02T11:53:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736, upload-time = "2026-06-02T11:53:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564, upload-time = "2026-06-02T11:53:21.2Z" }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122, upload-time = "2026-06-02T11:53:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512, upload-time = "2026-06-02T11:53:27.183Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603, upload-time = "2026-06-02T11:53:30.328Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097, upload-time = "2026-06-02T11:53:33.456Z" }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173, upload-time = "2026-06-02T11:53:36.602Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451, upload-time = "2026-06-02T11:53:39.075Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188, upload-time = "2026-06-02T11:53:41.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299, upload-time = "2026-06-02T11:53:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690, upload-time = "2026-06-02T11:53:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723, upload-time = "2026-06-02T11:53:50.702Z" }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330, upload-time = "2026-06-02T11:53:53.396Z" }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653, upload-time = "2026-06-02T11:53:56.035Z" }, +] + +[[package]] +name = "scikit-posthocs" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "seaborn" }, + { name = "statsmodels" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/88/1a1758a6b68079cc58dd38b291cd194a29f9018a0eb5397342c5156009e5/scikit_posthocs-0.14.0.tar.gz", hash = "sha256:3b9f9273fc9037ee967d11b6a15aef1c6c0ed33a1936ff8879d6f5fb0a181227", size = 40554, upload-time = "2026-05-26T13:01:40.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/50/975860099ee9f1288f030374386d110c54d58cd1dc71df023135aa79633d/scikit_posthocs-0.14.0-py3-none-any.whl", hash = "sha256:9e2ecd5828cb783c42c1923e223eab202f529b03ab97de587647ab30f6e88333", size = 36836, upload-time = "2026-05-26T13:01:39.23Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "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", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, +] + +[[package]] +name = "scverse-misc" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", +] +dependencies = [ + { name = "session-info2", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-extensions", marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/05/6123a4362e2810ef216f152e249a66799f9c37975dabf89b69abb2d68c42/scverse_misc-0.0.3.tar.gz", hash = "sha256:18c46eeeac8ccef8f435e41a8ee86173b3d7ef6ea1167fde97a17553f70d3210", size = 23128, upload-time = "2026-04-10T15:12:21.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/3e/9dad188e00d08f5381b95dd1f8f7b1dc9eb244250d93fea3e9cb7d0e61f0/scverse_misc-0.0.3-py3-none-any.whl", hash = "sha256:295702368db8f5fe946b5296135990492b3fc891a45bd6dd27798775db4c61c6", size = 8845, upload-time = "2026-04-10T15:12:20.366Z" }, +] + +[[package]] +name = "scverse-misc" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130'", +] +dependencies = [ + { name = "session-info2", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-extensions", marker = "python_full_version == '3.12.*' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/9b/e3d49620c7bacecad1de9c6474fb453969cc715202e5fc4f7929bfae4e0f/scverse_misc-0.1.1.tar.gz", hash = "sha256:5001572763a88d962f7e40309d1e47a62ae3c358aec93e377af998e4c7e6dd97", size = 42585, upload-time = "2026-06-22T13:01:27.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d7/324b9bdd6fa89fefb4765494dd20d76c06d0f4f0aa8e46233e122c9f9f21/scverse_misc-0.1.1-py3-none-any.whl", hash = "sha256:d402e470a6921c110ab44a63f2e606204d6f6ef25626a3cb2d7b567832148369", size = 22662, upload-time = "2026-06-22T13:01:26.43Z" }, +] + +[package.optional-dependencies] +settings = [ + { name = "pydantic-settings", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "python-dotenv", marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.66.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ff/670abe04c5072719b5060ed93851d0d69525d60f8f2c5810f8becd58f9c1/sentry_sdk-2.66.0.tar.gz", hash = "sha256:9727d35aa83c56cd53294676fe65b96296a334c9ce107fa2142bd70f47acb265", size = 935745, upload-time = "2026-07-16T12:42:04.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/bb/49b10783f29067da2eec179320617e94faf63196609de47aeab3c26c3325/sentry_sdk-2.66.0-py3-none-any.whl", hash = "sha256:096136c214c602be2b323524d30755dc5b30ec5a218a206207f33b12c05c6f11", size = 504769, upload-time = "2026-07-16T12:42:02.919Z" }, +] + +[[package]] +name = "session-info2" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/17/c6a81d91781734bd10d7842c32de665f34395b4db234abdc80dac271632b/session_info2-0.4.1.tar.gz", hash = "sha256:3bb2bf7b73b2e13a1737e9aa91a6dae55e2c49e83bee973f24245f31ae264a1f", size = 25207, upload-time = "2026-04-08T11:30:55.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/d7/6893c9c2a52e4bcbeca2a2bf2aee970a686cc7bf555f97db13b00f35250e/session_info2-0.4.1-py3-none-any.whl", hash = "sha256:423b3f6bb7023433cfc3f791a6fdbb6a2cfbe226770ae6c127c3b2c4cf5a9d56", size = 17696, upload-time = "2026-04-08T11:30:54.707Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smart-open" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/53/9c513747547fd595d5c143259129ea8b9c3ea2f6b7bb9dcea2b1966ded3c/smart_open-8.0.1.tar.gz", hash = "sha256:18b1c4496003c6902be17c15f032b5c319f307c89c6ae9e6b028b508bed8b2cf", size = 61882, upload-time = "2026-07-15T13:56:10.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/96/325b8c507ccecc50421fecc0345a502ee6e4a44785af3c4e6ecbadad624a/smart_open-8.0.1-py3-none-any.whl", hash = "sha256:3e97f90e92a952cb57863dfe132082c400a52eeeb27c067692fb51dbcc5b0089", size = 73504, upload-time = "2026-07-15T13:56:09.033Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, +] + +[[package]] +name = "spectra" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colormath2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/1b/23c9c5c8f60fa8d5a4585448e958b6b68f4d69255f50d2d9c2f13b8bc8c1/spectra-0.1.0.tar.gz", hash = "sha256:6a30e33241cb18256020395181cb4cc029dcac6de6f8d78cecbed81c14226a3f", size = 19363, upload-time = "2025-02-13T04:01:20.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/99/61929098d0ad75d9b7240ab028083e199803f78e554b2581f02f0c97c35f/spectra-0.1.0-py3-none-any.whl", hash = "sha256:2aa0f4a6e3cb58c667df5c3321be0630320a03f29b04114fc223119e51213ebb", size = 17488, upload-time = "2025-02-13T04:01:19.352Z" }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2025.8.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "sphinx" }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, +] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4f/4fd5583678bb7dc8afa69e9b309e6a99ee8d79ad3a4728f4e52fd7cb37c7/sphinx_autodoc_typehints-3.5.2.tar.gz", hash = "sha256:5fcd4a3eb7aa89424c1e2e32bedca66edc38367569c9169a80f4b3e934171fdb", size = 37839, upload-time = "2025-10-16T00:50:15.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/f2/9657c98a66973b7c35bfd48ba65d1922860de9598fbb535cd96e3f58a908/sphinx_autodoc_typehints-3.5.2-py3-none-any.whl", hash = "sha256:0accd043619f53c86705958e323b419e41667917045ac9215d7be1b493648d8c", size = 21184, upload-time = "2025-10-16T00:50:13.973Z" }, +] + +[[package]] +name = "sphinx-design" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/44/c97faec644d29a5ceddd3020ae2edffa69e7d00054a8c7a6021e82f20335/sphinx_rtd_theme-3.0.2.tar.gz", hash = "sha256:b7457bc25dda723b20b086a670b9953c859eab60a2a03ee8eb2bb23e176e5f85", size = 7620463, upload-time = "2024-11-13T11:06:04.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/77/46e3bac77b82b4df5bb5b61f2de98637724f246b4966cfc34bc5895d852a/sphinx_rtd_theme-3.0.2-py2.py3-none-any.whl", hash = "sha256:422ccc750c3a3a311de4ae327e82affdaf59eb695ba4936538552f3b00f4ee13", size = 7655561, upload-time = "2024-11-13T11:06:02.094Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-mermaid" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/9d/bf3a48a657682c7e0445c71d916bca7ab8194454276cc22b16e7f974e502/sphinxcontrib_mermaid-2.1.0.tar.gz", hash = "sha256:13c5f9ac395cb6abf403eca34e228dc9fb3a30c9d960dbf3e40e9a8cef969549", size = 21695, upload-time = "2026-07-18T23:08:11.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/bd/d3348d62296e73a91420f0edf671450c9003ecd8704da40b1e3d9b868459/sphinxcontrib_mermaid-2.1.0-py3-none-any.whl", hash = "sha256:417cd144ec4b28852f46ba653f02ce8e538881c812111671a4c30344e87f2112", size = 16190, upload-time = "2026-07-18T23:08:10.098Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "statsmodels" +version = "0.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "patsy" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/81/e8d74b34f85285f7335d30c5e3c2d7c0346997af9f3debf9a0a9a63de184/statsmodels-0.14.6.tar.gz", hash = "sha256:4d17873d3e607d398b85126cd4ed7aad89e4e9d89fc744cdab1af3189a996c2a", size = 20689085, upload-time = "2025-12-05T23:08:39.522Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/4d/df4dd089b406accfc3bb5ee53ba29bb3bdf5ae61643f86f8f604baa57656/statsmodels-0.14.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ad5c2810fc6c684254a7792bf1cbaf1606cdee2a253f8bd259c43135d87cfb4", size = 10121514, upload-time = "2025-12-05T19:28:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/82/af/ec48daa7f861f993b91a0dcc791d66e1cf56510a235c5cbd2ab991a31d5c/statsmodels-0.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:341fa68a7403e10a95c7b6e41134b0da3a7b835ecff1eb266294408535a06eb6", size = 10003346, upload-time = "2025-12-05T19:28:29.568Z" }, + { url = "https://files.pythonhosted.org/packages/a9/2c/c8f7aa24cd729970728f3f98822fb45149adc216f445a9301e441f7ac760/statsmodels-0.14.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdf1dfe2a3ca56f5529118baf33a13efed2783c528f4a36409b46bbd2d9d48eb", size = 10129872, upload-time = "2025-12-05T23:09:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/9ae8e9b0721e9b6eb5f340c3a0ce8cd7cce4f66e03dd81f80d60f111987f/statsmodels-0.14.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3764ba8195c9baf0925a96da0743ff218067a269f01d155ca3558deed2658ca", size = 10381964, upload-time = "2025-12-05T23:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/28/8c/cf3d30c8c2da78e2ad1f50ade8b7fabec3ff4cdfc56fbc02e097c4577f90/statsmodels-0.14.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9e8d2e519852adb1b420e018f5ac6e6684b2b877478adf7fda2cfdb58f5acb5d", size = 10409611, upload-time = "2025-12-05T23:09:57.131Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/018f14ecb58c6cb89de9d52695740b7d1f5a982aa9ea312483ea3c3d5f77/statsmodels-0.14.6-cp311-cp311-win_amd64.whl", hash = "sha256:2738a00fca51196f5a7d44b06970ace6b8b30289839e4808d656f8a98e35faa7", size = 9580385, upload-time = "2025-12-05T19:28:42.778Z" }, + { url = "https://files.pythonhosted.org/packages/25/ce/308e5e5da57515dd7cab3ec37ea2d5b8ff50bef1fcc8e6d31456f9fae08e/statsmodels-0.14.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe76140ae7adc5ff0e60a3f0d56f4fffef484efa803c3efebf2fcd734d72ecb5", size = 10091932, upload-time = "2025-12-05T19:28:55.446Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/affbabf3c27fb501ec7b5808230c619d4d1a4525c07301074eb4bda92fa9/statsmodels-0.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:26d4f0ed3b31f3c86f83a92f5c1f5cbe63fc992cd8915daf28ca49be14463a1c", size = 9997345, upload-time = "2025-12-05T19:29:10.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/3a73b51e6450c31652c53a8e12e24eac64e3824be816c0c2316e7dbdcb7d/statsmodels-0.14.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8c00a42863e4f4733ac9d078bbfad816249c01451740e6f5053ecc7db6d6368", size = 10058649, upload-time = "2025-12-05T23:10:12.775Z" }, + { url = "https://files.pythonhosted.org/packages/81/68/dddd76117df2ef14c943c6bbb6618be5c9401280046f4ddfc9fb4596a1b8/statsmodels-0.14.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19b58cf7474aa9e7e3b0771a66537148b2df9b5884fbf156096c0e6c1ff0469d", size = 10339446, upload-time = "2025-12-05T23:10:28.503Z" }, + { url = "https://files.pythonhosted.org/packages/56/4a/dce451c74c4050535fac1ec0c14b80706d8fc134c9da22db3c8a0ec62c33/statsmodels-0.14.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81e7dcc5e9587f2567e52deaff5220b175bf2f648951549eae5fc9383b62bc37", size = 10368705, upload-time = "2025-12-05T23:10:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/60/15/3daba2df40be8b8a9a027d7f54c8dedf24f0d81b96e54b52293f5f7e3418/statsmodels-0.14.6-cp312-cp312-win_amd64.whl", hash = "sha256:b5eb07acd115aa6208b4058211138393a7e6c2cf12b6f213ede10f658f6a714f", size = 9543991, upload-time = "2025-12-05T23:10:58.536Z" }, + { url = "https://files.pythonhosted.org/packages/81/59/a5aad5b0cc266f5be013db8cde563ac5d2a025e7efc0c328d83b50c72992/statsmodels-0.14.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47ee7af083623d2091954fa71c7549b8443168f41b7c5dce66510274c50fd73e", size = 10072009, upload-time = "2025-12-05T23:11:14.021Z" }, + { url = "https://files.pythonhosted.org/packages/53/dd/d8cfa7922fc6dc3c56fa6c59b348ea7de829a94cd73208c6f8202dd33f17/statsmodels-0.14.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa60d82e29fcd0a736e86feb63a11d2380322d77a9369a54be8b0965a3985f71", size = 9980018, upload-time = "2025-12-05T23:11:30.907Z" }, + { url = "https://files.pythonhosted.org/packages/ee/77/0ec96803eba444efd75dba32f2ef88765ae3e8f567d276805391ec2c98c6/statsmodels-0.14.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89ee7d595f5939cc20bf946faedcb5137d975f03ae080f300ebb4398f16a5bd4", size = 10060269, upload-time = "2025-12-05T23:11:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/10/b9/fd41f1f6af13a1a1212a06bb377b17762feaa6d656947bf666f76300fc05/statsmodels-0.14.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:730f3297b26749b216a06e4327fe0be59b8d05f7d594fb6caff4287b69654589", size = 10324155, upload-time = "2025-12-05T23:12:01.805Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0f/a6900e220abd2c69cd0a07e3ad26c71984be6061415a60e0f17b152ecf08/statsmodels-0.14.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f1c08befa85e93acc992b72a390ddb7bd876190f1360e61d10cf43833463bc9c", size = 10349765, upload-time = "2025-12-05T23:12:18.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/08/b79f0c614f38e566eebbdcff90c0bcacf3c6ba7a5bbb12183c09c29ca400/statsmodels-0.14.6-cp313-cp313-win_amd64.whl", hash = "sha256:8021271a79f35b842c02a1794465a651a9d06ec2080f76ebc3b7adce77d08233", size = 9540043, upload-time = "2025-12-05T23:12:33.887Z" }, +] + +[[package]] +name = "subword-nmt" +version = "0.3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mock" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/1a/bc10ed2b43788716c9b25ff066c92d6838444a7883f462abbc2e25b34c03/subword_nmt-0.3.8.tar.gz", hash = "sha256:3964c66b37712ca1d9fb9a1a6ff7e57c9ab72d838813da3e9a1d4d4997f4fb75", size = 22099, upload-time = "2021-12-08T10:05:48.498Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/9a/488ecac22d78eb429928b9ee4f6b6c692e116ca4bd43ef42a475698def32/subword_nmt-0.3.8-py3-none-any.whl", hash = "sha256:d22526b557752f35ac15e8ea384ea7773e50a51d966b8752d023d16cb87eac36", size = 27271, upload-time = "2021-12-08T10:05:47.307Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "pywinpty", marker = "(os_name == 'nt' and sys_platform != 'darwin') or (os_name == 'nt' and extra != 'extra-8-drevalpy-cpu') or (os_name != 'nt' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (os_name != 'nt' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (os_name != 'nt' and extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, + { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" }, + { url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" }, + { url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" }, + { url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", + "python_full_version < '3.12' and sys_platform == 'darwin'", +] +dependencies = [ + { name = "filelock", marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "fsspec", marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "jinja2", marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "networkx", marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "setuptools", marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "sympy", marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-extensions", marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", upload-time = "2026-07-08T12:26:13Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", upload-time = "2026-07-08T12:26:18Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", upload-time = "2026-07-08T12:26:23Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten'", +] +dependencies = [ + { name = "cuda-bindings", version = "13.3.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "cuda-toolkit", version = "13.0.3.0", source = { registry = "https://pypi.org/simple" }, extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "(sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "filelock", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130')" }, + { name = "fsspec", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130')" }, + { name = "jinja2", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130')" }, + { name = "networkx", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cudnn-cu13", marker = "(sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cusparselt-cu13", marker = "(sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nccl-cu13", marker = "(sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nvshmem-cu13", marker = "(sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "setuptools", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130')" }, + { name = "sympy", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130')" }, + { name = "triton", marker = "(sys_platform == 'linux' and extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-extensions", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/fe/cba54dc58523434919b66f13a667e36e436deddd77ca519e96553617d4ec/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", size = 111187938, upload-time = "2026-07-08T16:05:17.065Z" }, + { url = "https://files.pythonhosted.org/packages/c2/59/1e3160e18e12aa3038390efab3ce02b36a9d4d6a527ecdd8520dca2e68d8/torch-2.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:092790c696a760c729fd5722835f50b9d81fd7c8f141571f3f3cf4081a8f664c", size = 427199369, upload-time = "2026-07-08T16:04:51.054Z" }, + { url = "https://files.pythonhosted.org/packages/01/79/1f2d34ad7034ee1c7ffc1cf8bf0f8213af2a81df6ecdb3997ecec107c09d/torch-2.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60fcdcb2f3876e21146cb4524ef06397d727ca9ad5f020818547e25075fe3cb7", size = 526574961, upload-time = "2026-07-08T16:04:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fd/0f2ce40f58aefbdb3392f9acce3c8171940943ae2d661f70558bfa73befb/torch-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:a0d8b11f16a48d60e2015d8213aa0390744cbebb98e58b62b3514dddc656e330", size = 122015870, upload-time = "2026-07-08T16:05:27.59Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3a/ed0f4d4d1dcde03bced7aac9a28e800abcdc0cbd06b6775044c9fbd877b7/torch-2.13.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2fe228aba290d14b9f31b049be550dbd469c3fd3013d7a19705b30454da97027", size = 111213045, upload-time = "2026-07-08T16:05:22.997Z" }, + { url = "https://files.pythonhosted.org/packages/df/a9/f6a2a4d763ff1df02e9a64c477029db614295bc9367f4131223791ccc243/torch-2.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:572df8be8ffb4599c88cbd6a0726f1f854f4da65d2e3c09f0e2c2283333cd6d4", size = 427210998, upload-time = "2026-07-08T16:04:37.708Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/fea946351658e6534db52d2cc12bc53087cbf87f9440c5f180f367c1950b/torch-2.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:796633c4cdf0fe2cdced72d8f88f22e73dbcfce83132763162f6d4bff13b820b", size = 526605292, upload-time = "2026-07-08T16:04:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/21/d6/e8f3c6f7e01f626f77259de9860d2a78bc84c40539e28e79b7e98b0bb659/torch-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:024c6cc0c1b085f2f91f20a3dc27b0471d021c31ce84b81be3afdc39f791fd9d", size = 122057313, upload-time = "2026-07-08T16:03:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fa/c1c10b7aff4a9a3e8956d4f0a5f468fa6db7abc3208805719076772b4833/torch-2.13.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:33449899ce5496c1b84b4853179d94fd102028ae1407314d9fb956bb79e70d09", size = 111213743, upload-time = "2026-07-08T16:03:28.579Z" }, + { url = "https://files.pythonhosted.org/packages/11/18/9ecb37b56293a0be8d80f810bf672a72fe7e02f8b475d5ef1b9bf8a0d748/torch-2.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e09d6a722504957c694faceca843acde562786df1144ebcc5a74075ec7f6005", size = 427213008, upload-time = "2026-07-08T16:03:44.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/7c50ba1b7b713d71d34669c6d13dab0a11531a3eceb0307a5162dbfec0f7/torch-2.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:a3a9a21312872af8a26950b2c15680335a386a1f56ed03e780653d78b9607e9e", size = 526602329, upload-time = "2026-07-08T16:03:12.649Z" }, + { url = "https://files.pythonhosted.org/packages/91/3d/e7adcc6aaf36961cd18f56cf8ad0f3058c3a5c84ccf391762176c94581b8/torch-2.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:49b58f1e2c52440abb6f17c28f0335fe6c6d01ad1a7f55b0183b81e4b34d64e6", size = 122057920, upload-time = "2026-07-08T16:03:01.808Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0+cpu" +source = { registry = "https://download.pytorch.org/whl/cpu" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'darwin' and sys_platform != 'emscripten'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'emscripten'", +] +dependencies = [ + { name = "filelock", marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "fsspec", marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "jinja2", marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "networkx", marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "setuptools", marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "sympy", marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-extensions", marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:6e9817dbdf5ea76789babd46e457eac5bf14ff566cf85f8addbfdff2d56601ce", upload-time = "2026-07-08T19:27:52Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:84453b69508ec79902f899c5ed9495acb9e2bbe9fda5f1d5d6f19e3c3842e1a7", upload-time = "2026-07-08T19:28:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6746dbcbeb526eb61330b76b41ff1b4eb848951103a892eeb080dfa2b264667b", upload-time = "2026-07-08T19:28:16Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:10717d8b3b67c45a4788bf7ffc0bab1ea1e5ebbedd24466be6100102d141fac1", upload-time = "2026-07-08T19:28:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:2b3d093abd919ad934c43d47e73ba63ceba7cbd7269fc2e9c1e4fc29e8fe45fa", upload-time = "2026-07-08T19:28:33Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:ffadde149901c8afa138daa38d898264003cfcf1a3336ca5cd964b5af227d867", upload-time = "2026-07-08T19:28:41Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:6f307c2c32d764ffc6ff6893b801fad6d4752f3e67966cb8abf1843427c02604", upload-time = "2026-07-08T19:28:51Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4ca4a9394b0c771238a4f73590fdbbc4debad85ed0fa63d026ae1b085da7d6e2", upload-time = "2026-07-08T19:29:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:a8b450c1e58e5800e5b4691dac412f8d2d65a1dc3298166f91596603a3531e6f", upload-time = "2026-07-08T19:29:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:fa0762705b933624d59f6823db9ce7ec2e35b3e1e9c319c9db51fbeecfc3e319", upload-time = "2026-07-08T19:29:21Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:966d020354f465672dc7dd10d3a5c6cd17d7eb48620aa1d265b48a1f78f06898", upload-time = "2026-07-08T19:29:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:0b8f7d0423027ae8b90c7977c627f3379f325363a08224dffad9b4b2d684a83d", upload-time = "2026-07-08T19:29:40Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3fbf9c9d1f3c10c2d59d04aca426dee9ccc6ceb32d255c61e93acc3b4f75fae6", upload-time = "2026-07-08T19:29:54Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:a17ff48608634db245e17e8bb00a9558554a49aeb1e4f5fe6cd039af2a10515b", upload-time = "2026-07-08T19:30:05Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:ac7aaf322be4777765a53bed7264a214dd81b3a1d276b93150515a3c5f75e4b0", upload-time = "2026-07-08T19:30:12Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0+cu126" +source = { registry = "https://download.pytorch.org/whl/cu126" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten'", +] +dependencies = [ + { name = "cuda-bindings", version = "12.9.7", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "cuda-toolkit", version = "12.6.3", source = { registry = "https://pypi.org/simple" }, extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "filelock", marker = "extra == 'extra-8-drevalpy-cu126' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "fsspec", marker = "extra == 'extra-8-drevalpy-cu126' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "jinja2", marker = "extra == 'extra-8-drevalpy-cu126' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "networkx", marker = "extra == 'extra-8-drevalpy-cu126' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cudnn-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cusparselt-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nccl-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nvshmem-cu12", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "setuptools", marker = "extra == 'extra-8-drevalpy-cu126' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "sympy", marker = "extra == 'extra-8-drevalpy-cu126' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "triton", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-extensions", marker = "extra == 'extra-8-drevalpy-cu126' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.13.0%2Bcu126-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:83d4559903f2d04e9900ce0f3ca58b8659f9e74607dc27ba50aa8658c9a854fb", upload-time = "2026-07-08T19:37:25Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.13.0%2Bcu126-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:0f4e49e334e24b552f694f6315e0676fb3f816fb0f727871b9c6d1f73784cc25", upload-time = "2026-07-08T19:38:03Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.13.0%2Bcu126-cp311-cp311-win_amd64.whl", hash = "sha256:8095729db14e7fd5178a39676fdd679208eff4041407ea34e3d898336c90f5c5", upload-time = "2026-07-08T19:40:16Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.13.0%2Bcu126-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:24b2f3b177b3a715c30ab893a72db08dde2c55d070339d661347873b18adaa03", upload-time = "2026-07-08T19:42:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.13.0%2Bcu126-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8695f3c6b7966d44560275b90c5c28e5091ba33ddbb1ab33b2173782ca1e9145", upload-time = "2026-07-08T19:42:50Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.13.0%2Bcu126-cp312-cp312-win_amd64.whl", hash = "sha256:380081ea098bf2b9e727aa85205d94790d884d17c62df3bb00a4f6a1047010a2", upload-time = "2026-07-08T19:44:46Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.13.0%2Bcu126-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b2618235bacb29eea37357ce660aab5296d9729eacfd4629d50327382da11f7f", upload-time = "2026-07-08T19:46:32Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.13.0%2Bcu126-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4198c8d7478ab47ad2569309387d88b21fb553a1cf8ab06260fbd5a6ab9b9712", upload-time = "2026-07-08T19:47:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cu126/torch-2.13.0%2Bcu126-cp313-cp313-win_amd64.whl", hash = "sha256:cb91b2f91d053bb91e413459a13ec8b036ff348d03e411e9c29988cc6edb7b32", upload-time = "2026-07-08T19:48:48Z" }, +] + +[[package]] +name = "torch" +version = "2.13.0+cu130" +source = { registry = "https://download.pytorch.org/whl/cu130" } +resolution-markers = [ + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten'", +] +dependencies = [ + { name = "cuda-bindings", version = "13.3.1", source = { registry = "https://pypi.org/simple" }, marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "cuda-toolkit", version = "13.0.3.0", source = { registry = "https://pypi.org/simple" }, extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "filelock", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "fsspec", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "jinja2", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "networkx", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cudnn-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-cusparselt-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nccl-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "nvidia-nvshmem-cu13", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "setuptools", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "sympy", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "triton", marker = "(sys_platform == 'linux' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "typing-extensions", marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.13.0%2Bcu130-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b8a6b58c0176dd532254f6622fb1dffef6b38ad7cb67fcd0beb673066c8c710c", upload-time = "2026-07-08T20:20:24Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.13.0%2Bcu130-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:794664c05a3470a5e738447b54495cc6bbf1efca48bf2794270a897d9f4356c4", upload-time = "2026-07-08T20:21:04Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.13.0%2Bcu130-cp311-cp311-win_amd64.whl", hash = "sha256:45e97bd9bc0416f4f4190b5098c55119a389fa5a7c8bbf2639f08f1d04e0a0dc", upload-time = "2026-07-08T20:22:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.13.0%2Bcu130-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5ebd552c887e707c8e64927aceb8377ca2e81588c4e7494bcd23cb8ac0aca14d", upload-time = "2026-07-08T20:24:19Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.13.0%2Bcu130-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8db7338e6895c3d4bd89a02ff4209507d1f0cf2ffeb3b898538b5a07d1ea8c1e", upload-time = "2026-07-08T20:24:52Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.13.0%2Bcu130-cp312-cp312-win_amd64.whl", hash = "sha256:2efab1e83604ca628c6d85b9e188c153690980498d1297081a9dad704919303c", upload-time = "2026-07-08T20:26:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.13.0%2Bcu130-cp313-cp313-manylinux_2_28_aarch64.whl", upload-time = "2026-07-08T11:19:11Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.13.0%2Bcu130-cp313-cp313-manylinux_2_28_x86_64.whl", upload-time = "2026-07-08T11:19:18Z" }, + { url = "https://download-r2.pytorch.org/whl/cu130/torch-2.13.0%2Bcu130-cp313-cp313-win_amd64.whl", upload-time = "2026-07-08T11:19:21Z" }, +] + +[[package]] +name = "torch-geometric" +version = "2.8.0.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "numpy" }, + { name = "psutil" }, + { name = "pyparsing" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/2c/bfcb404c448f7c347cea7b956d3c7e92d53e36c8db2010eb1bdc1b937bfb/torch_geometric-2.8.0.post1.tar.gz", hash = "sha256:2c0c81666ec10f2132f6b4e0b6c57fc813f2c9f6b57599d7b7a799c614c22fbd", size = 928666, upload-time = "2026-07-20T20:44:40.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/5c/edf74a71249ad19aa0390fb97ff021e2a5b04ce23252730014e1feac957e/torch_geometric-2.8.0.post1-py3-none-any.whl", hash = "sha256:5d9841cbfa64eadc425e445767127d103dd52fdc5890548c6364986c9bc78029", size = 1325397, upload-time = "2026-07-20T20:44:38.789Z" }, +] + +[[package]] +name = "torchmetrics" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightning-utilities" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform == 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "torch", version = "2.13.0", source = { registry = "https://pypi.org/simple" }, marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra != 'extra-8-drevalpy-cu126' and extra != 'extra-8-drevalpy-cu130')" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(sys_platform != 'darwin' and extra == 'extra-8-drevalpy-cpu') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "torch", version = "2.13.0+cu126", source = { registry = "https://download.pytorch.org/whl/cu126" }, marker = "extra == 'extra-8-drevalpy-cu126' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "torch", version = "2.13.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "(extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra != 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130') or (extra != 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/34/39b8b749333db56c0585d7a11fa62a283c087bb1dfc897d69fb8cedbefb1/torchmetrics-1.9.0.tar.gz", hash = "sha256:a488609948600df52d3db4fcdab02e62aab2a85ef34da67037dc3e65b8512faa", size = 581765, upload-time = "2026-03-09T17:41:22.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/a2/c7f6ebf546f8f644edf0f999aa98ece106986a77a7b922316bf6414ff825/torchmetrics-1.9.0-py3-none-any.whl", hash = "sha256:bfdcbff3dd1d96b3374bb2496eb39f23c4b28b8a845b6a18c313688e0d2d9ca1", size = 983384, upload-time = "2026-03-09T17:41:19.756Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + +[[package]] +name = "tqdm" +version = "4.69.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/69/40407dfc835517f058b603dbf37a6df094d8582b015a51eddc988febbcb7/tqdm-4.69.0.tar.gz", hash = "sha256:700c5e85dcd5f009dd6222588a29180a193a748247a5d855b4d67db93d79a53b", size = 792569, upload-time = "2026-07-17T18:09:06.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/21/99a0cdaf54eb35e77623c41b5a2c9472ee4404bba687052791fe2aba6773/tqdm-4.69.0-py3-none-any.whl", hash = "sha256:9979978912be667a6ef21fd5d8abf54e324e63d82f7f43c360792ebc2bc4e622", size = 676680, upload-time = "2026-07-17T18:09:04.172Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "transformers" +version = "5.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/fb/2a2ba88f325e68a921d8b69ff63b477830b2e73ade9a3c8c8cab2f06d741/transformers-5.14.1.tar.gz", hash = "sha256:60d196c27781eacf8637e2b533f517582907ad6f9ae142046d6b69431a5b2173", size = 9295927, upload-time = "2026-07-16T09:41:57.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/67/8d85ca2323233ae3c0365a659c4e52ee1f587b440e4bc577e7d8e4416d0f/transformers-5.14.1-py3-none-any.whl", hash = "sha256:9db974c4079ede2d1a3ea7ca5a240df33f2cc26fc2b36ba64c5f2a4f43b6e725", size = 11625234, upload-time = "2026-07-16T09:41:54.143Z" }, +] + +[[package]] +name = "triton" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/f9/19d842d06a08559534fa1eaab6ca551b1bcf40f06620bddec1babaa2772d/triton-3.7.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a0e1cd4c4a76370ed74a8432a53cea28716827d19e40ffc732233e35ceb3f6", size = 184664887, upload-time = "2026-06-17T20:03:42.913Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5e/fce69606f7f240297f163e25539906732b199530d486ce67ae319877e821/triton-3.7.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6744957e9fd610a29680ec2346057d0c86948ed3812468670719f391e94b44a5", size = 197701306, upload-time = "2026-06-17T19:53:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/f856e24deb462d5f18bd4b5a746957862ab9b6ee5834bda60605ec348366/triton-3.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9497f2e696ee368862a181a90b2dcc03ca978cc4f602abd67c7d81022a6988e1", size = 184692359, upload-time = "2026-06-17T20:03:48.288Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6f/fb96d15db6f36d6eae4cafb998c2e0353bf59d7c4ea1662d7497f269134a/triton-3.7.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e40869937a68206ec70d7f25bb7ec6433cb083f9135e1f36dbd318dc449a728", size = 197719725, upload-time = "2026-06-17T19:53:20.419Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/c5089d4d9327fcd1e862c599cc2927f39418f84dd11a84cb2ccff9d4787a/triton-3.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdbfc09d9ec58bc5e68321525653220de7515c199e7a8097a97c85e62b52cd0a", size = 184694629, upload-time = "2026-06-17T20:03:53.444Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/2c3ac59253ae8892b6f307875263dd23dc875cdf732d3aea40d6d41fb7cb/triton-3.7.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58c0e131da05134a2a4788ccbcc0c1105cf0f54c8e98f19e34cd465396dc15eb", size = 197729241, upload-time = "2026-06-17T19:53:27.801Z" }, +] + +[[package]] +name = "ty" +version = "0.0.62" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/2c/ecf9d6a7456e64ea9114d21e68edaea77ecda9151cc89dfd3ed0b673e4a9/ty-0.0.62.tar.gz", hash = "sha256:145b6feba4d5f38b6d595eb41f7a8ec1c970a0a83b79a70680e9e3b787a3e381", size = 6271717, upload-time = "2026-07-22T01:02:43.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/ab/cb629d65b4437b298214a095488a8d84ec349f225ed84da13763adfce08b/ty-0.0.62-py3-none-linux_armv6l.whl", hash = "sha256:3db167f0aef29e7c34d47414c9ce160fd045e9b2d6a0edd08d0331b4eaa3b9ad", size = 12045218, upload-time = "2026-07-22T01:02:04.497Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/f6555aa3b0c46bf2a38891a2048c45616e836a6aab96dff847ab01cc83b3/ty-0.0.62-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aab3f1d6ca4c9ad1869e57164478694d9828ad1dfcadc192ece7215a20859197", size = 11688585, upload-time = "2026-07-22T01:02:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/56/25/9f4a322c64ac8a4f2d1593749bb5932c5c964e557fe7788116fa029b2463/ty-0.0.62-py3-none-macosx_11_0_arm64.whl", hash = "sha256:69ce10b241f487b69ccdc36ca4ac4f4ba683172d24594b5d7296a66f7e26d15c", size = 11204179, upload-time = "2026-07-22T01:02:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/ef/db/d9cf7d5343ff896c2d780caec3dcd0ee77493d8a4140b8aa49e15824abff/ty-0.0.62-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd736420acb009d00a2379e47be4085dd3e3cdbe5305bdf5acce0dd9082f1bbb", size = 11745874, upload-time = "2026-07-22T01:02:11.507Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/a575008c2ccdaacaf1c358be52087db449ea73a78c91c8afb30084d64cfc/ty-0.0.62-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bca9a80e2e5fb72e88e31cf662aae3bd8d7e9e0005ab517a89c368267c992387", size = 11875688, upload-time = "2026-07-22T01:02:14.072Z" }, + { url = "https://files.pythonhosted.org/packages/df/92/43a6250b52a704169b6d864665f286f71588d95ff645941cc0e4ca026ab7/ty-0.0.62-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f8d2a35940bbdea1d28c36c83d3eaba08bf47990486a066b9cb3ae4fbb8625e", size = 12484062, upload-time = "2026-07-22T01:02:16.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/34/35c698baa067a2543a32cd7237ad9bdc63dec1ce8b5b62b4686b4485fec4/ty-0.0.62-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f8257481a517f05e0621ef3b4ef4089a38ac03f17aa800c9f672df19fd03b3d", size = 13007869, upload-time = "2026-07-22T01:02:18.145Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2c/e9dd785d62bcfeb9285cc030495288640f1942bf3228a791a3a30d72f7b4/ty-0.0.62-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b907322a2cecd3e25902878997d08de57cf0efbb27833f8b3378fbd4f218590", size = 12675651, upload-time = "2026-07-22T01:02:20.299Z" }, + { url = "https://files.pythonhosted.org/packages/36/86/5650d8b70bd80946afdb3aaefedd280a698b82d9066f18f8a47f58642230/ty-0.0.62-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac8b70f0b5494feed0a3cf47af579141e4c2e88faffcaeebcb4176e914b54b6a", size = 12324208, upload-time = "2026-07-22T01:02:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/38/2d/278a17a0f47a90804a744e7d7a598c47fbc15a357ad5aaff563fee3bc65a/ty-0.0.62-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:059790546f9304a750f28ded9b964a7a27dec88e725d329a4b9ab5d944e83dd6", size = 12616124, upload-time = "2026-07-22T01:02:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/1a/51/62741847bd646f8702b9ce61de496804a8ac52055afab8dbb94aaf64f431/ty-0.0.62-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9e83bc6d59e2b37a1d8923ffb7e2455fd35ad35821a3dc69c8459ca9cd28724", size = 11695150, upload-time = "2026-07-22T01:02:27.17Z" }, + { url = "https://files.pythonhosted.org/packages/0c/0d/74bd23f94a527bdc32d8366f51db157f75a3074e320c567a2eae83bd6d21/ty-0.0.62-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f84ac4b606c3b4275a17f7b2f60cb0ca8216972bb7af2f515383ee6e4b1fb7f1", size = 11884562, upload-time = "2026-07-22T01:02:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/dd/0e/2f42cc81436b5dc0070b4e353c74cbe94ce91ef5828753db921088dec500/ty-0.0.62-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1c05f8f9cd720fa42744574bf3bda9b1b676f7876e33324b03b21aad81f651df", size = 12071773, upload-time = "2026-07-22T01:02:31.292Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/0bd3c0069524bf7d5e610d92dd0698414372015461507280c8787c9cd25b/ty-0.0.62-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:88bef93008228890e990fe9e87f0504b8dea9f9f24534eb3a5ee9d8ab9e5cd4f", size = 12425247, upload-time = "2026-07-22T01:02:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/3f/27/08505fa0d2bef8fe4107753bb02ed2da4e323103ff5d423b9cc9eea4346e/ty-0.0.62-py3-none-win32.whl", hash = "sha256:254690c758e9d0ee2c8dfe07320d4e9d54709d5e95fa588ecf5a96391dc8d8d4", size = 11381070, upload-time = "2026-07-22T01:02:36.625Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c1/609840f13d1c48e88e5b431fe789650da14cb25da0df2979fe08ce422e73/ty-0.0.62-py3-none-win_amd64.whl", hash = "sha256:d88c2594e6a33c5f859c1d9016ad6137ec304c5d51422ce48894d2b9393956a7", size = 12415015, upload-time = "2026-07-22T01:02:39.193Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/43f838fa38922f175a223814120b5faeb1bb6e5ef1533e45f543c0f510b8/ty-0.0.62-py3-none-win_arm64.whl", hash = "sha256:11b5e7df21890bef3eda49ae15a0c4d12554917ec4ea90ea985caa33241af627", size = 11773787, upload-time = "2026-07-22T01:02:41.564Z" }, +] + +[[package]] +name = "typeguard" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "universal-pathlib" +version = "0.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec" }, + { name = "pathlib-abc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/6e/d997a70ee8f4c61f9a7e2f4f8af721cf072a3326848fc881b05187e52558/universal_pathlib-0.3.10.tar.gz", hash = "sha256:4487cbc90730a48cfb64f811d99e14b6faed6d738420cd5f93f59f48e6930bfb", size = 261110, upload-time = "2026-02-22T14:40:58.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/1a/5d9a402b39ec892d856bbdd9db502ff73ce28cdf4aff72eb1ce1d6843506/universal_pathlib-0.3.10-py3-none-any.whl", hash = "sha256:dfaf2fb35683d2eb1287a3ed7b215e4d6016aa6eaf339c607023d22f90821c66", size = 83528, upload-time = "2026-02-22T14:40:57.316Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[[package]] +name = "vulture" +version = "2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, +] + +[[package]] +name = "wandb" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/fb/8d3f96a8b143060d6fa145462d0785981373e04694e4152555ccb5d23939/wandb-0.28.1.tar.gz", hash = "sha256:870ccb1a01238b0ac07c6fd96a0810a1f79090aba04ea29f4ee012ac8327705d", size = 40578119, upload-time = "2026-07-16T18:47:05.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/21/8df50164d07623cfcefec19bbf9327d9be84b637a827cea1f0c06db005fd/wandb-0.28.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:da909a76e65c64c0d93acc485d2a19f66e336f1e3f725f1c98a070883e084943", size = 24277925, upload-time = "2026-07-16T18:46:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/6c3da7e6cb215ad363324db8dc4d83b93626f5e339822b05b1c38a6097fd/wandb-0.28.1-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:3da3db219c54bfd1082c00e9061c8ea894ba43e42733b5af00bb10c09d7158fe", size = 25480852, upload-time = "2026-07-16T18:46:45.102Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1a/d15bcfb4417fa69edcaa33db8ea012db733da1057e193b047e3f69fdd671/wandb-0.28.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:ae9ae6fb29e2e2b1d097ed8b75c0c0240c778c2a8cad1d996dee870a1e401c2c", size = 24832138, upload-time = "2026-07-16T18:46:47.433Z" }, + { url = "https://files.pythonhosted.org/packages/b3/da/49924c7df2952dfd82c86c3779c339c0c3d6f6439387c03d97d0470c3658/wandb-0.28.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8cfb898b6a6c884d9c9294b02764e88bce65049f027a124d6bee53fe722469b6", size = 26486533, upload-time = "2026-07-16T18:46:49.839Z" }, + { url = "https://files.pythonhosted.org/packages/11/c0/06b23518e29690784f1b3081e39c7679ca076cb0af094cb9b4bb309150f5/wandb-0.28.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cf2b1533945395e4fdbe6182b272bb0ca8a02c10b3086a395e2d57686ae3ed0d", size = 25022635, upload-time = "2026-07-16T18:46:52.376Z" }, + { url = "https://files.pythonhosted.org/packages/23/30/6de2f7995a8a6eecbd03d24c79a139a734c0168f5520cf4c7ccb43c1dbbc/wandb-0.28.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7233061080507a4b4098bed1ccb381ce6f890c60397cd4153d060285bcb267bd", size = 27008895, upload-time = "2026-07-16T18:46:55.025Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/49deab9447687625371435ca21b6da82f223f1c7d014d77386b9cb91833c/wandb-0.28.1-py3-none-win32.whl", hash = "sha256:4bc461cda3ce23a19d8df5e42981a664d95fa3231efb10fd1e85d9d4824c7d29", size = 24418398, upload-time = "2026-07-16T18:46:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/ed6616b11ea15b8ceabedcaa567286c1c9ec65fa50563230a90bfb627cc5/wandb-0.28.1-py3-none-win_amd64.whl", hash = "sha256:d98a10370162b1e970850237114c56e9c4c58f3cb701e4b8cb38f36f6749fd52", size = 24418404, upload-time = "2026-07-16T18:47:00.427Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/75b6827a6665337a715c5347c5edbd84eca660f7a0f48d8d6d24d1f66bee/wandb-0.28.1-py3-none-win_arm64.whl", hash = "sha256:4aa07f13dd3bcac2c0524c8d0f49f76e83ab5c1054fd09f3b1a436cfcde146a6", size = 22299006, upload-time = "2026-07-16T18:47:02.71Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, + { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, + { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, + { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +] + +[[package]] +name = "xdoctest" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/df/1b5751e3546967a4884cff6ea743b1e457d3f7c94fc35913f149a85734fc/xdoctest-1.3.2.tar.gz", hash = "sha256:bf6078c4fc0d60aafd8753fccdc435c95f26a640508e606d188d96f48359f0aa", size = 209923, upload-time = "2026-03-27T01:13:25.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/1d/6b9cf46122f2f07eab95c4af29ab5e29bd6674147a0b1e9a6d3929870af1/xdoctest-1.3.2-py3-none-any.whl", hash = "sha256:052118c8efb2b4cfb54485d328915b9e7b44da37c64b0998ca6aa21193dcb601", size = 146469, upload-time = "2026-03-27T01:13:23.417Z" }, +] + +[[package]] +name = "xgboost" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu126') or (extra == 'extra-8-drevalpy-cpu' and extra == 'extra-8-drevalpy-cu130') or (extra == 'extra-8-drevalpy-cu126' and extra == 'extra-8-drevalpy-cu130')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/bb/1eb0242409d22db725d7a88088e6cfd6556829fb0736f9ff69aa9f1e9455/xgboost-3.2.0.tar.gz", hash = "sha256:99b0e9a2a64896cdaf509c5e46372d336c692406646d20f2af505003c0c5d70d", size = 1263936, upload-time = "2026-02-10T11:03:05.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/49/6e4cdd877c24adf56cb3586bc96d93d4dcd780b5ea1efb32e1ee0de08bae/xgboost-3.2.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:2f661966d3e322536d9c448090a870fcba1e32ee5760c10b7c46bac7a342079a", size = 2507014, upload-time = "2026-02-10T10:50:57.44Z" }, + { url = "https://files.pythonhosted.org/packages/93/f1/c09ef1add609453aa3ba5bafcd0d1c1a805c1263c0b60138ec968f8ec296/xgboost-3.2.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:eabbd40d474b8dbf6cb3536325f9150b9e6f0db32d18de9914fb3227d0bef5b7", size = 2328527, upload-time = "2026-02-10T10:51:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/96/9f/d9914a7b8df842832850b1a18e5f47aaa071c217cdd1da2ae9deb291018b/xgboost-3.2.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:852eabc6d3b3702a59bf78dbfdcd1cb9c4d3a3b6e5ed1f8781d8b9512354fdd2", size = 131100954, upload-time = "2026-02-10T11:02:42.704Z" }, + { url = "https://files.pythonhosted.org/packages/79/98/679de17c2caa4fd3b0b4386ecf7377301702cb0afb22930a07c142fcb1d8/xgboost-3.2.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:99b4a6bbcb47212fec5cf5fbe12347215f073c08967431b0122cfbd1ee70312c", size = 131748579, upload-time = "2026-02-10T10:54:40.424Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1661dd114a914a67e3f7ab66fa1382e7599c2a8c340f314ad30a3e2b4d08/xgboost-3.2.0-py3-none-win_amd64.whl", hash = "sha256:0d169736fd836fc13646c7ab787167b3a8110351c2c6bc770c755ee1618f0442", size = 101681668, upload-time = "2026-02-10T10:59:31.202Z" }, +] + +[[package]] +name = "xxhash" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/5a/05eaa129555f85476a3e16ff869e95f81a78bbe4647eef9d0229f515a317/xxhash-3.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602efcad4a42c184e81d43a2b7e6e4f524d619878f2b6ee2ba469011f47c8147", size = 34699, upload-time = "2026-07-06T10:44:10.14Z" }, + { url = "https://files.pythonhosted.org/packages/80/59/0df1133958b2228929355e022aab1e958c7b2c43e27bf7f59bc9edfa8a54/xxhash-3.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:131324f719957b988861714de7d6ddf57b47abec3b0cc691302ffeaba0e05e10", size = 32373, upload-time = "2026-07-06T10:44:11.353Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bf/1cfda5b5e6bf26617812b4a31662ef2220d2ad04e0a55b8ff9eb36e56a5c/xxhash-3.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db77278a6eddadbf44ce5aae2fee5ebb4d061f026b1ce2130d058cd4d7a7b670", size = 220284, upload-time = "2026-07-06T10:44:12.683Z" }, + { url = "https://files.pythonhosted.org/packages/70/93/45dc0ad7913b69e5b08bd039236cf628380e4c9cc76a8a4c6625a328e058/xxhash-3.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c332dd48b8cb050da2bb2a3c96d72b1664168650a250ef9718e423df7989e05", size = 240980, upload-time = "2026-07-06T10:44:14.297Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/f28ba7d17f2c1410ee397982c817ab1bd5b2701070c2d2c373539aad000a/xxhash-3.8.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a5cd96f6dcdf4fa657b2d95668d71d58455248f98712ecffaa9c528edf40ccae", size = 264526, upload-time = "2026-07-06T10:44:16.017Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/f10651cec2c7981b20d693deae6bdfc438427d92be2db4ccabb6181f0021/xxhash-3.8.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c959f88160b13b4e730b0d75b459b7929fc0d2225c284c9683ac95d6feeeac6a", size = 241369, upload-time = "2026-07-06T10:44:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/136e0cbaf5db51e191423b1c98643593189f02b6cd90837bf64b19113d70/xxhash-3.8.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:027dee4355f3fcc41481650d846cf6cfc895c85a1ab7acd063063821a0df5b4c", size = 473186, upload-time = "2026-07-06T10:44:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3f/6aa808a96bdc43dba9a740dec56c744526ee3c0019e32c75e810fa90ae4d/xxhash-3.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad52a0e4bcc0ba956a953a169d1feec2734a64981d689e4fc8f490f7bf91af60", size = 220092, upload-time = "2026-07-06T10:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/47/28/a8675e78a9ced96dab853416162268e10e05b452e95db7888cf69f58ac5f/xxhash-3.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d3dfb1f0ff146da7952867a9414f0c7a29762f8825a84879592612fd6139342", size = 309846, upload-time = "2026-07-06T10:44:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/0f/7fe4d4ef4e69f0033e012396ee2a115886bca7b10b7e45ce398626436bfc/xxhash-3.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4482380b462ca9e59994d072a877ecadd1cf51102daeeab2db696f96ab763723", size = 237659, upload-time = "2026-07-06T10:44:24.135Z" }, + { url = "https://files.pythonhosted.org/packages/38/8f/83e9e31d4ed57fe963b99cb5b13a23e3e0f0dad1885aa0ebd2a7819dd423/xxhash-3.8.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:950ac754d16daea42038f38e7465eb84cda4d08d7343c1c915771b29470f065a", size = 268737, upload-time = "2026-07-06T10:44:25.875Z" }, + { url = "https://files.pythonhosted.org/packages/57/79/7e7de46dbe5d1f49afc96a0bc42e6b8df24eae3d6bad6007b99e42f48430/xxhash-3.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0418ec8b2331b9d4d575fc9284427e8e69449d7172e99e1a86fcdd1f51a0a937", size = 224955, upload-time = "2026-07-06T10:44:27.777Z" }, + { url = "https://files.pythonhosted.org/packages/ec/34/b8540839e958d5ef5c6101af6f16032109e7099698ae8edbc8dcefe4d8f4/xxhash-3.8.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:32a94ad2763e0263d9102037d349002c3d3c401e42770542c3eeb4801f311661", size = 239653, upload-time = "2026-07-06T10:44:29.422Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/a735d05f7f859354acadabe470ff40e2c46672275f96dcf096a761904def/xxhash-3.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:89b11a5cdd441aa463f6d34ca0241602bc09b001a76994b6059828494108c673", size = 300213, upload-time = "2026-07-06T10:44:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/3e1cb020237b68117fc212dc5f9753b87f865b4dfee7c1ce62d0836955b5/xxhash-3.8.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:09a204dd4bb0823daf938cdd0dc8057d5f1e14fe3cbde929424255f23f9de872", size = 442508, upload-time = "2026-07-06T10:44:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/f80090622141cc734b039ce1d15ce3ff6dced375e9680249bf5b9b8c6bf9/xxhash-3.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e710ad822c493fb80a4fbc1e3d0a807b1422cb90adbe64378f98291b7fa48fef", size = 216853, upload-time = "2026-07-06T10:44:34.983Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a3/60157acecc307b238d3651c2483168e224b48b23a36ae6d6903588341d80/xxhash-3.8.1-cp311-cp311-win32.whl", hash = "sha256:5013be3bea7612852c62a7437f3302c1cfb91ca7e703b194459db0b2b2e0d792", size = 31936, upload-time = "2026-07-06T10:44:36.542Z" }, + { url = "https://files.pythonhosted.org/packages/59/5c/ef70c418d878d187b8da56d4cdc06aea6cf5e456b301e96e51e1d2cc8625/xxhash-3.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:f377012b86c0a23a1df0cf5a1b05aa7187649e472f71c7892e5f2c2815bbe74f", size = 32724, upload-time = "2026-07-06T10:44:38.177Z" }, + { url = "https://files.pythonhosted.org/packages/2c/25/f008db952cec6b2a26445b456eeed2ebebd65e08e848ebe09ed6ac0634e6/xxhash-3.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:836f11d4474d3228e9909d97216faa4f7505df41cfaf3927eb29809de785a78d", size = 29212, upload-time = "2026-07-06T10:44:39.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/91/f65c34a7aa7b4e7cf4854f8e6ef3f7ee32ceac41d4f008da0780db0612f6/xxhash-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc", size = 34680, upload-time = "2026-07-06T10:44:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/57/04/b10a245a4c09a9cfa88f8e9ae755029413ad1ac17047f9a61906e5ae0799/xxhash-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215", size = 32397, upload-time = "2026-07-06T10:44:42.196Z" }, + { url = "https://files.pythonhosted.org/packages/3a/75/45ab795b5945b6388583bd75202106af505537935566c15a1577797a0e08/xxhash-3.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478", size = 220549, upload-time = "2026-07-06T10:44:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/13/44/5ba2bd0a14ddf4193fc7d8ec29625f659f22c06d60b28f04bf46305d8330/xxhash-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc", size = 241186, upload-time = "2026-07-06T10:44:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/32/c4147def4d1e4538b906f82731e0ba23424377fc50a7cddd03cd284c8f63/xxhash-3.8.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8", size = 264852, upload-time = "2026-07-06T10:44:47.199Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bd/71ed14f4f0318bb7fd7b2ec51999413487fa8da8d41208e84d50d1ef0f98/xxhash-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56", size = 242663, upload-time = "2026-07-06T10:44:48.846Z" }, + { url = "https://files.pythonhosted.org/packages/91/09/70af22c565a8473b3f2ae73f88e7721af281bc4a575236dbd1970c9f76f6/xxhash-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170", size = 473510, upload-time = "2026-07-06T10:44:50.695Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc", size = 220469, upload-time = "2026-07-06T10:44:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/9a184f615fa5a4dce30c01534f62946ce5a11ce40f73785cbd356ccabaa9/xxhash-3.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c", size = 310290, upload-time = "2026-07-06T10:44:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/a9/dc/9b9a9789011ee153723a5eb9e7dd7fcbae2ba9b3fe7a729249ca7c252056/xxhash-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d", size = 238173, upload-time = "2026-07-06T10:44:55.693Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/71c6005ada9dcb608a4e1902e8475ecadb5f3fbfa04e1e244d276a2d0c43/xxhash-3.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb", size = 269026, upload-time = "2026-07-06T10:44:57.424Z" }, + { url = "https://files.pythonhosted.org/packages/2f/87/d6c036ba25dfbd9c8633be5aa86fc9474bbb9e2c68212a841d090abe7344/xxhash-3.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061", size = 224970, upload-time = "2026-07-06T10:44:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/48/62/4c1f035a41c5752aa05e195b6c904c07b94fe9061a16de61e72a6e6b135f/xxhash-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887", size = 240820, upload-time = "2026-07-06T10:45:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/d39d565069b87e86d21a2af2a31d04db79249d25aa8d5b62959056a89857/xxhash-3.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e", size = 300619, upload-time = "2026-07-06T10:45:02.716Z" }, + { url = "https://files.pythonhosted.org/packages/13/22/75467acc887edc8cf71c97ab1708feb3df7a88bda589b9f399765c6387d2/xxhash-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975", size = 443267, upload-time = "2026-07-06T10:45:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b6/1da3baa5fa6ef705e3425fddd382be7dfc4dfba2686df90a20f16e9c7b1b/xxhash-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a", size = 217338, upload-time = "2026-07-06T10:45:06.304Z" }, + { url = "https://files.pythonhosted.org/packages/78/dd/b5295a9f97484e7a1c2b283a742ca45e3104991c55a1ef670dde161829ba/xxhash-3.8.1-cp312-cp312-win32.whl", hash = "sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e", size = 31970, upload-time = "2026-07-06T10:45:07.823Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/3fa0b807d7e21515cd975e7fe5c039d52ac3e9401a96d6ad68dae6305215/xxhash-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724", size = 32741, upload-time = "2026-07-06T10:45:09.42Z" }, + { url = "https://files.pythonhosted.org/packages/b8/05/86feada74e239600e6875aa507afb40482a89b92700aa74a92da83bdcb77/xxhash-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396", size = 29234, upload-time = "2026-07-06T10:45:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913", size = 38562, upload-time = "2026-07-06T10:45:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec", size = 36656, upload-time = "2026-07-06T10:45:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297", size = 31169, upload-time = "2026-07-06T10:45:15.687Z" }, + { url = "https://files.pythonhosted.org/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed", size = 32177, upload-time = "2026-07-06T10:45:17.035Z" }, + { url = "https://files.pythonhosted.org/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276", size = 34642, upload-time = "2026-07-06T10:45:18.39Z" }, + { url = "https://files.pythonhosted.org/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0", size = 34684, upload-time = "2026-07-06T10:45:20.033Z" }, + { url = "https://files.pythonhosted.org/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315", size = 32401, upload-time = "2026-07-06T10:45:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907", size = 220617, upload-time = "2026-07-06T10:45:23.234Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0", size = 241295, upload-time = "2026-07-06T10:45:25.364Z" }, + { url = "https://files.pythonhosted.org/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2", size = 264688, upload-time = "2026-07-06T10:45:27.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329", size = 242740, upload-time = "2026-07-06T10:45:28.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0", size = 473599, upload-time = "2026-07-06T10:45:30.524Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437", size = 220559, upload-time = "2026-07-06T10:45:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62", size = 310383, upload-time = "2026-07-06T10:45:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf", size = 238228, upload-time = "2026-07-06T10:45:38.02Z" }, + { url = "https://files.pythonhosted.org/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf", size = 269137, upload-time = "2026-07-06T10:45:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3", size = 225068, upload-time = "2026-07-06T10:45:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104", size = 240874, upload-time = "2026-07-06T10:45:43.414Z" }, + { url = "https://files.pythonhosted.org/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e", size = 300702, upload-time = "2026-07-06T10:45:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893", size = 443351, upload-time = "2026-07-06T10:45:47.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3", size = 217396, upload-time = "2026-07-06T10:45:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a", size = 31974, upload-time = "2026-07-06T10:45:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55", size = 32739, upload-time = "2026-07-06T10:45:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a", size = 29239, upload-time = "2026-07-06T10:45:53.714Z" }, + { url = "https://files.pythonhosted.org/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc", size = 34964, upload-time = "2026-07-06T10:45:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92", size = 32697, upload-time = "2026-07-06T10:45:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75", size = 225954, upload-time = "2026-07-06T10:45:58.897Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda", size = 249776, upload-time = "2026-07-06T10:46:00.977Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629", size = 274776, upload-time = "2026-07-06T10:46:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068", size = 252056, upload-time = "2026-07-06T10:46:04.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1", size = 482108, upload-time = "2026-07-06T10:46:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647", size = 226739, upload-time = "2026-07-06T10:46:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1", size = 319658, upload-time = "2026-07-06T10:46:10.504Z" }, + { url = "https://files.pythonhosted.org/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113", size = 246059, upload-time = "2026-07-06T10:46:12.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9", size = 275535, upload-time = "2026-07-06T10:46:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa", size = 231292, upload-time = "2026-07-06T10:46:16.452Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31", size = 250490, upload-time = "2026-07-06T10:46:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6", size = 309861, upload-time = "2026-07-06T10:46:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398", size = 448739, upload-time = "2026-07-06T10:46:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838", size = 223657, upload-time = "2026-07-06T10:46:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22", size = 32377, upload-time = "2026-07-06T10:46:26.86Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629", size = 33274, upload-time = "2026-07-06T10:46:28.39Z" }, + { url = "https://files.pythonhosted.org/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65", size = 29375, upload-time = "2026-07-06T10:46:29.962Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/4d8040435aeac814fc69ba63621565fbeb19229a138e2568324a26b2a45c/xxhash-3.8.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:39c9d5b61508b0bb68f29e54546de0ed2a74943c6a18585535a7e37356f1dd12", size = 32687, upload-time = "2026-07-06T10:49:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/6a/975f1f2318c760e5bcec109ed379713ae645d8d856c2a3b9ec5d26857087/xxhash-3.8.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:83b9130b80b216d56fdf9e87131946b353c9627930c061955a101ea82b09fed9", size = 29879, upload-time = "2026-07-06T10:49:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/08/0b/40a2a55ff52cf635bfdc5eae67a772bec85b4f44c6c737f73f6f528d51d1/xxhash-3.8.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8304be0982130954b7fd3aad18e2c6f8ee40254bc3d2e635991c16d77c91e2bd", size = 43246, upload-time = "2026-07-06T10:49:47.905Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/56ed2b6b200f26fb474f3fd387d95d0601efcd5bb33430c90c68924bdd77/xxhash-3.8.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b512261801b1e5fde7b6ebf2fef7977339c620cbbca88a0040ad9ad134f4d02", size = 38202, upload-time = "2026-07-06T10:49:50.59Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/56864d895d1161a9f17502088e9c1fb7c06bde2c2efdde620d22bb7a9c43/xxhash-3.8.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49aa8692507835dcc1e8ad8021f20c74c2dc13d83b5112e87877faa2a0035b20", size = 34448, upload-time = "2026-07-06T10:49:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/6b/57/5c6e0908a47f61dca96d01c8ee6fce01ed1050611eb779083ba8758fed81/xxhash-3.8.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:345b07b78e2bf583d71682aa34ae5b5fab575f7a1cb31e10263ebbc6f89f8c42", size = 32869, upload-time = "2026-07-06T10:49:55.972Z" }, +] + +[[package]] +name = "xyzservices" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/08/3cb9f67a8d48021aca2a02292cc26eecd71d949ae70ad66420a8730cc302/xyzservices-2026.3.0.tar.gz", hash = "sha256:d226866a5d8e9fef337034d8da37a8298f0a1d9d1489b4018e69579eb321fea4", size = 1135736, upload-time = "2026-03-30T14:42:25.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a9/d23012099dc88ec69a29c6407b41d89681cb674c2043cd5b467c7e299c08/xyzservices-2026.3.0-py3-none-any.whl", hash = "sha256:503183d4b322bfebc3c50cdd21192aa3e81e36c5efbf9133d54ae82143e0576b", size = 94101, upload-time = "2026-03-30T14:42:24.608Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] + +[[package]] +name = "zarr" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "donfig" }, + { name = "google-crc32c" }, + { name = "numcodecs" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/5a/b8a0cf39a14c770c30bd1f2d120c54000c8cd9e84e8e79f38d9a7ce58071/zarr-3.1.6.tar.gz", hash = "sha256:d95e72cbea4b90e9a70679468b8266400331756232576ae2b43400ac5108d0eb", size = 386531, upload-time = "2026-03-23T17:25:18.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/7c/ba8ca8cbe9dbef8e83a95fc208fed8e6686c98b4719aaa0aa7f3d31fe390/zarr-3.1.6-py3-none-any.whl", hash = "sha256:b5a82c5079d1c3d4ee8f06746fa3b9a98a7d804300fa3f4be154362a33e1207e", size = 295655, upload-time = "2026-03-23T17:25:17.189Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]