diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 000000000..3994ec0a8 --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,4 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true)$ +ref-names: $Format:%D$ diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 5c6e03a2e..9521b7b68 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -22,12 +22,13 @@ jobs: fetch-depth: 0 - name: Set up conda environment - uses: mamba-org/provision-with-micromamba@v13 + uses: mamba-org/setup-micromamba@v1 with: environment-file: ci/environment.yml environment-name: flox-tests - cache-env: true - # extra-specs: | + init-shell: bash + cache-environment: true + # create-args: | # python="${{ matrix.python-version }}" # - name: Setup some dependencies diff --git a/.github/workflows/ci-additional.yaml b/.github/workflows/ci-additional.yaml index 605e20cf0..9449d9290 100644 --- a/.github/workflows/ci-additional.yaml +++ b/.github/workflows/ci-additional.yaml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 2 - - uses: xarray-contrib/ci-trigger@v1.1 + - uses: xarray-contrib/ci-trigger@v1.2 id: detect-trigger with: keyword: "[skip-ci]" @@ -53,14 +53,15 @@ jobs: echo "TODAY=$(date +'%Y-%m-%d')" >> $GITHUB_ENV - name: Setup micromamba - uses: mamba-org/provision-with-micromamba@34071ca7df4983ccd272ed0d3625818b27b70dcc + uses: mamba-org/setup-micromamba@v1 with: environment-file: ${{env.CONDA_ENV_FILE}} environment-name: flox-tests - extra-specs: | - python=${{env.PYTHON_VERSION}} - cache-env: true + init-shell: bash + cache-environment: true cache-env-key: "${{runner.os}}-${{runner.arch}}-py${{env.PYTHON_VERSION}}-${{env.TODAY}}-${{hashFiles(env.CONDA_ENV_FILE)}}" + create-args: | + python=${{ env.PYTHON_VERSION }} - name: Install flox run: | @@ -71,7 +72,15 @@ jobs: conda list - name: Run doctests run: | - python -m pytest --doctest-modules flox --ignore flox/tests + python -m pytest --doctest-modules flox --ignore flox/tests --cov=./ --cov-report=xml + - name: Upload code coverage to Codecov + uses: codecov/codecov-action@v3.1.4 + with: + file: ./coverage.xml + flags: unittests + env_vars: RUNNER_OS + name: codecov-umbrella + fail_ci_if_error: false mypy: name: Mypy @@ -94,15 +103,16 @@ jobs: run: | echo "TODAY=$(date +'%Y-%m-%d')" >> $GITHUB_ENV - name: Setup micromamba - uses: mamba-org/provision-with-micromamba@34071ca7df4983ccd272ed0d3625818b27b70dcc + uses: mamba-org/setup-micromamba@v1 with: environment-file: ${{env.CONDA_ENV_FILE}} - environment-name: xarray-tests - extra-specs: | - python=${{env.PYTHON_VERSION}} - cache-env: true + environment-name: flox-tests + init-shell: bash + cache-environment: true cache-env-key: "${{runner.os}}-${{runner.arch}}-py${{env.PYTHON_VERSION}}-${{env.TODAY}}-${{hashFiles(env.CONDA_ENV_FILE)}}" - - name: Install xarray + create-args: | + python=${{ env.PYTHON_VERSION }} + - name: Install flox run: | python -m pip install --no-deps -e . - name: Version info @@ -115,4 +125,13 @@ jobs: - name: Run mypy run: | - python -m mypy --install-types --non-interactive + python -m mypy --install-types --non-interactive --cobertura-xml-report mypy_report + + - name: Upload mypy coverage to Codecov + uses: codecov/codecov-action@v3.1.4 + with: + file: mypy_report/cobertura.xml + flags: mypy + env_vars: PYTHON_VERSION + name: codecov-umbrella + fail_ci_if_error: false diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7133527c0..75aafc4b6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - os: ["ubuntu-latest"] + os: ["ubuntu-latest", "windows-latest"] python-version: ["3.8", "3.10"] steps: - uses: actions/checkout@v3 @@ -34,22 +34,22 @@ jobs: run: | echo "PYTHON_VERSION=${{ matrix.python-version }}" >> $GITHUB_ENV - name: Set up conda environment - uses: mamba-org/provision-with-micromamba@v13 + uses: mamba-org/setup-micromamba@v1 with: environment-file: ci/environment.yml environment-name: flox-tests - cache-env: true - extra-specs: | - python="${{ matrix.python-version }}" + init-shell: bash + cache-environment: true + create-args: | + python=${{ matrix.python-version }} - name: Install flox run: | - python -m pip install -e . - conda list + python -m pip install --no-deps -e . - name: Run Tests run: | pytest -n auto --cov=./ --cov-report=xml - name: Upload code coverage to Codecov - uses: codecov/codecov-action@v3.1.1 + uses: codecov/codecov-action@v3.1.4 with: file: ./coverage.xml flags: unittests @@ -78,38 +78,28 @@ jobs: with: fetch-depth: 0 # Fetch all history for all branches and tags. - name: Set up conda environment - uses: mamba-org/provision-with-micromamba@v13 + uses: mamba-org/setup-micromamba@v1 with: - environment-file: ci/${{ matrix.env }}.yml + environment-file: ci/environment.yml environment-name: flox-tests - cache-env: true - extra-specs: | - python="${{ matrix.python-version }}" + init-shell: bash + cache-environment: true + create-args: | + python=${{ matrix.python-version }} - name: Install flox run: | python -m pip install --no-deps -e . - name: Run tests run: | - python -m pytest -n auto - - upstream-dev: - name: upstream-dev - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - steps: - - uses: actions/checkout@v3 - - name: Set up conda environment - uses: mamba-org/provision-with-micromamba@v13 + python -m pytest -n auto --cov=./ --cov-report=xml + - name: Upload code coverage to Codecov + uses: codecov/codecov-action@v3.1.4 with: - environment-file: ci/upstream-dev-env.yml - environment-name: flox-tests - extra-specs: | - python="3.10" - - name: Run Tests - run: | - pytest -n 2 + file: ./coverage.xml + flags: unittests + env_vars: RUNNER_OS + name: codecov-umbrella + fail_ci_if_error: false xarray-groupby: name: xarray-groupby @@ -123,13 +113,14 @@ jobs: repository: 'pydata/xarray' fetch-depth: 0 # Fetch all history for all branches and tags. - name: Set up conda environment - uses: mamba-org/provision-with-micromamba@v13 + uses: mamba-org/setup-micromamba@v1 with: environment-file: ci/requirements/environment.yml environment-name: xarray-tests - cache-env: true - extra-specs: | - python="3.10" + init-shell: bash + cache-environment: true + create-args: | + python=3.10 - name: Install xarray run: | python -m pip install --no-deps . diff --git a/.github/workflows/testpypi-release.yaml b/.github/workflows/testpypi-release.yaml new file mode 100644 index 000000000..1ed0a4a56 --- /dev/null +++ b/.github/workflows/testpypi-release.yaml @@ -0,0 +1,89 @@ +name: Build and Upload to TestPyPI + +on: + push: + branches: + - "main" + pull_request: + types: [opened, reopened, synchronize, labeled] + branches: + - "*" + workflow_dispatch: + +# no need for concurrency limits + +jobs: + build-artifacts: + if: ${{ contains( github.event.pull_request.labels.*.name, 'test-build') && github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v4 + name: Install Python + with: + python-version: "3.10" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build twine + python -m pip install tomli tomli_w + + # - name: Disable local versions + # run: | + # python .github/workflows/configure-testpypi-version.py pyproject.toml + # git update-index --assume-unchanged pyproject.toml + # cat pyproject.toml + + - name: Build tarball and wheels + run: | + git clean -xdf + python -m build + + - name: Check built artifacts + run: | + python -m twine check --strict dist/* + if [ -f dist/flox-999.tar.gz ]; then + echo "❌ INVALID VERSION NUMBER" + exit 1 + else + echo "βœ… Looks good" + fi + + - uses: actions/upload-artifact@v3 + with: + name: releases + path: dist + + test-built-dist: + needs: build-artifacts + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v4 + name: Install Python + with: + python-version: "3.10" + - uses: actions/download-artifact@v3 + with: + name: releases + path: dist + - name: List contents of built dist + run: | + ls -ltrh + ls -ltrh dist + + - name: Verify the built dist/wheel is valid + run: | + python -m pip install --upgrade pip + python -m pip install dist/flox*.whl + # python -m cf_xarray.scripts.print_versions + + # - name: Publish package to TestPyPI + # uses: pypa/gh-action-pypi-publish@v1.6.4 + # with: + # password: ${{ secrets.TESTPYPI_TOKEN }} + # repository_url: https://test.pypi.org/legacy/ + # verbose: true diff --git a/.github/workflows/upstream-dev-ci.yaml b/.github/workflows/upstream-dev-ci.yaml new file mode 100644 index 000000000..0de92037f --- /dev/null +++ b/.github/workflows/upstream-dev-ci.yaml @@ -0,0 +1,64 @@ +name: CI Upstream +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize, labeled] + branches: + - main + schedule: + - cron: "0 0 * * *" # Daily β€œAt 00:00” UTC + workflow_dispatch: # allows you to trigger the workflow run manually + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + upstream-dev: + name: upstream-dev + runs-on: ubuntu-latest + if: ${{ (contains(github.event.pull_request.labels.*.name, 'test-upstream') && github.event_name == 'pull_request') || github.event_name == 'workflow_dispatch' }} + defaults: + run: + shell: bash -l {0} + strategy: + fail-fast: false + matrix: + python-version: ["3.10"] + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Fetch all history for all branches and tags. + - name: Set environment variables + run: | + echo "PYTHON_VERSION=${{ matrix.python-version }}" >> $GITHUB_ENV + - name: Set up conda environment + uses: mamba-org/setup-micromamba@v1 + with: + environment-file: ci/upstream-dev-env.yml + environment-name: flox-tests + init-shell: bash + cache-environment: true + create-args: >- + python=${{ matrix.python-version }} + pytest-reportlog + - name: Install flox + run: | + python -m pip install --no-deps -e . + - name: Run Tests + if: success() + id: status + run: | + pytest -rf -n auto --cov=./ --cov-report=xml \ + --report-log output-${{ matrix.python-version }}-log.jsonl + - name: Generate and publish the report + if: | + failure() + && steps.status.outcome == 'failure' + && github.event_name == 'schedule' + && github.repository_owner == 'xarray-contrib' + uses: xarray-contrib/issue-from-pytest-log@v1 + with: + log-path: output-${{ matrix.python-version }}-log.jsonl diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b76d51d01..e9b1e9d6b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,36 +2,55 @@ ci: autoupdate_schedule: quarterly repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: 'v0.0.276' + hooks: + - id: ruff + args: ["--fix"] + - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: + - id: check-yaml - id: trailing-whitespace - id: end-of-file-fixer - id: check-docstring-first - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 23.3.0 hooks: - id: black - - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 + - repo: https://github.com/executablebooks/mdformat + rev: 0.7.16 + hooks: + - id: mdformat + additional_dependencies: + - mdformat-black + - mdformat-myst + + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.7.0 + hooks: + - id: nbqa-black + - id: nbqa-ruff + args: [--fix] + + - repo: https://github.com/kynan/nbstripout + rev: 0.6.1 hooks: - - id: flake8 + - id: nbstripout + args: [--extra-keys=metadata.kernelspec metadata.language_info.version] - - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + - repo: https://github.com/codespell-project/codespell + rev: v2.2.5 hooks: - - id: isort + - id: codespell + additional_dependencies: + - tomli - - repo: https://github.com/deathbeds/prenotebook - rev: f5bdb72a400f1a56fe88109936c83aa12cc349fa + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.13 hooks: - - id: prenotebook - args: - [ - '--keep-output', - '--keep-metadata', - '--keep-execution-count', - '--keep-empty', - ] + - id: validate-pyproject diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 8f1266ef8..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,6 +0,0 @@ -include LICENSE -include README.md - -recursive-include flox *.py -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] diff --git a/README.md b/README.md index d99afaa9c..b7f986242 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![GitHub Workflow CI Status](https://img.shields.io/github/workflow/status/xarray-contrib/flox/CI?logo=github&style=flat)](https://github.com/xarray-contrib/flox/actions) +[![GitHub Workflow CI Status](https://img.shields.io/github/actions/workflow/status/xarray-contrib/flox/ci.yaml?branch=main&logo=github&style=flat)](https://github.com/xarray-contrib/flox/actions) [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/xarray-contrib/flox/main.svg)](https://results.pre-commit.ci/latest/github/xarray-contrib/flox/main) [![image](https://img.shields.io/codecov/c/github/xarray-contrib/flox.svg?style=flat)](https://codecov.io/gh/xarray-contrib/flox) [![Documentation Status](https://readthedocs.org/projects/flox/badge/?version=latest)](https://flox.readthedocs.io/en/latest/?badge=latest) @@ -14,10 +14,10 @@ This project explores strategies for fast GroupBy reductions with dask.array. It used to be called `dask_groupby` It was motivated by -1. Dask Dataframe GroupBy - [blogpost](https://blog.dask.org/2019/10/08/df-groupby) -2. [numpy_groupies](https://github.com/ml31415/numpy-groupies) in Xarray - [issue](https://github.com/pydata/xarray/issues/4473) +1. Dask Dataframe GroupBy + [blogpost](https://blog.dask.org/2019/10/08/df-groupby) +1. [numpy_groupies](https://github.com/ml31415/numpy-groupies) in Xarray + [issue](https://github.com/pydata/xarray/issues/4473) (See a [presentation](https://docs.google.com/presentation/d/1YubKrwu9zPHC_CzVBhvORuQBW-z148BvX3Ne8XcvWsQ/edit?usp=sharing) @@ -26,22 +26,23 @@ about this package, from the Pangeo Showcase). ## Acknowledgements This work was funded in part by + 1. NASA-ACCESS 80NSSC18M0156 "Community tools for analysis of NASA Earth Observing System -Data in the Cloud" (PI J. Hamman, NCAR), -2. NASA-OSTFL 80NSSC22K0345 "Enhancing analysis of NASA data with the open-source Python Xarray Library" (PIs Scott Henderson, University of Washington; Deepak Cherian, NCAR; Jessica Scheick, University of New Hampshire), and -3. [NCAR's Earth System Data Science Initiative](https://ncar.github.io/esds/). + Data in the Cloud" (PI J. Hamman, NCAR), +1. NASA-OSTFL 80NSSC22K0345 "Enhancing analysis of NASA data with the open-source Python Xarray Library" (PIs Scott Henderson, University of Washington; Deepak Cherian, NCAR; Jessica Scheick, University of New Hampshire), and +1. [NCAR's Earth System Data Science Initiative](https://ncar.github.io/esds/). It was motivated by [very](https://github.com/pangeo-data/pangeo/issues/266) [very](https://github.com/pangeo-data/pangeo/issues/271) [many](https://github.com/dask/distributed/issues/2602) [discussions](https://github.com/pydata/xarray/issues/2237) in the [Pangeo](https://pangeo.io) community. ## API There are two main functions -1. `flox.groupby_reduce(dask_array, by_dask_array, "mean")` - "pure" dask array interface -1. `flox.xarray.xarray_reduce(xarray_object, by_dataarray, "mean")` - "pure" xarray interface; though [work is ongoing](https://github.com/pydata/xarray/pull/5734) to integrate this - package in xarray. +1. `flox.groupby_reduce(dask_array, by_dask_array, "mean")` + "pure" dask array interface +1. `flox.xarray.xarray_reduce(xarray_object, by_dataarray, "mean")` + "pure" xarray interface; though [work is ongoing](https://github.com/pydata/xarray/pull/5734) to integrate this + package in xarray. ## Implementation @@ -53,21 +54,21 @@ See [the documentation](https://flox.readthedocs.io/en/latest/implementation.htm It also allows you to specify a custom Aggregation (again inspired by dask.dataframe), though this might not be fully functional at the moment. See `aggregations.py` for examples. -``` python - mean = Aggregation( - # name used for dask tasks - name="mean", - # operation to use for pure-numpy inputs - numpy="mean", - # blockwise reduction - chunk=("sum", "count"), - # combine intermediate results: sum the sums, sum the counts - combine=("sum", "sum"), - # generate final result as sum / count - finalize=lambda sum_, count: sum_ / count, - # Used when "reindexing" at combine-time - fill_value=0, - # Used when any member of `expected_groups` is not found - final_fill_value=np.nan, - ) +```python +mean = Aggregation( + # name used for dask tasks + name="mean", + # operation to use for pure-numpy inputs + numpy="mean", + # blockwise reduction + chunk=("sum", "count"), + # combine intermediate results: sum the sums, sum the counts + combine=("sum", "sum"), + # generate final result as sum / count + finalize=lambda sum_, count: sum_ / count, + # Used when "reindexing" at combine-time + fill_value=0, + # Used when any member of `expected_groups` is not found + final_fill_value=np.nan, +) ``` diff --git a/asv_bench/benchmarks/README_CI.md b/asv_bench/benchmarks/README_CI.md index 9d86cc257..f306736ab 100644 --- a/asv_bench/benchmarks/README_CI.md +++ b/asv_bench/benchmarks/README_CI.md @@ -1,7 +1,9 @@ # Benchmark CI + + ## How it works @@ -10,39 +12,39 @@ The `asv` suite can be run for any PR on GitHub Actions (check workflow `.github We use `asv continuous` to run the job, which runs a relative performance measurement. This means that there's no state to be saved and that regressions are only caught in terms of performance ratio (absolute numbers are available but they are not useful since we do not use stable hardware over time). `asv continuous` will: -* Compile `scikit-image` for _both_ commits. We use `ccache` to speed up the process, and `mamba` is used to create the build environments. -* Run the benchmark suite for both commits, _twice_ (since `processes=2` by default). -* Generate a report table with performance ratios: - * `ratio=1.0` -> performance didn't change. - * `ratio<1.0` -> PR made it slower. - * `ratio>1.0` -> PR made it faster. +- Compile `scikit-image` for _both_ commits. We use `ccache` to speed up the process, and `mamba` is used to create the build environments. +- Run the benchmark suite for both commits, _twice_ (since `processes=2` by default). +- Generate a report table with performance ratios: + - `ratio=1.0` -> performance didn't change. + - `ratio<1.0` -> PR made it slower. + - `ratio>1.0` -> PR made it faster. Due to the sensitivity of the test, we cannot guarantee that false positives are not produced. In practice, values between `(0.7, 1.5)` are to be considered part of the measurement noise. When in doubt, running the benchmark suite one more time will provide more information about the test being a false positive or not. ## Running the benchmarks on GitHub Actions 1. On a PR, add the label `run-benchmark`. -2. The CI job will be started. Checks will appear in the usual dashboard panel above the comment box. -3. If more commits are added, the label checks will be grouped with the last commit checks _before_ you added the label. -4. Alternatively, you can always go to the `Actions` tab in the repo and [filter for `workflow:Benchmark`](https://github.com/scikit-image/scikit-image/actions?query=workflow%3ABenchmark). Your username will be assigned to the `actor` field, so you can also filter the results with that if you need it. +1. The CI job will be started. Checks will appear in the usual dashboard panel above the comment box. +1. If more commits are added, the label checks will be grouped with the last commit checks _before_ you added the label. +1. Alternatively, you can always go to the `Actions` tab in the repo and [filter for `workflow:Benchmark`](https://github.com/scikit-image/scikit-image/actions?query=workflow%3ABenchmark). Your username will be assigned to the `actor` field, so you can also filter the results with that if you need it. ## The artifacts The CI job will also generate an artifact. This is the `.asv/results` directory compressed in a zip file. Its contents include: -* `fv-xxxxx-xx/`. A directory for the machine that ran the suite. It contains three files: - * `.json`, `.json`: the benchmark results for each commit, with stats. - * `machine.json`: details about the hardware. -* `benchmarks.json`: metadata about the current benchmark suite. -* `benchmarks.log`: the CI logs for this run. -* This README. +- `fv-xxxxx-xx/`. A directory for the machine that ran the suite. It contains three files: + - `.json`, `.json`: the benchmark results for each commit, with stats. + - `machine.json`: details about the hardware. +- `benchmarks.json`: metadata about the current benchmark suite. +- `benchmarks.log`: the CI logs for this run. +- This README. ## Re-running the analysis Although the CI logs should be enough to get an idea of what happened (check the table at the end), one can use `asv` to run the analysis routines again. 1. Uncompress the artifact contents in the repo, under `.asv/results`. This is, you should see `.asv/results/benchmarks.log`, not `.asv/results/something_else/benchmarks.log`. Write down the machine directory name for later. -2. Run `asv show` to see your available results. You will see something like this: +1. Run `asv show` to see your available results. You will see something like this: ``` $> asv show @@ -115,8 +117,10 @@ To minimize the time required to run the full suite, we trimmed the parameter ma ```python from . import _skip_slow # this function is defined in benchmarks.__init__ + def time_something_slow(): pass + time_something.setup = _skip_slow ``` diff --git a/asv_bench/benchmarks/cohorts.py b/asv_bench/benchmarks/cohorts.py index 2c19c881f..dbfbe8cd5 100644 --- a/asv_bench/benchmarks/cohorts.py +++ b/asv_bench/benchmarks/cohorts.py @@ -42,9 +42,9 @@ def track_num_layers(self): )[0] return len(result.dask.layers) - track_num_tasks.unit = "tasks" - track_num_tasks_optimized.unit = "tasks" - track_num_layers.unit = "layers" + track_num_tasks.unit = "tasks" # type: ignore[attr-defined] # Lazy + track_num_tasks_optimized.unit = "tasks" # type: ignore[attr-defined] # Lazy + track_num_layers.unit = "layers" # type: ignore[attr-defined] # Lazy class NWMMidwest(Cohorts): @@ -92,8 +92,8 @@ def setup(self, *args, **kwargs): by = (self.time.dt.month.values, self.time.dt.hour.values) ret = flox.core._factorize_multiple( by, - expected_groups=(pd.Index(np.arange(1, 13)), pd.Index(np.arange(1, 25))), - by_is_dask=False, + (pd.Index(np.arange(1, 13)), pd.Index(np.arange(1, 25))), + False, reindex=False, ) # Add one so the rechunk code is simpler and makes sense diff --git a/asv_bench/benchmarks/combine.py b/asv_bench/benchmarks/combine.py index 2da0b1392..27600685f 100644 --- a/asv_bench/benchmarks/combine.py +++ b/asv_bench/benchmarks/combine.py @@ -1,3 +1,6 @@ +from functools import partial +from typing import Any + import numpy as np import flox @@ -7,26 +10,31 @@ N = 1000 +def _get_combine(combine): + if combine == "grouped": + return partial(flox.core._grouped_combine, engine="numpy") + else: + return partial(flox.core._simple_combine, reindex=False) + + class Combine: def setup(self, *args, **kwargs): raise NotImplementedError - @parameterized("kind", ("cohorts", "mapreduce")) - def time_combine(self, kind): - flox.core._grouped_combine( + @parameterized(("kind", "combine"), (("reindexed", "not_reindexed"), ("grouped", "simple"))) + def time_combine(self, kind, combine): + _get_combine(combine)( getattr(self, f"x_chunk_{kind}"), **self.kwargs, keepdims=True, - engine="numpy", ) - @parameterized("kind", ("cohorts", "mapreduce")) - def peakmem_combine(self, kind): - flox.core._grouped_combine( + @parameterized(("kind", "combine"), (("reindexed", "not_reindexed"), ("grouped", "simple"))) + def peakmem_combine(self, kind, combine): + _get_combine(combine)( getattr(self, f"x_chunk_{kind}"), **self.kwargs, keepdims=True, - engine="numpy", ) @@ -36,8 +44,8 @@ class Combine1d(Combine): this is for reducting along a single dimension """ - def setup(self, *args, **kwargs): - def construct_member(groups): + def setup(self, *args, **kwargs) -> None: + def construct_member(groups) -> dict[str, Any]: return { "groups": groups, "intermediates": [ @@ -47,7 +55,7 @@ def construct_member(groups): } # motivated by - self.x_chunk_mapreduce = [ + self.x_chunk_not_reindexed = [ construct_member(groups) for groups in [ np.array((1, 2, 3, 4)), @@ -57,5 +65,12 @@ def construct_member(groups): * 2 ] - self.x_chunk_cohorts = [construct_member(groups) for groups in [np.array((1, 2, 3, 4))] * 4] - self.kwargs = {"agg": flox.aggregations.mean, "axis": (3,)} + self.x_chunk_reindexed = [ + construct_member(groups) for groups in [np.array((1, 2, 3, 4))] * 4 + ] + self.kwargs = { + "agg": flox.aggregations._initialize_aggregation( + "sum", "float64", np.float64, 0, 0, {} + ), + "axis": (3,), + } diff --git a/asv_bench/benchmarks/reduce.py b/asv_bench/benchmarks/reduce.py index 0ed38e9ba..89c58e0bf 100644 --- a/asv_bench/benchmarks/reduce.py +++ b/asv_bench/benchmarks/reduce.py @@ -1,13 +1,15 @@ import numpy as np import numpy_groupies as npg +import pandas as pd import flox from . import parameterized N = 1000 -funcs = ["sum", "nansum", "mean", "nanmean", "max"] +funcs = ["sum", "nansum", "mean", "nanmean", "max", "var", "nanvar", "count"] engines = ["flox", "numpy", "numbagg"] +expected_groups = [None, pd.IntervalIndex.from_breaks([1, 2, 4])] class ChunkReduce: @@ -31,24 +33,26 @@ def setup(self, *args, **kwargs): raise NotImplementedError - @parameterized("func, engine", [funcs, engines]) - def time_reduce(self, func, engine): + @parameterized("func, engine, expected_groups", [funcs, engines, expected_groups]) + def time_reduce(self, func, engine, expected_groups): flox.groupby_reduce( self.array, self.labels, func=func, engine=engine, axis=self.axis, + expected_groups=expected_groups, ) - @parameterized("func, engine", [funcs, engines]) - def peakmem_reduce(self, func, engine): + @parameterized("func, engine, expected_groups", [funcs, engines, expected_groups]) + def peakmem_reduce(self, func, engine, expected_groups): flox.groupby_reduce( self.array, self.labels, func=func, engine=engine, axis=self.axis, + expected_groups=expected_groups, ) diff --git a/ci/docs.yml b/ci/docs.yml index 9cdfb38e5..c3359e4f1 100644 --- a/ci/docs.yml +++ b/ci/docs.yml @@ -7,7 +7,7 @@ dependencies: - xarray - numpy>=1.20 - numpydoc - - numpy_groupies + - numpy_groupies>=0.9.19 - toolz - matplotlib-base - myst-parser @@ -16,5 +16,6 @@ dependencies: - furo - ipykernel - jupyter + - sphinx-codeautolink - pip: - - git+https://github.com/xarray-contrib/flox + - -e .. diff --git a/ci/environment.yml b/ci/environment.yml index f50aa1b1c..cd96707ce 100644 --- a/ci/environment.yml +++ b/ci/environment.yml @@ -9,14 +9,16 @@ dependencies: - netcdf4 - pandas - numpy>=1.20 + - lxml # for mypy coverage report - matplotlib - pip - pytest - pytest-cov + - pytest-pretty - pytest-xdist - xarray - pre-commit - - numpy_groupies>=0.9.15 + - numpy_groupies>=0.9.19 - pooch - toolz - numba diff --git a/ci/minimal-requirements.yml b/ci/minimal-requirements.yml index 882c8d1fb..eeb075194 100644 --- a/ci/minimal-requirements.yml +++ b/ci/minimal-requirements.yml @@ -7,9 +7,10 @@ dependencies: - pip - pytest - pytest-cov + - pytest-pretty - pytest-xdist - numpy==1.20 - - numpy_groupies==0.9.15 + - numpy_groupies==0.9.19 - pandas - pooch - toolz diff --git a/ci/no-dask.yml b/ci/no-dask.yml index 31ce0ade3..8876a84cf 100644 --- a/ci/no-dask.yml +++ b/ci/no-dask.yml @@ -9,11 +9,12 @@ dependencies: - pip - pytest - pytest-cov + - pytest-pretty - pytest-xdist - xarray - numpydoc - pre-commit - - numpy_groupies>=0.9.15 + - numpy_groupies>=0.9.19 - pooch - toolz - numba diff --git a/ci/no-xarray.yml b/ci/no-xarray.yml index 25c777fa1..491d7ba8e 100644 --- a/ci/no-xarray.yml +++ b/ci/no-xarray.yml @@ -9,11 +9,12 @@ dependencies: - pip - pytest - pytest-cov + - pytest-pretty - pytest-xdist - dask-core - numpydoc - pre-commit - - numpy_groupies>=0.9.15 + - numpy_groupies>=0.9.19 - pooch - toolz - numba diff --git a/ci/upstream-dev-env.yml b/ci/upstream-dev-env.yml index b44dc45e7..04fd7ce60 100644 --- a/ci/upstream-dev-env.yml +++ b/ci/upstream-dev-env.yml @@ -10,6 +10,7 @@ dependencies: - numba - pytest - pytest-cov + - pytest-pretty - pytest-xdist - pip - pip: diff --git a/codecov.yml b/codecov.yml index aa1da5f32..90c354ae2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -5,6 +5,7 @@ codecov: comment: false ignore: + - 'benchmarks/*.py' - 'tests/*.py' - 'setup.py' diff --git a/docs/diagrams/new-blockwise-annotated.svg b/docs/diagrams/new-blockwise-annotated.svg new file mode 100644 index 000000000..15f3f2417 --- /dev/null +++ b/docs/diagrams/new-blockwise-annotated.svg @@ -0,0 +1,1185 @@ + +2022-11-14T16:09:56.206507image/svg+xmlMatplotlib v3.6.0, https://matplotlib.org/ diff --git a/docs/diagrams/new-blockwise.svg b/docs/diagrams/new-blockwise.svg new file mode 100644 index 000000000..3f310ad10 --- /dev/null +++ b/docs/diagrams/new-blockwise.svg @@ -0,0 +1,1563 @@ + + + + + + + + 2022-11-15T16:33:54.164631 + image/svg+xml + + + Matplotlib v3.6.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/diagrams/new-cohorts-annotated.svg b/docs/diagrams/new-cohorts-annotated.svg new file mode 100644 index 000000000..e6396328f --- /dev/null +++ b/docs/diagrams/new-cohorts-annotated.svg @@ -0,0 +1,1845 @@ + + + + diff --git a/docs/diagrams/new-cohorts.svg b/docs/diagrams/new-cohorts.svg new file mode 100644 index 000000000..fdbcf2050 --- /dev/null +++ b/docs/diagrams/new-cohorts.svg @@ -0,0 +1,2261 @@ + + + + + + + + 2022-11-14T16:28:28.725615 + image/svg+xml + + + Matplotlib v3.6.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/diagrams/new-map-reduce-reindex-False-annotated.svg b/docs/diagrams/new-map-reduce-reindex-False-annotated.svg new file mode 100644 index 000000000..73a45dc82 --- /dev/null +++ b/docs/diagrams/new-map-reduce-reindex-False-annotated.svg @@ -0,0 +1,1887 @@ + + + + diff --git a/docs/diagrams/new-map-reduce-reindex-False.svg b/docs/diagrams/new-map-reduce-reindex-False.svg new file mode 100644 index 000000000..5e03ccee8 --- /dev/null +++ b/docs/diagrams/new-map-reduce-reindex-False.svg @@ -0,0 +1,2382 @@ + + + + + + + + 2022-11-15T21:26:39.966187 + image/svg+xml + + + Matplotlib v3.6.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/diagrams/new-map-reduce-reindex-True-annotated.svg b/docs/diagrams/new-map-reduce-reindex-True-annotated.svg new file mode 100644 index 000000000..412eaa301 --- /dev/null +++ b/docs/diagrams/new-map-reduce-reindex-True-annotated.svg @@ -0,0 +1,1794 @@ + + + + diff --git a/docs/diagrams/new-map-reduce-reindex-True.svg b/docs/diagrams/new-map-reduce-reindex-True.svg new file mode 100644 index 000000000..97b615190 --- /dev/null +++ b/docs/diagrams/new-map-reduce-reindex-True.svg @@ -0,0 +1,2373 @@ + + + + + + + + 2022-11-15T16:09:02.384660 + image/svg+xml + + + Matplotlib v3.6.0, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/diagrams/new-split-apply-combine-annotated.svg b/docs/diagrams/new-split-apply-combine-annotated.svg new file mode 100644 index 000000000..fd38c9fef --- /dev/null +++ b/docs/diagrams/new-split-apply-combine-annotated.svg @@ -0,0 +1,2370 @@ + + + + diff --git a/docs/diagrams/split-apply-combine.svg b/docs/diagrams/split-apply-combine.svg new file mode 100644 index 000000000..a118d8fb3 --- /dev/null +++ b/docs/diagrams/split-apply-combine.svg @@ -0,0 +1,2342 @@ + + + + + + + + 2022-11-26T15:23:21.390398 + image/svg+xml + + + Matplotlib v3.6.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/source/aggregations.md b/docs/source/aggregations.md new file mode 100644 index 000000000..e6c10e4ba --- /dev/null +++ b/docs/source/aggregations.md @@ -0,0 +1,45 @@ +# Aggregations + +`flox` implements all common reductions provided by `numpy_groupies` in `aggregations.py`. Control this by passing +the `func` kwarg: + +- `"sum"`, `"nansum"` +- `"prod"`, `"nanprod"` +- `"count"` - number of non-NaN elements by group +- `"mean"`, `"nanmean"` +- `"var"`, `"nanvar"` +- `"std"`, `"nanstd"` +- `"argmin"` +- `"argmax"` +- `"first"` +- `"last"` + +```{tip} +We would like to add support for `cumsum`, `cumprod` ([issue](https://github.com/xarray-contrib/flox/issues/91)). Contributions are welcome! +``` + +## Custom Aggregations + +`flox` also allows you to specify a custom Aggregation (again inspired by dask.dataframe), +though this might not be fully functional at the moment. See `aggregations.py` for examples. + +See the ["Custom Aggregations"](user-stories/custom-aggregations.ipynb) user story for a more user-friendly example. + +```python +mean = Aggregation( + # name used for dask tasks + name="mean", + # operation to use for pure-numpy inputs + numpy="mean", + # blockwise reduction + chunk=("sum", "count"), + # combine intermediate results: sum the sums, sum the counts + combine=("sum", "sum"), + # generate final result as sum / count + finalize=lambda sum_, count: sum_ / count, + # Used when "reindexing" at combine-time + fill_value=0, + # Used when any member of `expected_groups` is not found + final_fill_value=np.nan, +) +``` diff --git a/docs/source/api.rst b/docs/source/api.rst index 5c0d21e63..b0d5e0aa7 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -9,7 +9,7 @@ Functions .. autosummary:: :toctree: generated/ - ~core.groupby_reduce + groupby_reduce xarray.xarray_reduce Rechunking @@ -18,8 +18,8 @@ Rechunking .. autosummary:: :toctree: generated/ - ~core.rechunk_for_blockwise - ~core.rechunk_for_cohorts + rechunk_for_blockwise + rechunk_for_cohorts xarray.rechunk_for_blockwise xarray.rechunk_for_cohorts @@ -30,7 +30,7 @@ Visualization :toctree: generated/ visualize.draw_mesh - visualize.visualize_groups + visualize.visualize_groups_1d visualize.visualize_cohorts_2d Aggregation Objects diff --git a/docs/source/arrays.md b/docs/source/arrays.md new file mode 100644 index 000000000..cf41c6fa1 --- /dev/null +++ b/docs/source/arrays.md @@ -0,0 +1,14 @@ +# Duck Array Support + +Aggregating over other array types will work if the array types supports the following methods, [ufunc.reduceat](https://numpy.org/doc/stable/reference/generated/numpy.ufunc.reduceat.html) or [ufunc.at](https://numpy.org/doc/stable/reference/generated/numpy.ufunc.at.html) + +| Reduction | `method="numpy"` | `method="flox"` | +| ------------------------------ | ---------------- | ----------------- | +| sum, nansum | bincount | add.reduceat | +| mean, nanmean | bincount | add.reduceat | +| var, nanvar | bincount | add.reduceat | +| std, nanstd | bincount | add.reduceat | +| count | bincount | add.reduceat | +| prod | multiply.at | multiply.reduceat | +| max, nanmax, argmax, nanargmax | maximum.at | maximum.reduceat | +| min, nanmin, argmin, nanargmin | minimum.at | minimum.reduceat | diff --git a/docs/source/conf.py b/docs/source/conf.py index 8790c9502..80412ba23 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # complexity documentation build configuration file, created by # sphinx-quickstart on Tue Jul 9 22:26:36 2013. @@ -40,11 +39,14 @@ "numpydoc", "sphinx.ext.napoleon", "myst_nb", + "sphinx_codeautolink", ] +codeautolink_concat_default = True + extlinks = { - "issue": ("https://github.com/xarray-contrib/flox/issues/%s", "GH#"), - "pr": ("https://github.com/xarray-contrib/flox/pull/%s", "GH#"), + "issue": ("https://github.com/xarray-contrib/flox/issues/%s", "GH#%s"), + "pr": ("https://github.com/xarray-contrib/flox/pull/%s", "PR#%s"), } templates_path = ["_templates"] @@ -61,6 +63,7 @@ # Myst_nb options nb_execution_excludepatterns = ["climatology-hourly.ipynb"] nb_execution_raise_on_error = True +nb_execution_mode = "cache" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -95,13 +98,34 @@ # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" +pygments_style = "igor" # -- Options for HTML output --------------------------------------------------- html_theme = "furo" +# 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. +css_vars = { + "admonition-font-size": "0.9rem", + "font-size--small": "92%", + "font-size--small--2": "87.5%", +} +html_theme_options = dict( + sidebar_hide_name=True, + light_css_variables=css_vars, + dark_css_variables=css_vars, +) + +html_context = { + "github_user": "xarray-contrib", + "github_repo": "flox", + "github_version": "main", + "doc_path": "doc", +} + # 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. @@ -174,7 +198,7 @@ "numpy": ("https://numpy.org/doc/stable", None), # "numba": ("https://numba.pydata.org/numba-doc/latest", None), "dask": ("https://docs.dask.org/en/latest", None), - "xarray": ("http://xarray.pydata.org/en/stable/", None), + "xarray": ("https://docs.xarray.dev/en/stable/", None), } autosummary_generate = True diff --git a/docs/source/custom.md b/docs/source/custom.md deleted file mode 100644 index dca975529..000000000 --- a/docs/source/custom.md +++ /dev/null @@ -1,26 +0,0 @@ -# Custom reductions - -`flox` implements all common reductions provided by `numpy_groupies` in `aggregations.py`. -It also allows you to specify a custom Aggregation (again inspired by dask.dataframe), -though this might not be fully functional at the moment. See `aggregations.py` for examples. - -See the ["Custom Aggregations"](user-stories/custom-aggregations.ipynb) user story for a more user-friendly example. - -```python - mean = Aggregation( - # name used for dask tasks - name="mean", - # operation to use for pure-numpy inputs - numpy="mean", - # blockwise reduction - chunk=("sum", "count"), - # combine intermediate results: sum the sums, sum the counts - combine=("sum", "sum"), - # generate final result as sum / count - finalize=lambda sum_, count: sum_ / count, - # Used when "reindexing" at combine-time - fill_value=0, - # Used when any member of `expected_groups` is not found - final_fill_value=np.nan, - ) -``` diff --git a/docs/source/engines.md b/docs/source/engines.md new file mode 100644 index 000000000..867979d13 --- /dev/null +++ b/docs/source/engines.md @@ -0,0 +1,26 @@ +(engines)= + +# Engines + +`flox` provides multiple options, using the `engine` kwarg, for computing the core GroupBy reduction on numpy or other array types other than dask. + +1. `engine="numpy"` wraps `numpy_groupies.aggregate_numpy`. This uses indexing tricks and functions like `np.bincount`, or the ufunc `.at` methods + (.e.g `np.maximum.at`) to provided reasonably performant aggregations. +1. `engine="numba"` wraps `numpy_groupies.aggregate_numba`. This uses `numba` kernels for the core aggregation. +1. `engine="flox"` uses the `ufunc.reduceat` method after first argsorting the array so that all group members occur sequentially. This was copied from + a [gist by Stephan Hoyer](https://gist.github.com/shoyer/f538ac78ae904c936844) + +See [](arrays) for more details. + +## Tradeoffs + +For the common case of reducing a nD array by a 1D array of group labels (e.g. `groupby("time.month")`), `engine="flox"` *can* be faster. + +The reason is that `numpy_groupies` converts all groupby problems to a 1D problem, this can involve [some overhead](https://github.com/ml31415/numpy-groupies/pull/46). +It is possible to optimize this a bit in `flox` or `numpy_groupies`, but the work has not been done yet. +The advantage of `engine="numpy"` is that it tends to work for more array types, since it appears to be more common to implement `np.bincount`, and not `np.add.reduceat`. + +```{tip} +Other potential engines we could add are [`numbagg`](https://github.com/numbagg/numbagg) ([stalled PR here](https://github.com/xarray-contrib/flox/pull/72)) and [`datashader`](https://github.com/xarray-contrib/flox/issues/142). +Both use numba for high-performance aggregations. Contributions or discussion is very welcome! +``` diff --git a/docs/source/implementation.md b/docs/source/implementation.md index ae0db3539..f3a2a87f7 100644 --- a/docs/source/implementation.md +++ b/docs/source/implementation.md @@ -1,17 +1,31 @@ -# Algorithms +(algorithms)= -`flox` outsources the core GroupBy operation to the vectorized implementations in -[numpy_groupies](https://github.com/ml31415/numpy-groupies). Constructing -an efficient groupby reduction with dask is hard, and depends on how the -groups are distributed amongst the blocks of an array. `flox` implements 4 strategies for -grouped reductions, each is appropriate for a particular distribution of groups -among the blocks of a dask array. +# Parallel Algorithms -Switch between the various strategies by passing `method` to either {py:func}`flox.core.groupby_reduce` -or `xarray_reduce`. +`flox` outsources the core GroupBy operation to the vectorized implementations controlled by the +[`engine` kwarg](engines.md). Applying these implementations on a parallel array type like dask +can be hard. Performance strongly depends on how the groups are distributed amongst the blocks of an array. +`flox` implements 4 strategies for grouped reductions, each is appropriate for a particular distribution of groups +among the blocks of a dask array. Switch between the various strategies by passing `method` +and/or `reindex` to either {py:func}`flox.groupby_reduce` or {py:func}`flox.xarray.xarray_reduce`. -First we describe xarray's current strategy +Your options are: + +1. [`method="map-reduce"` with `reindex=False`](map-reindex-false) +1. [`method="map-reduce"` with `reindex=True`](map-reindex-True) +1. [`method="blockwise"`](method-blockwise) +1. [`method="cohorts"`](method-cohorts) + +The most appropriate strategy for your problem will depend on the chunking of your dataset, +and the distribution of group labels across those chunks. + +```{tip} +Currently these strategies are implemented for dask. We would like to generalize to other parallel array types +as appropriate (e.g. Ramba, cubed, arkouda). Please open an issue to discuss if you are interested. +``` + +(xarray-split)= ## Background: Xarray's current GroupBy strategy @@ -21,7 +35,13 @@ labels (i.e. you cannot use this strategy to group by a dask array). Schematically, this looks like (colors indicate group labels; separated groups of colors indicate different blocks of an array): -![xarray-current-strategy](../diagrams/split-reduce.png) + +```{image} ../diagrams/new-split-apply-combine-annotated.svg +--- +alt: xarray-current-strategy +width: 100% +--- +``` The first step is to extract all members of a group, which involves a *lot* of communication and is quite expensive (in dataframe terminology, this is a "shuffle"). @@ -30,89 +50,181 @@ big datasets. ## `method="map-reduce"` -The first idea is to use the "map-reduce" strategy (inspired by `dask.dataframe`). - ![map-reduce-strategy-schematic](/../diagrams/map-reduce.png) +The "map-reduce" strategy is inspired by `dask.dataframe.groupby`). The GroupBy reduction is first applied blockwise. Those intermediate results are combined by concatenating to form a new array which is then reduced -again. The combining of intermediate results uses dask\'s `_tree_reduce` +again. The combining of intermediate results uses dask's `_tree_reduce` till all group results are in one block. At that point the result is -\"finalized\" and returned to the user. +"finalized" and returned to the user. + +### General Tradeoffs + +1. This approach works well when either the initial blockwise reduction is effective, or if the + reduction at the first combine step is effective. Here "effective" means we have multiple members of a single + group in a block so the blockwise application of groupby-reduce actually reduces values and releases some memory. +1. One downside is that the final result will only have one chunk along the new group axis. +1. We have two choices for how to construct the intermediate arrays. See below. + +(map-reindex-True)= -*Tradeoffs*: -1. Allows grouping by a dask array so group labels need not be known at graph construction - time. -1. Works well when either the initial blockwise reduction is effective, or if the - reduction at the first combine step is effective. "effective" means we actually - reduce values and release some memory. +### `reindex=True` + +If we know all the group labels, we can do so right at the blockwise step (`reindex=True`). This matches `dask.array.histogram` and +`xhistogram`, where the bin edges, or group labels oof the output, are known. The downside is the potential of large memory use +if number of output groups is much larger than number of groups in a block. + +```{image} ../diagrams/new-map-reduce-reindex-True-annotated.svg +--- +alt: map-reduce-reindex-True-strategy-schematic +width: 100% +--- +``` + +(map-reindex-False)= + +### `reindex=False` + +We can `reindex` at the combine stage to groups present in the blocks being combined (`reindex=False`). This can limit memory use at the cost +of a performance reduction due to extra copies of the intermediate data during reindexing. + +```{image} ../diagrams/new-map-reduce-reindex-False-annotated.svg +--- +alt: map-reduce-reindex-True-strategy-schematic +width: 100% +--- +``` + +This approach allows grouping by a dask array so group labels can be discovered at compute time, similar to `dask.dataframe.groupby`. + +### Example + +For example, consider `groupby("time.month")` with monthly frequency data and chunksize of 4 along `time`. +![cohorts-schematic](/../diagrams/cohorts-month-chunk4.png) +With `reindex=True`, each block will become 3x its original size at the blockwise step: input blocks have 4 timesteps while output block +has a value for all 12 months. One could use `reindex=False` to control memory usage but also see [`method="cohorts"`](method-cohorts) below. + +(method-blockwise)= ## `method="blockwise"` -One case where `"map-reduce"` doesn't work well is the case of "resampling" reductions. An -example here is resampling from daily frequency to monthly frequency data: `da.resample(time="M").mean()` +One case where `method="map-reduce"` doesn't work well is the case of "resampling" reductions. An +example here is resampling from daily frequency to monthly frequency data: `da.resample(time="M").mean()` For resampling type reductions, + 1. Group members occur sequentially (all days in January 2001 occur one after the other) -2. All groups are roughly equal length (31 days in January but 28 in most Februaries) -3. All members in a group are next to each other (if the time series is sorted, which it +1. All groups not of exactly equal length (31 days in January but 28 in most Februaries) +1. All members in a group are next to each other (if the time series is sorted, which it usually is). +1. Because there can be a large number of groups, concatenating results for all groups in a single chunk could be catastrophic. -In this case, it makes sense to use `dask.dataframe` resample strategy which is to rechunk +In this case, it makes sense to use `dask.dataframe` resample strategy which is to rechunk using {py:func}`flox.rechunk_for_blockwise` so that all members of a group are in a single block. Then, the groupby operation can be applied blockwise. -![blockwise-strategy-schematic](/../diagrams/blockwise.png) +```{image} ../diagrams/new-blockwise-annotated.svg +--- +alt: blockwise-strategy-schematic +width: 100% +--- +``` *Tradeoffs* + 1. Only works for certain groupings. 1. Group labels must be known at graph construction time, so this only works for numpy arrays 1. Currently the rechunking is only implemented for 1D arrays (being motivated by time resampling), but a nD generalization seems possible. -1. Works better when multiple groups are already in a single block; so that the intial +1. Only can use the `blockwise` strategy for grouping by `nD` arrays. +1. Works better when multiple groups are already in a single block; so that the initial rechunking only involves a small amount of communication. +(method-cohorts)= + ## `method="cohorts"` -We can combine all of the above ideas for cases where members from different groups tend to occur close to each other. +The `map-reduce` strategy is quite effective but can involve some unnecessary communication. It can be possible to exploit +patterns in how group labels are distributed across chunks (similar to `method="blockwise"` above). Two cases are illustrative: + +1. Groups labels can be *approximately-periodic*: e.g. `time.dayofyear` (period 365 or 366) or `time.month` (period 12). + Consider our earlier example, `groupby("time.month")` with monthly frequency data and chunksize of 4 along `time`. + ![cohorts-schematic](/../diagrams/cohorts-month-chunk4.png) + Because a chunksize of 4 evenly divides the number of groups (12) all we need to do is index out blocks + 0, 3, 7 and then apply the `"map-reduce"` strategy to form the final result for months Jan-Apr. Repeat for the + remaining groups of months (May-Aug; Sep-Dec) and then concatenate. + +1. Groups can be *spatially localized* like the blockwise case above, for example grouping by country administrative boundaries like + counties or districts. In this case, concatenating the result for the northwesternmost county or district and the southeasternmost + district can involve a lot of wasteful communication (again depending on chunking). + +For such cases, we can adapt xarray's shuffling or subsetting strategy by indexing out "cohorts" or group labels +that tend to occur next to each other. + +### A motivating example : time grouping + One example is the construction of "climatologies" which is a climate science term for something like `groupby("time.month")` ("monthly climatology") or `groupby("time.dayofyear")` ("daily climatology"). In these cases, -1. Groups occur sequentially (day 2 is always after day 1; and February is always after January) -2. Groups are approximately periodic (some years have 365 days and others have 366) -The idea here is to copy xarray's subsetting strategy but instead index out "cohorts" or group labels -that tend to occur next to each other. +1. Groups occur sequentially (day 2 is always after day 1; and February is always after January) +1. Groups are approximately periodic (some years have 365 days and others have 366) -Consider this example of monthly average data; where 4 months are present in a single block (i.e. chunksize=4) +Consider our earlier example, `groupby("time.month")` with monthly frequency data and chunksize of 4 along `time`. ![cohorts-schematic](/../diagrams/cohorts-month-chunk4.png) -Because a chunksize of 4 evenly divides the number of groups (12) all we need to do is index out blocks +With `method="map-reduce", reindex=True`, each block will become 3x its original size at the blockwise step: input blocks have 4 timesteps while output block +has a value for all 12 months. Note that the blockwise groupby-reduction *does not reduce* the data since there is only one element in each +group. In addition, since `map-reduce` will make the final result have only one chunk of size 12 along the new `month` +dimension, the final result has chunk sizes 3x that of the input, which may not be ideal. + +However, because a chunksize of 4 evenly divides the number of groups (12) all we need to do is index out blocks 0, 3, 7 and then apply the `"map-reduce"` strategy to form the final result for months Jan-Apr. Repeat for the -remaining groups of months (May-Aug; Sep-Dec) and then concatenate. +remaining groups of months (May-Aug; Sep-Dec) and then concatenate. This is the essence of `method="cohorts"` + +### Summary + +We can generalize this idea for more complicated problems (inspired by the `split_out`kwarg in `dask.dataframe.groupby`) +We first apply the groupby-reduction blockwise, then split and reindex blocks to create a new array with which we complete the reduction +using `map-reduce`. Because the split or shuffle step occurs after the blockwise reduction, we *sometimes* communicate a significantly smaller +amount of data than if we split or shuffled the input array. + +```{image} /../diagrams/new-cohorts-annotated.svg +--- +alt: cohorts-strategy-schematic +width: 100% +--- +``` + +### Tradeoffs + +1. Group labels must be known at graph construction time, so this only works for numpy arrays. +1. This does require more tasks and a more complicated graph, but the communication overhead can be significantly lower. +1. The detection of "cohorts" is currently slow but could be improved. +1. The extra effort of detecting cohorts and mul;tiple copying of intermediate blocks may be worthwhile only if the chunk sizes are small + relative to the approximate period of group labels, or small relative to the size of spatially localized groups. + +### Example : sensitivity to chunking + +One annoyance is that if the chunksize doesn't evenly divide the number of groups, we still end up splitting a number of chunks. +Consider our earlier example, `groupby("time.month")` with monthly frequency data and chunksize of 4 along `time`. +![cohorts-schematic](/../diagrams/cohorts-month-chunk4.png) `flox` can find these cohorts, below it identifies the cohorts with labels `1,2,3,4`; `5,6,7,8`, and `9,10,11,12`. -``` python ->>> flox.core.find_group_cohorts(labels, array.chunks[-1])) + +```python +>>> flox.find_group_cohorts(labels, array.chunks[-1]).values() [[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]] # 3 cohorts ``` -For each cohort, it counts the number of blocks that need to be reduced. If `1` then it applies the reduction blockwise. -If > 1; then it uses `"map-reduce"`. -One annoyance is that if the chunksize doesn't evenly divide the number of groups, we still end up splitting a number of chunks. -For example, when `chunksize=5` +Now consider `chunksize=5`. ![cohorts-schematic](/../diagrams/cohorts-month-chunk5.png) -``` python ->>> flox.core.find_group_cohorts(labels, array.chunks[-1])) +```python +>>> flox.core.find_group_cohorts(labels, array.chunks[-1]).values() [[1], [2, 3], [4, 5], [6], [7, 8], [9, 10], [11], [12]] # 8 cohorts ``` -We find 8 cohorts (note the original xarray strategy is equivalent to constructing 12 cohorts). -It's possible that some initial rechunking makes the situation better (just rechunk from 5-4), but it isn't an obvious improvement. +We find 8 cohorts (note the original xarray strategy is equivalent to constructing 12 cohorts). +In this case, it seems to better to rechunk to a size of `4` along `time`. If you have ideas for improving this case, please open an issue. -*Tradeoffs* -1. Generalizes well; when there's exactly one groups per chunk, this replicates Xarray's - strategy which is optimal. For resampling type reductions, as long as the array - is chunked appropriately ({py:func}`flox.core.rechunk_for_blockwise`, {py:func}`flox.xarray.rechunk_for_blockwise`), `method="cohorts"` is equivalent to `method="blockwise"`! -1. Group labels must be known at graph construction time, so this only works for numpy arrays -1. Currenltly implemented for grouping by 1D arrays. An nD generalization seems possible, - but hard? +### Example : spatial grouping diff --git a/docs/source/index.md b/docs/source/index.md index cf4c5c3ef..9fdd470c2 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -1,54 +1,73 @@ # flox: fast & furious GroupBy reductions for `dask.array` -## Overview - -[![GitHub Workflow CI Status](https://img.shields.io/github/workflow/status/xarray-contrib/flox/CI?logo=github&style=flat)](https://github.com/xarray-contrib/flox/actions) -[![GitHub Workflow Code Style Status](https://img.shields.io/github/workflow/status/xarray-contrib/flox/code-style?label=Code%20Style&style=flat)](https://github.com/xarray-contrib/flox/actions) +[![GitHub Workflow CI Status](https://img.shields.io/github/actions/workflow/status/xarray-contrib/flox/ci.yaml?branch=main&logo=github&style=flat)](https://github.com/xarray-contrib/flox/actions) +[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/xarray-contrib/flox/main.svg)](https://results.pre-commit.ci/latest/github/xarray-contrib/flox/main) [![image](https://img.shields.io/codecov/c/github/xarray-contrib/flox.svg?style=flat)](https://codecov.io/gh/xarray-contrib/flox) +[![Documentation Status](https://readthedocs.org/projects/flox/badge/?version=latest)](https://flox.readthedocs.io/en/latest/?badge=latest) + [![PyPI](https://img.shields.io/pypi/v/flox.svg?style=flat)](https://pypi.org/project/flox/) [![Conda-forge](https://img.shields.io/conda/vn/conda-forge/flox.svg?style=flat)](https://anaconda.org/conda-forge/flox) -This project explores strategies for fast GroupBy reductions with dask.array. It used to be called `dask_groupby`. It was motivated by +[![NASA-80NSSC18M0156](https://img.shields.io/badge/NASA-80NSSC18M0156-blue)](https://earthdata.nasa.gov/esds/competitive-programs/access/pangeo-ml) +[![NASA-80NSSC22K0345](https://img.shields.io/badge/NASA-80NSSC22K0345-blue)](https://science.nasa.gov/open-science-overview) -1. Dask Dataframe GroupBy - [blogpost](https://blog.dask.org/2019/10/08/df-groupby) -2. numpy_groupies in Xarray - [issue](https://github.com/pydata/xarray/issues/4473) +## Overview + +`flox` mainly provides strategies for fast GroupBy reductions with dask.array. `flox` uses the MapReduce paradigm (or a "tree reduction") +to run the GroupBy operation in a parallel-native way totally avoiding a sort or shuffle operation. It was motivated by + +1. Dask Dataframe GroupBy + [blogpost](https://blog.dask.org/2019/10/08/df-groupby) +1. numpy_groupies in Xarray + [issue](https://github.com/pydata/xarray/issues/4473) See a presentation ([video](https://discourse.pangeo.io/t/november-17-2021-flox-fast-furious-groupby-reductions-with-dask-at-pangeo-scale/2016), [slides](https://docs.google.com/presentation/d/1YubKrwu9zPHC_CzVBhvORuQBW-z148BvX3Ne8XcvWsQ/edit?usp=sharing)) about this package, from the Pangeo Showcase. +## Why flox? + +1. {py:func}`flox.groupby_reduce` [wraps](engines.md) the `numpy-groupies` package for performant Groupby reductions on nD arrays. +1. {py:func}`flox.groupby_reduce` provides [parallel-friendly strategies](implementation.md) for GroupBy reductions by wrapping `numpy-groupies` for dask arrays. +1. `flox` [integrates with xarray](xarray.md) to provide more performant Groupby and Resampling operations. +1. {py:func}`flox.xarray.xarray_reduce` [extends](xarray.md) Xarray's GroupBy operations allowing lazy grouping by dask arrays, grouping by multiple arrays, + as well as combining categorical grouping and histogram-style binning operations using multiple variables. +1. `flox` also provides utility functions for rechunking both dask arrays and Xarray objects along a single dimension using the group labels as a guide: + 1. To rechunk for blockwise operations: {py:func}`flox.rechunk_for_blockwise`, {py:func}`flox.xarray.rechunk_for_blockwise`. + 1. To rechunk so that "cohorts", or groups of labels, tend to occur in the same chunks: {py:func}`flox.rechunk_for_cohorts`, {py:func}`flox.xarray.rechunk_for_cohorts`. + ## Installing -``` shell +```shell $ pip install flox ``` -``` shell +```shell $ conda install -c conda-forge flox ``` -## API +## Acknowledgements -There are two main functions -1. {py:func}`flox.core.groupby_reduce` - "pure" dask array interface -1. {py:func}`flox.xarray.xarray_reduce` - "pure" xarray interface; though [work is ongoing](https://github.com/pydata/xarray/pull/5734) to integrate this - package in xarray. +This work was funded in part by -## Acknowledgements +1. NASA-ACCESS 80NSSC18M0156 "Community tools for analysis of NASA Earth Observing System + Data in the Cloud" (PI J. Hamman), +1. NASA-OSTFL 80NSSC22K0345 "Enhancing analysis of NASA data with the open-source Python Xarray Library" (PIs Scott Henderson, University of Washington; + Deepak Cherian, NCAR; Jessica Scheick, University of New Hampshire), and +1. [NCAR's Earth System Data Science Initiative](https://ncar.github.io/esds/). -This work was funded in part by NASA-ACCESS 80NSSC18M0156 "Community tools for analysis of NASA Earth Observing System -Data in the Cloud" (PI J. Hamman), and [NCAR's Earth System Data Science Initiative](https://ncar.github.io/esds/). It was motivated by many discussions in the [Pangeo](https://pangeo.io) community. ## Contents + ```{eval-rst} .. toctree:: :maxdepth: 1 + intro.md + aggregations.md + engines.md + arrays.md implementation.md - custom.md - api.rst + xarray.md user-stories.md + api.rst ``` diff --git a/docs/source/intro.md b/docs/source/intro.md new file mode 100644 index 000000000..4660b0cf9 --- /dev/null +++ b/docs/source/intro.md @@ -0,0 +1,186 @@ +--- +jupytext: + text_representation: + format_name: myst +kernelspec: + display_name: Python 3 + name: python3 +--- + +```{eval-rst} +.. currentmodule:: flox +``` + +# 10 minutes to flox + +## GroupBy single variable + +```{code-cell} +import numpy as np +import xarray as xr + +from flox.xarray import xarray_reduce + +labels = xr.DataArray( + [1, 2, 3, 1, 2, 3, 0, 0, 0], + dims="x", + name="label", +) +labels +``` + +### With numpy + +```{code-cell} +da = xr.DataArray( + np.ones((9,)), dims="x", name="array" +) +``` + +Apply the reduction using {py:func}`flox.xarray.xarray_reduce` specifying the reduction operation in `func` + +```{code-cell} +xarray_reduce(da, labels, func="sum") +``` + +### With dask + +Let's first chunk `da` and `labels` + +```{code-cell} +da_chunked = da.chunk(x=2) +labels_chunked = labels.chunk(x=3) +``` + +Grouping a dask array by a numpy array is unchanged + +```{code-cell} +xarray_reduce(da_chunked, labels, func="sum") +``` + +When grouping **by** a dask array, we need to specify the "expected group labels" on the output so we can construct the result DataArray. +Without the `expected_groups` kwarg, an error is raised + +```{code-cell} +--- +tags: [raises-exception] +--- +xarray_reduce(da_chunked, labels_chunked, func="sum") +``` + +Now we specify `expected_groups`: + +```{code-cell} +dask_result = xarray_reduce( + da_chunked, labels_chunked, func="sum", expected_groups=[0, 1, 2, 3], +) +dask_result +``` + +Note that any group labels not present in `expected_groups` will be ignored. +You can also provide `expected_groups` for the pure numpy GroupBy. + +```{code-cell} +numpy_result = xarray_reduce( + da, labels, func="sum", expected_groups=[0, 1, 2, 3], +) +numpy_result +``` + +The two are identical: + +```{code-cell} +numpy_result.identical(dask_result) +``` + +## Binning by a single variable + +For binning, specify the bin edges in `expected_groups` using {py:class}`pandas.IntervalIndex`: + +```{code-cell} +import pandas as pd + +xarray_reduce( + da, + labels, + func="sum", + expected_groups=pd.IntervalIndex.from_breaks([0.5, 1.5, 2.5, 6]), +) +``` + +Similarly for dask inputs + +```{code-cell} +xarray_reduce( + da_chunked, + labels_chunked, + func="sum", + expected_groups=pd.IntervalIndex.from_breaks([0.5, 1.5, 2.5, 6]), +) +``` + +For more control over the binning (which edge is closed), pass the appropriate kwarg to {py:class}`pandas.IntervalIndex`: + +```{code-cell} +xarray_reduce( + da_chunked, + labels_chunked, + func="sum", + expected_groups=pd.IntervalIndex.from_breaks([0.5, 1.5, 2.5, 6], closed="left"), +) +``` + +## Grouping by multiple variables + +```{code-cell} +arr = np.ones((4, 12)) +labels1 = np.array(["a", "a", "c", "c", "c", "b", "b", "c", "c", "b", "b", "f"]) +labels2 = np.array([1, 2, 2, 1]) + +da = xr.DataArray( + arr, dims=("x", "y"), coords={"labels2": ("x", labels2), "labels1": ("y", labels1)} +) +da +``` + +To group by multiple variables simply pass them as `*args`: + +```{code-cell} +xarray_reduce(da, "labels1", "labels2", func="sum") +``` + +## Histogramming (Binning by multiple variables) + +An unweighted histogram is simply a groupby multiple variables with count. + +```{code-cell} python +arr = np.ones((4, 12)) +labels1 = np.array(np.linspace(0, 10, 12)) +labels2 = np.array([1, 2, 2, 1]) + +da = xr.DataArray( + arr, dims=("x", "y"), coords={"labels2": ("x", labels2), "labels1": ("y", labels1)} +) +da +``` + +Specify bins in `expected_groups` + +```{code-cell} python +xarray_reduce( + da, + "labels1", + "labels2", + func="count", + expected_groups=( + pd.IntervalIndex.from_breaks([-0.5, 4.5, 6.5, 8.9]), # labels1 + pd.IntervalIndex.from_breaks([0.5, 1.5, 1.9]), # labels2 + ), +) +``` + +## Resampling + +Use the xarray interface i.e. `da.resample(time="M").mean()`. + +Optionally pass [`method="blockwise"`](method-blockwise): `da.resample(time="M").mean(method="blockwise")` diff --git a/docs/source/user-stories.md b/docs/source/user-stories.md index 2f190c63b..22b37939e 100644 --- a/docs/source/user-stories.md +++ b/docs/source/user-stories.md @@ -1,4 +1,4 @@ -# User Stories +# Tricks & Stories ```{eval-rst} .. toctree:: diff --git a/docs/source/user-stories/climatology-hourly.ipynb b/docs/source/user-stories/climatology-hourly.ipynb index 5fef37851..b17cdc1aa 100644 --- a/docs/source/user-stories/climatology-hourly.ipynb +++ b/docs/source/user-stories/climatology-hourly.ipynb @@ -25,7 +25,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "727f490e-906a-4537-ac5e-3c67985cd6d5", "metadata": {}, "outputs": [ @@ -55,7 +55,14 @@ } ], "source": [ + "import dask.array\n", + "import numpy as np\n", + "import pandas as pd\n", + "import xarray as xr\n", "from dask.distributed import Client\n", + "from distributed import performance_report\n", + "\n", + "import flox.xarray\n", "\n", "# Setup a local cluster.\n", "# By default this sets up 1 worker per core\n", @@ -65,7 +72,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "6085684f-cafa-450c-8448-d5c9c1cbb55f", "metadata": {}, "outputs": [ @@ -84,12 +91,7 @@ "source": [ "%load_ext watermark\n", "\n", - "import time\n", "\n", - "import dask.array\n", - "import numpy as np\n", - "import pandas as pd\n", - "import xarray as xr\n", "\n", "%watermark -iv" ] @@ -104,7 +106,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "2aa66559-b2dd-4b46-b32b-f1ce2270c3de", "metadata": {}, "outputs": [ @@ -614,7 +616,7 @@ " tp (time, latitude, longitude) float32 dask.array" ] }, - "execution_count": 3, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -624,9 +626,7 @@ " {\n", " \"tp\": (\n", " (\"time\", \"latitude\", \"longitude\"),\n", - " dask.array.ones(\n", - " (8760, 721, 1440), chunks=(744, 50, 1440), dtype=np.float32\n", - " ),\n", + " dask.array.ones((8760, 721, 1440), chunks=(744, 50, 1440), dtype=np.float32),\n", " )\n", " },\n", " coords={\"time\": pd.date_range(\"2021-01-01\", \"2021-12-31 23:59\", freq=\"H\")},\n", @@ -644,7 +644,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "ecc77698-5879-4b7c-ad97-891fb104d295", "metadata": {}, "outputs": [ @@ -1162,7 +1162,7 @@ "Dimensions without coordinates: latitude, longitude" ] }, - "execution_count": 4, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } @@ -1181,7 +1181,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "0a3da8e5-863a-4602-9176-0a9adc689563", "metadata": {}, "outputs": [ @@ -1663,31 +1663,27 @@ "Dimensions without coordinates: latitude, longitude" ] }, - "execution_count": 5, + "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "import flox.xarray\n", - "\n", "hourly = flox.xarray.xarray_reduce(ds.tp, ds.time.dt.hour, func=\"mean\")\n", "hourly" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "8aa1a641-1ce1-4264-96dc-d11bb1d4ab57", "metadata": {}, "outputs": [], - "source": [ - "from distributed import performance_report" - ] + "source": [] }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "e37c5aa2-c77a-4d87-8db4-5052c675c42d", "metadata": {}, "outputs": [], @@ -1709,11 +1705,7 @@ } ], "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, + "keep_output": true, "language_info": { "codemirror_mode": { "name": "ipython", @@ -1723,266 +1715,10 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.12" + "pygments_lexer": "ipython3" }, "mystnb": { "execution_mode": "off" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": { - "02bf99615dae4b7b9b2aac23acccc828": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": {} - }, - "06093fd4131d42749c5d32b149d36cbe": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": {} - }, - "23d59a300993407dabc70ab6282460ba": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "description_width": "" - } - }, - "2415e8902a9e4087827ebb98df678028": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "AccordionModel", - "state": { - "_titles": { - "0": "Manual Scaling", - "1": "Adaptive Scaling" - }, - "children": [ - "IPY_MODEL_fce763ee43d44833bfb73dc3ca34d18a", - "IPY_MODEL_5c81a669ef8d4e13921d9b6f3218fbe1" - ], - "layout": "IPY_MODEL_6e424b71aff3457baae281ef596e294a", - "selected_index": null - } - }, - "38ba388c3c144dd4af2d9487f9623f31": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ButtonStyleModel", - "state": {} - }, - "3eb1fff965764a2aa70f35e59754a6e5": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "description_width": "" - } - }, - "5bbcffef6cc04a6f893e5e8be12de433": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "layout": "IPY_MODEL_02bf99615dae4b7b9b2aac23acccc828", - "style": "IPY_MODEL_726a881ed9644cd988b37c70dbe1957b", - "value": "
\n
\n
\n
\n

LocalCluster

\n

2b898a97

\n \n \n \n \n \n \n \n \n \n \n \n \n \n\n\n \n
\n Dashboard: http://127.0.0.1:51613/status\n \n Workers: 4\n
\n Total threads: 4\n \n Total memory: 8.00 GiB\n
Status: runningUsing processes: True
\n\n
\n \n

Scheduler Info

\n
\n\n
\n
\n
\n
\n

Scheduler

\n

Scheduler-e88043e1-f96c-408b-828a-6133edf9383e

\n \n \n \n \n \n \n \n \n \n \n \n \n \n
\n Comm: tcp://127.0.0.1:51614\n \n Workers: 4\n
\n Dashboard: http://127.0.0.1:51613/status\n \n Total threads: 4\n
\n Started: 8 minutes ago\n \n Total memory: 8.00 GiB\n
\n
\n
\n\n
\n \n

Workers

\n
\n\n \n
\n
\n
\n
\n \n

Worker: 0

\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n\n \n\n
\n Comm: tcp://127.0.0.1:51625\n \n Total threads: 1\n
\n Dashboard: http://127.0.0.1:51632/status\n \n Memory: 2.00 GiB\n
\n Nanny: tcp://127.0.0.1:51618\n
\n Local directory: /Users/dcherian/work/python/flox/docs/source/user-stories/dask-worker-space/worker-sha7f1ls\n
\n
\n
\n
\n \n
\n
\n
\n
\n \n

Worker: 1

\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n\n \n\n
\n Comm: tcp://127.0.0.1:51626\n \n Total threads: 1\n
\n Dashboard: http://127.0.0.1:51639/status\n \n Memory: 2.00 GiB\n
\n Nanny: tcp://127.0.0.1:51619\n
\n Local directory: /Users/dcherian/work/python/flox/docs/source/user-stories/dask-worker-space/worker-o21y4jdf\n
\n
\n
\n
\n \n
\n
\n
\n
\n \n

Worker: 2

\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n\n \n\n
\n Comm: tcp://127.0.0.1:51631\n \n Total threads: 1\n
\n Dashboard: http://127.0.0.1:51640/status\n \n Memory: 2.00 GiB\n
\n Nanny: tcp://127.0.0.1:51617\n
\n Local directory: /Users/dcherian/work/python/flox/docs/source/user-stories/dask-worker-space/worker-ll8d_5ds\n
\n
\n
\n
\n \n
\n
\n
\n
\n \n

Worker: 3

\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n\n \n\n
\n Comm: tcp://127.0.0.1:51628\n \n Total threads: 1\n
\n Dashboard: http://127.0.0.1:51638/status\n \n Memory: 2.00 GiB\n
\n Nanny: tcp://127.0.0.1:51620\n
\n Local directory: /Users/dcherian/work/python/flox/docs/source/user-stories/dask-worker-space/worker-t_4kkml1\n
\n
\n
\n
\n \n\n
\n
\n\n
\n
\n
" - } - }, - "5c81a669ef8d4e13921d9b6f3218fbe1": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "children": [ - "IPY_MODEL_ebd6fbdbb6b149b8b71bc1adf4f98e8f", - "IPY_MODEL_e09755df3cd34c65adef354b74764926", - "IPY_MODEL_6d0480d5ac4243728c2e219060c4d160" - ], - "layout": "IPY_MODEL_b66ab102b9fc4ef69e3eb1a5a78f3211" - } - }, - "6a04758b6a5e4bbf8df42688a433ce7c": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": {} - }, - "6d0480d5ac4243728c2e219060c4d160": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ButtonModel", - "state": { - "description": "Adapt", - "layout": "IPY_MODEL_f47c6dced9324bfca691f320e4697911", - "style": "IPY_MODEL_38ba388c3c144dd4af2d9487f9623f31" - } - }, - "6e424b71aff3457baae281ef596e294a": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "min_width": "500px" - } - }, - "726a881ed9644cd988b37c70dbe1957b": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "description_width": "" - } - }, - "7d9c070ca8c8451086d0d8f977c3769f": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "description_width": "" - } - }, - "91ba64ec63f74f7dbe2aa552c53368ee": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "VBoxModel", - "state": { - "children": [ - "IPY_MODEL_987a6ab24d514f3f91c40bce527c23cc", - "IPY_MODEL_2415e8902a9e4087827ebb98df678028" - ], - "layout": "IPY_MODEL_6a04758b6a5e4bbf8df42688a433ce7c" - } - }, - "987a6ab24d514f3f91c40bce527c23cc": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HTMLModel", - "state": { - "layout": "IPY_MODEL_9a05be3122214bcebda0395a1685bd18", - "style": "IPY_MODEL_d427f7f692f947b69e66bdf3a799ffe5", - "value": "\n \n \n \n
Scaling mode: Manual
Workers: 4
\n " - } - }, - "99676d05a3504002a88bbcfa7dca2ab7": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ButtonStyleModel", - "state": {} - }, - "9a05be3122214bcebda0395a1685bd18": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": {} - }, - "a6fbe3d8ce864b40ac7ff3ed9cc28ee2": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "IntTextModel", - "state": { - "description": "Workers", - "layout": "IPY_MODEL_f47c6dced9324bfca691f320e4697911", - "step": 1, - "style": "IPY_MODEL_3eb1fff965764a2aa70f35e59754a6e5" - } - }, - "b30b5b56bbf24eea8658df01925e7cb9": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": {} - }, - "b66ab102b9fc4ef69e3eb1a5a78f3211": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": {} - }, - "d427f7f692f947b69e66bdf3a799ffe5": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "DescriptionStyleModel", - "state": { - "description_width": "" - } - }, - "e09755df3cd34c65adef354b74764926": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "IntTextModel", - "state": { - "description": "Maximum", - "layout": "IPY_MODEL_f47c6dced9324bfca691f320e4697911", - "step": 1, - "style": "IPY_MODEL_23d59a300993407dabc70ab6282460ba" - } - }, - "ebd6fbdbb6b149b8b71bc1adf4f98e8f": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "IntTextModel", - "state": { - "description": "Minimum", - "layout": "IPY_MODEL_f47c6dced9324bfca691f320e4697911", - "step": 1, - "style": "IPY_MODEL_7d9c070ca8c8451086d0d8f977c3769f" - } - }, - "f3fab32037ec4a8887a9d61036d93eed": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "ButtonModel", - "state": { - "description": "Scale", - "layout": "IPY_MODEL_f47c6dced9324bfca691f320e4697911", - "style": "IPY_MODEL_99676d05a3504002a88bbcfa7dca2ab7" - } - }, - "f47c6dced9324bfca691f320e4697911": { - "model_module": "@jupyter-widgets/base", - "model_module_version": "1.2.0", - "model_name": "LayoutModel", - "state": { - "width": "150px" - } - }, - "fc1dd8438def4d75acee8602c544248c": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "TabModel", - "state": { - "_titles": { - "0": "Status", - "1": "Scaling" - }, - "children": [ - "IPY_MODEL_5bbcffef6cc04a6f893e5e8be12de433", - "IPY_MODEL_91ba64ec63f74f7dbe2aa552c53368ee" - ], - "layout": "IPY_MODEL_b30b5b56bbf24eea8658df01925e7cb9" - } - }, - "fce763ee43d44833bfb73dc3ca34d18a": { - "model_module": "@jupyter-widgets/controls", - "model_module_version": "1.5.0", - "model_name": "HBoxModel", - "state": { - "children": [ - "IPY_MODEL_a6fbe3d8ce864b40ac7ff3ed9cc28ee2", - "IPY_MODEL_f3fab32037ec4a8887a9d61036d93eed" - ], - "layout": "IPY_MODEL_06093fd4131d42749c5d32b149d36cbe" - } - } - }, - "version_major": 2, - "version_minor": 0 - } } }, "nbformat": 4, diff --git a/docs/source/user-stories/climatology.ipynb b/docs/source/user-stories/climatology.ipynb index 3fd7ae55d..bc867eda4 100644 --- a/docs/source/user-stories/climatology.ipynb +++ b/docs/source/user-stories/climatology.ipynb @@ -22,12 +22,13 @@ "outputs": [], "source": [ "import dask.array\n", - "import flox\n", - "import flox.xarray\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import pandas as pd\n", - "import xarray as xr" + "import xarray as xr\n", + "\n", + "import flox\n", + "import flox.xarray" ] }, { @@ -49,9 +50,7 @@ "oisst = xr.DataArray(\n", " dask.array.ones((14532, 720, 1440), chunks=(20, -1, -1)),\n", " dims=(\"time\", \"lat\", \"lon\"),\n", - " coords={\n", - " \"time\": pd.date_range(\"1981-09-01 12:00\", \"2021-06-14 12:00\", freq=\"D\")\n", - " },\n", + " coords={\"time\": pd.date_range(\"1981-09-01 12:00\", \"2021-06-14 12:00\", freq=\"D\")},\n", " name=\"sst\",\n", ")\n", "oisst" @@ -177,7 +176,7 @@ "flox.core.find_group_cohorts(\n", " labels=oisst.time.dt.dayofyear.data,\n", " chunks=(oisst.chunksizes[\"time\"],),\n", - ")" + ").values()" ] }, { @@ -300,7 +299,7 @@ "flox.core.find_group_cohorts(\n", " labels=rechunked.time.dt.dayofyear.data,\n", " chunks=(rechunked.chunksizes[\"time\"],),\n", - ")" + ").values()" ] }, { @@ -319,9 +318,7 @@ "metadata": {}, "outputs": [], "source": [ - "flox.xarray.xarray_reduce(\n", - " rechunked, rechunked.time.dt.dayofyear, func=\"mean\", method=\"cohorts\"\n", - ")" + "flox.xarray.xarray_reduce(rechunked, rechunked.time.dt.dayofyear, func=\"mean\", method=\"cohorts\")" ] }, { @@ -362,11 +359,6 @@ } ], "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, "language_info": { "codemirror_mode": { "name": "ipython", @@ -376,15 +368,7 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.1" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": {}, - "version_major": 2, - "version_minor": 0 - } + "pygments_lexer": "ipython3" } }, "nbformat": 4, diff --git a/docs/source/user-stories/custom-aggregations.ipynb b/docs/source/user-stories/custom-aggregations.ipynb index d76c20f23..7b4167b98 100644 --- a/docs/source/user-stories/custom-aggregations.ipynb +++ b/docs/source/user-stories/custom-aggregations.ipynb @@ -21,393 +21,19 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "8c6fcc42-b081-44fa-acf7-a95ec4ed75d2", "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
<xarray.DataArray 'label' (profile: 28397)>\n",
-       "array([3, 2, 4, ..., 4, 1, 6])\n",
-       "Coordinates:\n",
-       "    lat      (profile) float64 -37.18 11.79 -40.99 17.31 ... -20.21 9.844 63.56\n",
-       "    lon      (profile) float64 130.9 53.86 66.59 161.0 ... -140.2 -30.68 -44.73\n",
-       "Dimensions without coordinates: profile
" - ], - "text/plain": [ - "\n", - "array([3, 2, 4, ..., 4, 1, 6])\n", - "Coordinates:\n", - " lat (profile) float64 -37.18 11.79 -40.99 17.31 ... -20.21 9.844 63.56\n", - " lon (profile) float64 130.9 53.86 66.59 161.0 ... -140.2 -30.68 -44.73\n", - "Dimensions without coordinates: profile" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "import flox.xarray\n", - "import matplotlib.pyplot as plt\n", "import numpy as np\n", + "import numpy_groupies as npg\n", "import xarray as xr\n", "\n", + "import flox.xarray\n", + "from flox import Aggregation\n", + "from flox.aggregations import mean\n", + "\n", "# define latitude and longitude bins\n", "binsize = 1.0 # 1Β°x1Β° bins\n", "lon_min, lon_max, lat_min, lat_max = [-180, 180, -65, 65]\n", @@ -447,37 +73,10 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "c0a7f29f-311c-41fd-b03b-33ba7ffccfc6", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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\n", - "text/plain": [ - "
" - ] - }, - "metadata": { - "image/png": { - "height": 266, - "width": 378 - }, - "needs_background": "light" - }, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "binned_mean = flox.xarray.xarray_reduce(\n", " da,\n", @@ -507,36 +106,11 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "574b93ef-dd73-4a98-bd53-69119d5d97c0", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - }, - { - "data": { - "text/plain": [ - "mean, fill: [dict_values([, (0, 0)])], dtype: {'mean': , 'intermediate': (None, )}\n", - "chunk: ('sum', 'nanlen')\n", - "combine: ('sum', 'sum')\n", - "aggregate: sum\n", - "finalize: at 0x10966c670>\n", - "min_count: None" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "from flox.aggregations import mean\n", - "\n", "print(type(mean))\n", "mean" ] @@ -622,18 +196,12 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "05b8a1e5-e865-4b25-8540-df5aa6c218e9", "metadata": {}, "outputs": [], "source": [ - "import numpy_groupies as npg\n", - "\n", - "\n", - "def grouped_median(\n", - " group_idx, array, *, axis=-1, size=None, fill_value=None, dtype=None\n", - "):\n", - "\n", + "def grouped_median(group_idx, array, *, axis=-1, size=None, fill_value=None, dtype=None):\n", " return npg.aggregate_numpy.aggregate(\n", " group_idx,\n", " array,\n", @@ -655,29 +223,11 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "07c0fc82-c77b-4472-9de7-3c4a7cf3e07e", "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "median, fill: [dict_values([, (-1,)])], dtype: {'median': None, 'intermediate': (None,)}\n", - "chunk: (None,)\n", - "combine: (None,)\n", - "aggregate: None\n", - "finalize: . at 0x10ec78550>\n", - "min_count: None" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "from flox import Aggregation\n", - "\n", "agg_median = Aggregation(\n", " name=\"median\",\n", " numpy=grouped_median,\n", @@ -698,529 +248,10 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "df85a390-99dd-432f-b248-6160935deb52", "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "\n", - "
<xarray.DataArray 'label' (lat_bins: 129, lon_bins: 359)>\n",
-       "array([[3. , 1. , nan, ..., nan, nan, nan],\n",
-       "       [nan, 4. , 2.5, ..., nan, 0. , 3.5],\n",
-       "       [nan, 2. , nan, ..., 5. , nan, nan],\n",
-       "       ...,\n",
-       "       [nan, 1. , 5. , ..., 2. , nan, 6. ],\n",
-       "       [nan, 3. , 5. , ..., 1. , 2. , 1. ],\n",
-       "       [2. , 6. , 5. , ..., nan, 5. , 6. ]])\n",
-       "Coordinates:\n",
-       "  * lat_bins  (lat_bins) object (-65.0, -64.0] (-64.0, -63.0] ... (63.0, 64.0]\n",
-       "  * lon_bins  (lon_bins) object (-180.0, -179.0] ... (178.0, 179.0]
" - ], - "text/plain": [ - "\n", - "array([[3. , 1. , nan, ..., nan, nan, nan],\n", - " [nan, 4. , 2.5, ..., nan, 0. , 3.5],\n", - " [nan, 2. , nan, ..., 5. , nan, nan],\n", - " ...,\n", - " [nan, 1. , 5. , ..., 2. , nan, 6. ],\n", - " [nan, 3. , 5. , ..., 1. , 2. , 1. ],\n", - " [2. , 6. , 5. , ..., nan, 5. , 6. ]])\n", - "Coordinates:\n", - " * lat_bins (lat_bins) object (-65.0, -64.0] (-64.0, -63.0] ... (63.0, 64.0]\n", - " * lon_bins (lon_bins) object (-180.0, -179.0] ... (178.0, 179.0]" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "flox.xarray.xarray_reduce(\n", " da,\n", @@ -1235,11 +266,6 @@ } ], "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, "language_info": { "codemirror_mode": { "name": "ipython", @@ -1249,15 +275,7 @@ "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.10" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": {}, - "version_major": 2, - "version_minor": 0 - } + "pygments_lexer": "ipython3" } }, "nbformat": 4, diff --git a/docs/source/user-stories/overlaps.md b/docs/source/user-stories/overlaps.md index c854f4c2b..eebcd47a3 100644 --- a/docs/source/user-stories/overlaps.md +++ b/docs/source/user-stories/overlaps.md @@ -19,6 +19,7 @@ globally, as well as over the Atlantic, and the Indo-Pacific. Generally group-by groups. In this example, the "global" group overlaps with the "Indo-Pacific" and "Atlantic" groups. Below we consider a simplified version of this problem. Consider the following labels: + ```{code-cell} import numpy as np import xarray as xr @@ -34,10 +35,12 @@ labels ``` These labels are non-overlapping. So when we reduce this data array over those labels along `x` + ```{code-cell} da = xr.ones_like(labels) da ``` + we get (note the reduction over `x` is implicit here): ```{code-cell} @@ -47,6 +50,7 @@ xarray_reduce(da, labels, func="sum") Now let's _also_ calculate the `sum` where `labels` is either `1` or `2`. We could easily compute this using the grouped result but here we use this simple example for illustration. The trick is to add a new dimension with new labels (here `4`) in the appropriate locations. + ```{code-cell} # assign 4 where label == 1 or 2, and -1 otherwise newlabels = xr.where(labels.isin([1, 2]), 4, -1) @@ -59,6 +63,7 @@ expanded Now we reduce over `x` _and_ the new dimension `y` (again implicitly) to get the appropriate sum under `label=4` (and `label=-1`). We can discard the value accumulated under `label=-1` later. + ```{code-cell} xarray_reduce(da, expanded, func="sum") ``` @@ -66,6 +71,7 @@ xarray_reduce(da, expanded, func="sum") This way we compute all the reductions we need, in a single pass over the data. This technique generalizes to more complicated aggregations. The trick is to + - generate appropriate labels - concatenate these new labels along a new dimension (`y`) absent on the object being reduced (`da`), and - reduce over that new dimension in addition to any others. diff --git a/docs/source/xarray.md b/docs/source/xarray.md new file mode 100644 index 000000000..1877079cf --- /dev/null +++ b/docs/source/xarray.md @@ -0,0 +1,32 @@ +(xarray)= + +# Xarray + +Xarray will use flox by default (if installed) for DataArrays containing numpy and dask arrays. The default choice is `method="cohorts"` which generalizes +the best. Pass flox-specific kwargs to the specific reduction method: + +```python +ds.groupby("time.month").mean(method="map-reduce", engine="flox") +ds.groupby_bins("lon", bins=[0, 10, 20]).mean(method="map-reduce") +ds.resample(time="M").mean(method="blockwise") +``` + +Xarray's GroupBy operations are currently limited: + +1. One can only group by a single variable. +1. When grouping by a dask array, that array will be computed to discover the unique group labels, and their locations + +These limitations can be avoided by using {py:func}`flox.xarray.xarray_reduce` which allows grouping by multiple variables, lazy grouping by dask variables, +as well as an arbitrary combination of categorical grouping and binning. For example, + +```python +flox.xarray.xarray_reduce( + ds, + ds.time.dt.month, + ds.lon, + func="mean", + expected_groups=[None, [0, 10, 20]], + isbin=[False, True], + method="map-reduce", +) +``` diff --git a/flox/__init__.py b/flox/__init__.py index 5647a4ee0..cc44cbca0 100644 --- a/flox/__init__.py +++ b/flox/__init__.py @@ -5,15 +5,14 @@ from .aggregations import Aggregation # noqa from .core import groupby_reduce, rechunk_for_blockwise, rechunk_for_cohorts # noqa -try: - from importlib.metadata import version as _version -except ImportError: - # if the fallback library is missing, we are doomed. - from importlib_metadata import version as _version # type: ignore[no-redef] -try: - __version__ = _version("flox") -except Exception: - # Local copy or not installed with setuptools. - # Disable minimum version checks on downstream libraries. +def _get_version(): __version__ = "999" + try: + from ._version import __version__ + except ImportError: + pass + return __version__ + + +__version__ = _get_version() diff --git a/flox/aggregate_flox.py b/flox/aggregate_flox.py index 62a760653..4df3f77a4 100644 --- a/flox/aggregate_flox.py +++ b/flox/aggregate_flox.py @@ -77,7 +77,6 @@ def _nan_grouped_op(group_idx, array, func, fillna, *args, **kwargs): def sum_of_squares(group_idx, array, *, axis=-1, size=None, fill_value=None, dtype=None): - return sum( group_idx, array**2, @@ -107,7 +106,8 @@ def mean(group_idx, array, *, axis=-1, size=None, fill_value=None, dtype=None): if fill_value is None: fill_value = 0 out = sum(group_idx, array, axis=axis, size=size, dtype=dtype, fill_value=fill_value) - out /= nanlen(group_idx, array, size=size, axis=axis, fill_value=0) + with np.errstate(invalid="ignore", divide="ignore"): + out /= nanlen(group_idx, array, size=size, axis=axis, fill_value=0) return out @@ -115,5 +115,6 @@ def nanmean(group_idx, array, *, axis=-1, size=None, fill_value=None, dtype=None if fill_value is None: fill_value = 0 out = nansum(group_idx, array, size=size, axis=axis, dtype=dtype, fill_value=fill_value) - out /= nanlen(group_idx, array, size=size, axis=axis, fill_value=0) + with np.errstate(invalid="ignore", divide="ignore"): + out /= nanlen(group_idx, array, size=size, axis=axis, fill_value=0) return out diff --git a/flox/aggregate_npg.py b/flox/aggregate_npg.py index 8015f67b5..30e0eb257 100644 --- a/flox/aggregate_npg.py +++ b/flox/aggregate_npg.py @@ -9,14 +9,41 @@ def _get_aggregate(engine): def sum_of_squares( - group_idx, array, engine, *, axis=-1, func="sum", size=None, fill_value=None, dtype=None + group_idx, + array, + engine, + *, + axis=-1, + size=None, + fill_value=None, + dtype=None, ): + return _get_aggregate(engine).aggregate( + group_idx, + array, + axis=axis, + func="sumofsquares", + size=size, + fill_value=fill_value, + dtype=dtype, + ) + +def nansum_of_squares( + group_idx, + array, + engine, + *, + axis=-1, + size=None, + fill_value=None, + dtype=None, +): return _get_aggregate(engine).aggregate( group_idx, - array**2, + array, axis=axis, - func=func, + func="nansumofsquares", size=size, fill_value=fill_value, dtype=dtype, @@ -55,19 +82,6 @@ def nanprod(group_idx, array, engine, *, axis=-1, size=None, fill_value=None, dt ) -def nansum_of_squares(group_idx, array, engine, *, axis=-1, size=None, fill_value=None, dtype=None): - return sum_of_squares( - group_idx, - array, - engine=engine, - func="nansum", - size=size, - fill_value=fill_value, - axis=axis, - dtype=dtype, - ) - - def _len(group_idx, array, engine, *, func, axis=-1, size=None, fill_value=None, dtype=None): result = _get_aggregate(engine).aggregate( group_idx, diff --git a/flox/aggregations.py b/flox/aggregations.py index a9be99ba0..21ac9925b 100644 --- a/flox/aggregations.py +++ b/flox/aggregations.py @@ -1,12 +1,19 @@ from __future__ import annotations import copy +import warnings from functools import partial +from typing import TYPE_CHECKING, Any, Callable, TypedDict import numpy as np import numpy_groupies as npg +from numpy.typing import DTypeLike -from . import aggregate_flox, aggregate_npg, xrdtypes as dtypes, xrutils +from . import aggregate_flox, aggregate_npg, xrutils +from . import xrdtypes as dtypes + +if TYPE_CHECKING: + FuncTuple = tuple[Callable | str, ...] def _is_arg_reduction(func: str | Aggregation) -> bool: @@ -17,6 +24,17 @@ def _is_arg_reduction(func: str | Aggregation) -> bool: return False +class AggDtypeInit(TypedDict): + final: DTypeLike | None + intermediate: tuple[DTypeLike, ...] + + +class AggDtype(TypedDict): + final: np.dtype + numpy: tuple[np.dtype | type[np.intp], ...] + intermediate: tuple[np.dtype | type[np.intp], ...] + + def generic_aggregate( group_idx, array, @@ -48,7 +66,7 @@ def generic_aggregate( method_ = getattr(aggregate_npg, func) method = partial(method_, engine=engine) except AttributeError: - aggregate = npg.aggregate_np if engine == "numpy" else npg.aggregate_nb + aggregate = aggregate_npg._get_aggregate(engine).aggregate method = partial(aggregate, func=func) else: raise ValueError( @@ -57,12 +75,15 @@ def generic_aggregate( group_idx = np.asarray(group_idx, like=array) - return method( - group_idx, array, axis=axis, size=size, fill_value=fill_value, dtype=dtype, **kwargs - ) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", r"All-NaN (slice|axis) encountered") + result = method( + group_idx, array, axis=axis, size=size, fill_value=fill_value, dtype=dtype, **kwargs + ) + return result -def _normalize_dtype(dtype, array_dtype, fill_value=None): +def _normalize_dtype(dtype: DTypeLike, array_dtype: np.dtype, fill_value=None) -> np.dtype: if dtype is None: dtype = array_dtype if dtype is np.floating: @@ -108,16 +129,16 @@ def __init__( self, name, *, - numpy=None, - chunk, - combine, - preprocess=None, - aggregate=None, - finalize=None, + numpy: str | FuncTuple | None = None, + chunk: str | FuncTuple | None, + combine: str | FuncTuple | None, + preprocess: Callable | None = None, + aggregate: Callable | None = None, + finalize: Callable | None = None, fill_value=None, final_fill_value=dtypes.NA, dtypes=None, - final_dtype=None, + final_dtype: DTypeLike | None = None, reduction_type="reduce", ): """ @@ -167,15 +188,17 @@ def __init__( self.preprocess = preprocess # Use "chunk_reduce" or "chunk_argreduce" self.reduction_type = reduction_type - self.numpy = (numpy,) if numpy else (self.name,) + self.numpy: FuncTuple = (numpy,) if numpy else (self.name,) # initialize blockwise reduction - self.chunk = _atleast_1d(chunk) + self.chunk: FuncTuple = _atleast_1d(chunk) # how to aggregate results after first round of reduction - self.combine = _atleast_1d(combine) + self.combine: FuncTuple = _atleast_1d(combine) + # simpler reductions used with the "simple combine" algorithm + self.simple_combine: tuple[Callable, ...] = () # final aggregation - self.aggregate = aggregate if aggregate else self.combine[0] + self.aggregate: Callable | str = aggregate if aggregate else self.combine[0] # finalize results (see mean) - self.finalize = finalize if finalize else lambda x: x + self.finalize: Callable | None = finalize self.fill_value = {} # This is used for the final reindexing @@ -185,13 +208,15 @@ def __init__( # They should make sense when aggregated together with results from other blocks self.fill_value["intermediate"] = self._normalize_dtype_fill_value(fill_value, "fill_value") - self.dtype = {} - self.dtype[name] = final_dtype - self.dtype["intermediate"] = self._normalize_dtype_fill_value(dtypes, "dtype") + self.dtype_init: AggDtypeInit = { + "final": final_dtype, + "intermediate": self._normalize_dtype_fill_value(dtypes, "dtype"), + } + self.dtype: AggDtype = None # type: ignore # The following are set by _initialize_aggregation - self.finalize_kwargs = {} - self.min_count = None + self.finalize_kwargs: dict[Any, Any] = {} + self.min_count: int = 0 def _normalize_dtype_fill_value(self, value, name): value = _atleast_1d(value) @@ -216,15 +241,15 @@ def __dask_tokenize__(self): self.dtype, ) - def __repr__(self): + def __repr__(self) -> str: return "\n".join( ( - f"{self.name}, fill: {np.unique(self.fill_value.values())}, dtype: {self.dtype}", - f"chunk: {self.chunk}", - f"combine: {self.combine}", - f"aggregate: {self.aggregate}", - f"finalize: {self.finalize}", - f"min_count: {self.min_count}", + f"{self.name!r}, fill: {self.fill_value.values()!r}, dtype: {self.dtype}", + f"chunk: {self.chunk!r}", + f"combine: {self.combine!r}", + f"aggregate: {self.aggregate!r}", + f"finalize: {self.finalize!r}", + f"min_count: {self.min_count!r}", ) ) @@ -252,11 +277,18 @@ def __repr__(self): fill_value=1, final_fill_value=dtypes.NA, ) + + +def _mean_finalize(sum_, count): + with np.errstate(invalid="ignore", divide="ignore"): + return sum_ / count + + mean = Aggregation( "mean", chunk=("sum", "nanlen"), combine=("sum", "sum"), - finalize=lambda sum_, count: sum_ / count, + finalize=_mean_finalize, fill_value=(0, 0), dtypes=(None, np.intp), final_dtype=np.floating, @@ -265,7 +297,7 @@ def __repr__(self): "nanmean", chunk=("nansum", "nanlen"), combine=("sum", "sum"), - finalize=lambda sum_, count: sum_ / count, + finalize=_mean_finalize, fill_value=(0, 0), dtypes=(None, np.intp), final_dtype=np.floating, @@ -274,7 +306,8 @@ def __repr__(self): # TODO: fix this for complex numbers def _var_finalize(sumsq, sum_, count, ddof=0): - result = (sumsq - (sum_**2 / count)) / (count - ddof) + with np.errstate(invalid="ignore", divide="ignore"): + result = (sumsq - (sum_**2 / count)) / (count - ddof) result[count <= ddof] = np.nan return result @@ -361,6 +394,10 @@ def _zip_index(array_, idx_): ) +def _pick_second(*x): + return x[1] + + argmax = Aggregation( "argmax", preprocess=argreduce_preprocess, @@ -369,7 +406,7 @@ def _zip_index(array_, idx_): reduction_type="argreduce", fill_value=(dtypes.NINF, 0), final_fill_value=-1, - finalize=lambda *x: x[1], + finalize=_pick_second, dtypes=(None, np.intp), final_dtype=np.intp, ) @@ -382,7 +419,7 @@ def _zip_index(array_, idx_): reduction_type="argreduce", fill_value=(dtypes.INF, 0), final_fill_value=-1, - finalize=lambda *x: x[1], + finalize=_pick_second, dtypes=(None, np.intp), final_dtype=np.intp, ) @@ -393,9 +430,9 @@ def _zip_index(array_, idx_): chunk=("nanmax", "nanargmax"), # order is important combine=("max", "argmax"), reduction_type="argreduce", - fill_value=(dtypes.NINF, -1), + fill_value=(dtypes.NINF, 0), final_fill_value=-1, - finalize=lambda *x: x[1], + finalize=_pick_second, dtypes=(None, np.intp), final_dtype=np.intp, ) @@ -406,9 +443,9 @@ def _zip_index(array_, idx_): chunk=("nanmin", "nanargmin"), # order is important combine=("min", "argmin"), reduction_type="argreduce", - fill_value=(dtypes.INF, -1), + fill_value=(dtypes.INF, 0), final_fill_value=-1, - finalize=lambda *x: x[1], + finalize=_pick_second, dtypes=(None, np.intp), final_dtype=np.intp, ) @@ -476,8 +513,8 @@ def _initialize_aggregation( dtype, array_dtype, fill_value, - min_count: int | None, - finalize_kwargs, + min_count: int, + finalize_kwargs: dict[Any, Any] | None, ) -> Aggregation: if not isinstance(func, Aggregation): try: @@ -495,24 +532,30 @@ def _initialize_aggregation( # np.dtype(None) == np.dtype("float64")!!! # so check for not None - if dtype is not None and not isinstance(dtype, np.dtype): - dtype = np.dtype(dtype) + dtype_: np.dtype | None = ( + np.dtype(dtype) if dtype is not None and not isinstance(dtype, np.dtype) else dtype + ) - agg.dtype[func] = _normalize_dtype(dtype or agg.dtype[func], array_dtype, fill_value) - agg.dtype["numpy"] = (agg.dtype[func],) - agg.dtype["intermediate"] = [ - _normalize_dtype(int_dtype, np.result_type(array_dtype, agg.dtype[func]), int_fv) - if int_dtype is None - else int_dtype - for int_dtype, int_fv in zip(agg.dtype["intermediate"], agg.fill_value["intermediate"]) - ] + final_dtype = _normalize_dtype(dtype_ or agg.dtype_init["final"], array_dtype, fill_value) + agg.dtype = { + "final": final_dtype, + "numpy": (final_dtype,), + "intermediate": tuple( + _normalize_dtype(int_dtype, np.result_type(array_dtype, final_dtype), int_fv) + if int_dtype is None + else np.dtype(int_dtype) + for int_dtype, int_fv in zip( + agg.dtype_init["intermediate"], agg.fill_value["intermediate"] + ) + ), + } # Replace sentinel fill values according to dtype agg.fill_value["intermediate"] = tuple( _get_fill_value(dt, fv) for dt, fv in zip(agg.dtype["intermediate"], agg.fill_value["intermediate"]) ) - agg.fill_value[func] = _get_fill_value(agg.dtype[func], agg.fill_value[func]) + agg.fill_value[func] = _get_fill_value(agg.dtype["final"], agg.fill_value[func]) fv = fill_value if fill_value is not None else agg.fill_value[agg.name] if _is_arg_reduction(agg): @@ -530,7 +573,7 @@ def _initialize_aggregation( # absent in one block, but present in another block # We set it for numpy to get nansum, nanprod tests to pass # where the identity element is 0, 1 - if min_count is not None: + if min_count > 0: agg.min_count = min_count agg.chunk += ("nanlen",) agg.numpy += ("nanlen",) @@ -539,5 +582,19 @@ def _initialize_aggregation( agg.fill_value["numpy"] += (0,) agg.dtype["intermediate"] += (np.intp,) agg.dtype["numpy"] += (np.intp,) + else: + agg.min_count = 0 + + simple_combine: list[Callable] = [] + for combine in agg.combine: + if isinstance(combine, str): + if combine in ["nanfirst", "nanlast"]: + simple_combine.append(getattr(xrutils, combine)) + else: + simple_combine.append(getattr(np, combine)) + else: + simple_combine.append(combine) + + agg.simple_combine = tuple(simple_combine) return agg diff --git a/flox/core.py b/flox/core.py index 6bd390137..f8f700f99 100644 --- a/flox/core.py +++ b/flox/core.py @@ -4,10 +4,22 @@ import itertools import math import operator +import sys +import warnings from collections import namedtuple from functools import partial, reduce from numbers import Integral -from typing import TYPE_CHECKING, Any, Callable, Dict, Literal, Mapping, Sequence, Union +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Literal, + Mapping, + Sequence, + Union, + overload, +) import numpy as np import numpy_groupies as npg @@ -26,9 +38,28 @@ from .xrutils import is_duck_array, is_duck_dask_array, isnull if TYPE_CHECKING: + try: + if sys.version_info < (3, 11): + from typing_extensions import Unpack + else: + from typing import Unpack + except (ModuleNotFoundError, ImportError): + Unpack: Any # type: ignore + import dask.array.Array as DaskArray - T_ExpectedGroups = Union[Sequence, np.ndarray, pd.Index] + T_DuckArray = Union[np.ndarray, DaskArray] # Any ? + T_By = T_DuckArray + T_Bys = tuple[T_By, ...] + T_ExpectIndex = Union[pd.Index] + T_ExpectIndexTuple = tuple[T_ExpectIndex, ...] + T_ExpectIndexOpt = Union[T_ExpectIndex, None] + T_ExpectIndexOptTuple = tuple[T_ExpectIndexOpt, ...] + T_Expect = Union[Sequence, np.ndarray, T_ExpectIndex] + T_ExpectTuple = tuple[T_Expect, ...] + T_ExpectOpt = Union[Sequence, np.ndarray, T_ExpectIndexOpt] + T_ExpectOptTuple = tuple[T_ExpectOpt, ...] + T_ExpectedGroups = Union[T_Expect, T_ExpectOptTuple] T_ExpectedGroupsOpt = Union[T_ExpectedGroups, None] T_Func = Union[str, Callable] T_Funcs = Union[T_Func, Sequence[T_Func]] @@ -39,8 +70,7 @@ T_Dtypes = Union[np.typing.DTypeLike, Sequence[np.typing.DTypeLike], None] T_FillValues = Union[np.typing.ArrayLike, Sequence[np.typing.ArrayLike], None] T_Engine = Literal["flox", "numpy", "numba"] - T_MethodCohorts = Literal["cohorts", "split-reduce"] - T_Method = Literal["map-reduce", "blockwise", T_MethodCohorts] + T_Method = Literal["map-reduce", "blockwise", "cohorts"] T_IsBins = Union[bool | Sequence[bool]] @@ -68,7 +98,11 @@ def _is_minmax_reduction(func: T_Agg) -> bool: ) -def _get_expected_groups(by, sort: bool) -> pd.Index: +def _is_first_last_reduction(func: T_Agg) -> bool: + return isinstance(func, str) and func in ["nanfirst", "nanlast", "first", "last"] + + +def _get_expected_groups(by: T_By, sort: bool) -> T_ExpectIndex: if is_duck_dask_array(by): raise ValueError("Please provide expected_groups if not grouping by a numpy array.") flatby = by.reshape(-1) @@ -76,7 +110,7 @@ def _get_expected_groups(by, sort: bool) -> pd.Index: return _convert_expected_groups_to_index((expected,), isbin=(False,), sort=sort)[0] -def _get_chunk_reduction(reduction_type: str) -> Callable: +def _get_chunk_reduction(reduction_type: Literal["reduce", "argreduce"]) -> Callable: if reduction_type == "reduce": return chunk_reduce elif reduction_type == "argreduce": @@ -120,7 +154,7 @@ def _get_optimal_chunks_for_groups(chunks, labels): firstidx = first_indexes[labels_at_chunk_bounds] newchunkidx = [0] - for c, f, l in zip(chunkidx, firstidx, lastidx): + for c, f, l in zip(chunkidx, firstidx, lastidx): # noqa Ξ”f = abs(c - f) Ξ”l = abs(c - l) if c == 0 or newchunkidx[-1] > l: @@ -137,14 +171,14 @@ def _get_optimal_chunks_for_groups(chunks, labels): return tuple(newchunks) -def _unique(a): +def _unique(a: np.ndarray) -> np.ndarray: """Much faster to use pandas unique and sort the results. np.unique sorts before uniquifying and is slow.""" - return np.sort(pd.unique(a)) + return np.sort(pd.unique(a.reshape(-1))) @memoize -def find_group_cohorts(labels, chunks, merge: bool = True): +def find_group_cohorts(labels, chunks, merge: bool = True) -> dict: """ Finds groups labels that occur together aka "cohorts" @@ -161,8 +195,6 @@ def find_group_cohorts(labels, chunks, merge: bool = True): merge : bool, optional Attempt to merge cohorts when one cohort's chunks are a subset of another cohort's chunks. - method : ["split-reduce", "cohorts"], optional - Which method are we using? Returns ------- @@ -179,6 +211,7 @@ def find_group_cohorts(labels, chunks, merge: bool = True): axis = range(-labels.ndim, 0) # Easier to create a dask array and use the .blocks property array = dask.array.ones(tuple(sum(c) for c in chunks), chunks=chunks) + labels = np.broadcast_to(labels, array.shape[-labels.ndim :]) # Iterate over each block and create a new block of same shape with "chunk number" shape = tuple(array.blocks.shape[ax] for ax in axis) @@ -190,8 +223,13 @@ def find_group_cohorts(labels, chunks, merge: bool = True): raveled = labels.reshape(-1) # these are chunks where a label is present label_chunks = pd.Series(which_chunk).groupby(raveled).unique() + # These invert the label_chunks mapping so we know which labels occur together. - chunks_cohorts = tlz.groupby(lambda x: tuple(label_chunks.get(x)), label_chunks.keys()) + def invert(x) -> tuple[np.ndarray, ...]: + arr = label_chunks.get(x) + return tuple(arr) # type: ignore [arg-type] # pandas issue? + + chunks_cohorts = tlz.groupby(invert, label_chunks.keys()) if merge: # First sort by number of chunks occupied by cohort @@ -231,14 +269,14 @@ def find_group_cohorts(labels, chunks, merge: bool = True): def rechunk_for_cohorts( - array, + array: DaskArray, axis: T_Axis, - labels, - force_new_chunk_at, - chunksize=None, - ignore_old_chunks=False, - debug=False, -): + labels: np.ndarray, + force_new_chunk_at: Sequence, + chunksize: int | None = None, + ignore_old_chunks: bool = False, + debug: bool = False, +) -> DaskArray: """ Rechunks array so that each new chunk contains groups that always occur together. @@ -257,7 +295,7 @@ def rechunk_for_cohorts( Labels at which we always start a new chunk. For the example ``labels`` array, this would be `1`. chunksize : int, optional - nominal chunk size. Chunk size is exceded when the label + nominal chunk size. Chunk size is exceeded when the label in ``force_new_chunk_at`` is less than ``chunksize//2`` elements away. If None, uses median chunksize along axis. @@ -326,10 +364,10 @@ def rechunk_for_cohorts( return array.rechunk({axis: newchunks}) -def rechunk_for_blockwise(array, axis: T_Axis, labels): +def rechunk_for_blockwise(array: DaskArray, axis: T_Axis, labels: np.ndarray) -> DaskArray: """ Rechunks array so that group boundaries line up with chunk boundaries, allowing - embarassingly parallel group reductions. + embarrassingly parallel group reductions. This only works when the groups are sequential (e.g. labels = ``[0,0,0,1,1,1,1,2,2]``). @@ -349,7 +387,7 @@ def rechunk_for_blockwise(array, axis: T_Axis, labels): DaskArray Rechunked array """ - labels = factorize_((labels,), axis=None)[0] + labels = factorize_((labels,), axes=())[0] chunks = array.chunks[axis] newchunks = _get_optimal_chunks_for_groups(chunks, labels) if newchunks == chunks: @@ -359,9 +397,13 @@ def rechunk_for_blockwise(array, axis: T_Axis, labels): def reindex_( - array: np.ndarray, from_, to, fill_value=None, axis: T_Axis = -1, promote: bool = False + array: np.ndarray, + from_, + to, + fill_value: Any = None, + axis: T_Axis = -1, + promote: bool = False, ) -> np.ndarray: - if not isinstance(to, pd.Index): if promote: to = pd.Index(to) @@ -388,7 +430,7 @@ def reindex_( ) idx = from_.get_indexer(to) indexer = [slice(None, None)] * array.ndim - indexer[axis] = idx # type: ignore + indexer[axis] = idx reindexed = array[tuple(indexer)] if any(idx == -1): if fill_value is None: @@ -416,18 +458,58 @@ def offset_labels(labels: np.ndarray, ngroups: int) -> tuple[np.ndarray, int]: ) # -1 indicates NaNs. preserve these otherwise we aggregate in the wrong groups! offset[labels == -1] = -1 - size: int = math.prod(labels.shape[:-1]) * ngroups # type: ignore + size: int = math.prod(labels.shape[:-1]) * ngroups return offset, size +@overload def factorize_( - by: tuple, - axis: T_AxesOpt, - expected_groups: tuple[pd.Index, ...] = None, + by: T_Bys, + axes: T_Axes, + *, + fastpath: Literal[True], + expected_groups: T_ExpectIndexOptTuple | None = None, + reindex: bool = False, + sort: bool = True, +) -> tuple[np.ndarray, tuple[np.ndarray, ...], tuple[int, ...], int, int, None]: + ... + + +@overload +def factorize_( + by: T_Bys, + axes: T_Axes, + *, + expected_groups: T_ExpectIndexOptTuple | None = None, + reindex: bool = False, + sort: bool = True, + fastpath: Literal[False] = False, +) -> tuple[np.ndarray, tuple[np.ndarray, ...], tuple[int, ...], int, int, FactorProps]: + ... + + +@overload +def factorize_( + by: T_Bys, + axes: T_Axes, + *, + expected_groups: T_ExpectIndexOptTuple | None = None, + reindex: bool = False, + sort: bool = True, + fastpath: bool = False, +) -> tuple[np.ndarray, tuple[np.ndarray, ...], tuple[int, ...], int, int, FactorProps | None]: + ... + + +def factorize_( + by: T_Bys, + axes: T_Axes, + *, + expected_groups: T_ExpectIndexOptTuple | None = None, reindex: bool = False, - sort=True, - fastpath=False, -): + sort: bool = True, + fastpath: bool = False, +) -> tuple[np.ndarray, tuple[np.ndarray, ...], tuple[int, ...], int, int, FactorProps | None]: """ Returns an array of integer codes for groups (and associated data) by wrapping pd.cut and pd.factorize (depending on isbin). @@ -435,9 +517,6 @@ def factorize_( a possibly large results array. Instead we set up the appropriate integer codes (group_idx) so that the results come out in the appropriate order. """ - if not isinstance(by, tuple): - raise ValueError(f"Expected `by` to be a tuple. Received {type(by)} instead") - if expected_groups is None: expected_groups = (None,) * len(by) @@ -446,26 +525,37 @@ def factorize_( for groupvar, expect in zip(by, expected_groups): flat = groupvar.reshape(-1) if isinstance(expect, pd.RangeIndex): - idx = flat + # idx is a view of the original `by` array + # copy here so we don't have a race condition with the + # group_idx[nanmask] = nan_sentinel assignment later + # this is important in shared-memory parallelism with dask + # TODO: figure out how to avoid this + idx = flat.copy() found_groups.append(np.array(expect)) # TODO: fix by using masked integers idx[idx > expect[-1]] = -1 elif isinstance(expect, pd.IntervalIndex): - # when binning we change expected groups to integers marking the interval - # this makes the reindexing logic simpler. - # workaround for https://github.com/pandas-dev/pandas/issues/47614 - # we create breaks and pass that to pd.cut, disallow closed="both" for now. if expect.closed == "both": raise NotImplementedError - if groupvar.dtype.kind == "M": - # pd.cut with bins = IntervalIndex[datetime64] doesn't work... - bins = np.concatenate([expect.left.to_numpy(), [expect.right[-1].to_numpy()]]) + bins = np.concatenate([expect.left.to_numpy(), expect.right.to_numpy()[[-1]]]) + + # digitize is 0 or idx.max() for values outside the bounds of all intervals + # make it behave like pd.cut which uses -1: + if len(bins) > 1: + right = expect.closed_right + idx = np.digitize( + flat, + bins=bins.view(np.int64) if bins.dtype.kind == "M" else bins, + right=right, + ) + idx -= 1 + within_bins = flat <= bins.max() if right else flat < bins.max() + idx[~within_bins] = -1 else: - bins = np.concatenate([expect.left.to_numpy(), [expect.right[-1]]]) - # code is -1 for values outside the bounds of all intervals - idx = pd.cut(flat, bins=bins, right=expect.closed_right).codes.copy() - found_groups.append(expect) + idx = np.zeros_like(flat, dtype=np.intp) - 1 + + found_groups.append(np.array(expect)) else: if expect is not None and reindex: sorter = np.argsort(expect) @@ -479,10 +569,10 @@ def factorize_( idx = sorter[(idx,)] idx[mask] = -1 else: - idx, groups = pd.factorize(flat, sort=sort) + idx, groups = pd.factorize(flat, sort=sort) # type: ignore # pandas issue? found_groups.append(np.array(groups)) - factorized.append(idx) + factorized.append(idx.reshape(groupvar.shape)) grp_shape = tuple(len(grp) for grp in found_groups) ngroups = math.prod(grp_shape) @@ -492,20 +582,18 @@ def factorize_( # Restore these after the raveling nan_by_mask = reduce(np.logical_or, [(f == -1) for f in factorized]) group_idx[nan_by_mask] = -1 - group_idx = group_idx.reshape(by[0].shape) else: group_idx = factorized[0] if fastpath: - return group_idx.reshape(by[0].shape), found_groups, grp_shape + return group_idx, tuple(found_groups), grp_shape, ngroups, ngroups, None - if np.isscalar(axis) and groupvar.ndim > 1: + if len(axes) == 1 and groupvar.ndim > 1: # Not reducing along all dimensions of by # this is OK because for 3D by and axis=(1,2), # we collapse to a 2D by and axis=-1 offset_group = True group_idx, size = offset_labels(group_idx.reshape(by[0].shape), ngroups) - group_idx = group_idx.reshape(-1) else: size = ngroups offset_group = False @@ -522,7 +610,7 @@ def factorize_( group_idx[nanmask] = nan_sentinel props = FactorProps(offset_group, nan_sentinel, nanmask) - return group_idx, found_groups, grp_shape, ngroups, size, props + return group_idx, tuple(found_groups), grp_shape, ngroups, size, props def chunk_argreduce( @@ -644,35 +732,49 @@ def chunk_reduce( assert len(kwargss) >= nfuncs if isinstance(axis, Sequence): - nax = len(axis) - if nax == 1: - axis = axis[0] + axes: T_Axes = axis + nax = len(axes) else: nax = by.ndim + if axis is None: + axes = () + else: + axes = (axis,) * nax + + assert by.ndim <= array.ndim final_array_shape = array.shape[:-nax] + (1,) * (nax - 1) final_groups_shape = (1,) * (nax - 1) - # when axis is a tuple - # collapse and move reduction dimensions to the end - if isinstance(axis, Sequence) and len(axis) < by.ndim: - by = _collapse_axis(by, len(axis)) - array = _collapse_axis(array, len(axis)) - axis = -1 + if 1 < nax < by.ndim: + # when axis is a tuple + # collapse and move reduction dimensions to the end + by = _collapse_axis(by, nax) + array = _collapse_axis(array, nax) + axes = (-1,) + nax = 1 # if indices=[2,2,2], npg assumes groups are (0, 1, 2); # and will return a result that is bigger than necessary # avoid by factorizing again so indices=[2,2,2] is changed to # indices=[0,0,0]. This is necessary when combining block results # factorize can handle strings etc unlike digitize - group_idx, groups, found_groups_shape, _, size, props = factorize_( - (by,), axis, expected_groups=(expected_groups,), reindex=reindex, sort=sort + group_idx, grps, found_groups_shape, _, size, props = factorize_( + (by,), axes, expected_groups=(expected_groups,), reindex=reindex, sort=sort ) - groups = groups[0] + groups = grps[0] + if nax > 1: + needs_broadcast = any( + group_idx.shape[ax] != array.shape[ax] and group_idx.shape[ax] == 1 + for ax in range(-nax, 0) + ) + if needs_broadcast: + group_idx = np.broadcast_to(group_idx, array.shape[-by.ndim :]) # always reshape to 1D along group dimensions newshape = array.shape[: array.ndim - by.ndim] + (math.prod(array.shape[-by.ndim :]),) array = array.reshape(newshape) + group_idx = group_idx.reshape(-1) assert group_idx.ndim == 1 empty = np.all(props.nanmask) @@ -760,7 +862,8 @@ def _finalize_results( """ squeezed = _squeeze_results(results, axis) - if agg.min_count is not None: + min_count = agg.min_count + if min_count > 0: counts = squeezed["intermediates"][-1] squeezed["intermediates"] = squeezed["intermediates"][:-1] @@ -771,8 +874,8 @@ def _finalize_results( else: finalized[agg.name] = agg.finalize(*squeezed["intermediates"], **agg.finalize_kwargs) - if agg.min_count is not None: - count_mask = counts < agg.min_count + if min_count > 0: + count_mask = counts < min_count if count_mask.any(): # For one count_mask.any() prevents promoting bool to dtype(fill_value) unless # necessary @@ -793,7 +896,7 @@ def _finalize_results( else: finalized["groups"] = squeezed["groups"] - finalized[agg.name] = finalized[agg.name].astype(agg.dtype[agg.name], copy=False) + finalized[agg.name] = finalized[agg.name].astype(agg.dtype["final"], copy=False) return finalized @@ -819,8 +922,25 @@ def _expand_dims(results: IntermediateDict) -> IntermediateDict: return results +def _find_unique_groups(x_chunk) -> np.ndarray: + from dask.base import flatten + from dask.utils import deepmap + + unique_groups = _unique(np.asarray(tuple(flatten(deepmap(listify_groups, x_chunk))))) + unique_groups = unique_groups[~isnull(unique_groups)] + + if len(unique_groups) == 0: + unique_groups = np.array([np.nan]) + return unique_groups + + def _simple_combine( - x_chunk, agg: Aggregation, axis: T_Axes, keepdims: bool, is_aggregate: bool = False + x_chunk, + agg: Aggregation, + axis: T_Axes, + keepdims: bool, + reindex: bool, + is_aggregate: bool = False, ) -> IntermediateDict: """ 'Simple' combination of blockwise results. @@ -830,17 +950,31 @@ def _simple_combine( 2. _expand_dims was used to insert an extra axis DUMMY_AXIS 3. Here we concatenate along DUMMY_AXIS, and then call the combine function along DUMMY_AXIS - 4. At the final agggregate step, we squeeze out DUMMY_AXIS + 4. At the final aggregate step, we squeeze out DUMMY_AXIS """ from dask.array.core import deepfirst + from dask.utils import deepmap - results: IntermediateDict = {"groups": deepfirst(x_chunk)["groups"]} + if not reindex: + # We didn't reindex at the blockwise step + # So now reindex before combining by reducing along DUMMY_AXIS + unique_groups = _find_unique_groups(x_chunk) + x_chunk = deepmap( + partial(reindex_intermediates, agg=agg, unique_groups=unique_groups), x_chunk + ) + else: + unique_groups = deepfirst(x_chunk)["groups"] + + results: IntermediateDict = {"groups": unique_groups} results["intermediates"] = [] axis_ = axis[:-1] + (DUMMY_AXIS,) - for idx, combine in enumerate(agg.combine): + for idx, combine in enumerate(agg.simple_combine): array = _conc2(x_chunk, key1="intermediates", key2=idx, axis=axis_) assert array.ndim >= 2 - result = getattr(np, combine)(array, axis=axis_, keepdims=True) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", r"All-NaN (slice|axis) encountered") + assert callable(combine) + result = combine(array, axis=axis_, keepdims=True) if is_aggregate: # squeeze out DUMMY_AXIS if this is the last step i.e. called from _aggregate result = result.squeeze(axis=DUMMY_AXIS) @@ -848,7 +982,7 @@ def _simple_combine( return results -def _conc2(x_chunk, key1, key2=slice(None), axis: T_Axes = None) -> np.ndarray: +def _conc2(x_chunk, key1, key2=slice(None), axis: T_Axes | None = None) -> np.ndarray: """copied from dask.array.reductions.mean_combine""" from dask.array.core import _concatenate2 from dask.utils import deepmap @@ -863,9 +997,9 @@ def _conc2(x_chunk, key1, key2=slice(None), axis: T_Axes = None) -> np.ndarray: # return concatenate3(mapped) -def reindex_intermediates(x, agg, unique_groups): +def reindex_intermediates(x: IntermediateDict, agg: Aggregation, unique_groups) -> IntermediateDict: new_shape = x["groups"].shape[:-1] + (len(unique_groups),) - newx = {"groups": np.broadcast_to(unique_groups, new_shape)} + newx: IntermediateDict = {"groups": np.broadcast_to(unique_groups, new_shape)} newx["intermediates"] = tuple( reindex_( v, from_=np.atleast_1d(x["groups"].squeeze()), to=pd.Index(unique_groups), fill_value=f @@ -875,7 +1009,7 @@ def reindex_intermediates(x, agg, unique_groups): return newx -def listify_groups(x): +def listify_groups(x: IntermediateDict): return list(np.atleast_1d(x["groups"].squeeze())) @@ -889,7 +1023,6 @@ def _grouped_combine( sort: bool = True, ) -> IntermediateDict: """Combine intermediates step of tree reduction.""" - from dask.base import flatten from dask.utils import deepmap if isinstance(x_chunk, dict): @@ -900,11 +1033,7 @@ def _grouped_combine( # when there's only a single axis of reduction, we can just concatenate later, # reindexing is unnecessary # I bet we can minimize the amount of reindexing for mD reductions too, but it's complicated - unique_groups = _unique(tuple(flatten(deepmap(listify_groups, x_chunk)))) - unique_groups = unique_groups[~isnull(unique_groups)] - if len(unique_groups) == 0: - unique_groups = [np.nan] - + unique_groups = _find_unique_groups(x_chunk) x_chunk = deepmap( partial(reindex_intermediates, agg=agg, unique_groups=unique_groups), x_chunk ) @@ -976,7 +1105,7 @@ def _grouped_combine( if array.shape[-1] == 0: # all empty when combined results["intermediates"].append( - np.empty(shape=(1,) * (len(axis) - 1) + (0,), dtype=agg.dtype) + np.empty(shape=(1,) * (len(axis) - 1) + (0,), dtype=dtype) ) results["groups"] = np.empty( shape=(1,) * (len(neg_axis) - 1) + (0,), dtype=groups.dtype @@ -1020,10 +1149,11 @@ def _reduce_blockwise( agg.finalize = None assert agg.finalize_kwargs is not None - finalize_kwargs = agg.finalize_kwargs - if isinstance(finalize_kwargs, Mapping): - finalize_kwargs = (finalize_kwargs,) - finalize_kwargs = finalize_kwargs + ({},) + ({},) + if isinstance(agg.finalize_kwargs, Mapping): + finalize_kwargs_: tuple[dict[Any, Any], ...] = (agg.finalize_kwargs,) + else: + finalize_kwargs_ = agg.finalize_kwargs + finalize_kwargs_ += ({},) + ({},) results = chunk_reduce( array, @@ -1036,7 +1166,7 @@ def _reduce_blockwise( # (see below) fill_value=agg.fill_value["numpy"], dtype=agg.dtype["numpy"], - kwargs=finalize_kwargs, + kwargs=finalize_kwargs_, engine=engine, sort=sort, reindex=reindex, @@ -1051,7 +1181,7 @@ def _reduce_blockwise( return result -def _normalize_indexes(array, flatblocks, blkshape): +def _normalize_indexes(array: DaskArray, flatblocks, blkshape) -> tuple: """ .blocks accessor can only accept one iterable at a time, but can handle multiple slices. @@ -1063,7 +1193,7 @@ def _normalize_indexes(array, flatblocks, blkshape): """ unraveled = np.unravel_index(flatblocks, blkshape) - normalized: list[Union[int, np.ndarray, slice]] = [] + normalized: list[int | slice | list[int]] = [] for ax, idx in enumerate(unraveled): i = _unique(idx).squeeze() if i.ndim == 0: @@ -1135,7 +1265,7 @@ def subset_to_blocks( return dask.array.Array(graph, name, chunks, meta=array) -def _extract_unknown_groups(reduced, group_chunks, dtype) -> tuple[DaskArray]: +def _extract_unknown_groups(reduced, dtype) -> tuple[DaskArray]: import dask.array from dask.highlevelgraph import HighLevelGraph @@ -1151,7 +1281,7 @@ def _extract_unknown_groups(reduced, group_chunks, dtype) -> tuple[DaskArray]: dask.array.Array( HighLevelGraph.from_collections(groups_token, layer, dependencies=[reduced]), groups_token, - chunks=group_chunks, + chunks=((np.nan,),), meta=np.array([], dtype=dtype), ), ) @@ -1161,9 +1291,9 @@ def _extract_unknown_groups(reduced, group_chunks, dtype) -> tuple[DaskArray]: def dask_groupby_agg( array: DaskArray, - by: DaskArray | np.ndarray, + by: T_By, agg: Aggregation, - expected_groups: pd.Index | None, + expected_groups: T_ExpectIndexOpt, axis: T_Axes = (), fill_value: Any = None, method: T_Method = "map-reduce", @@ -1172,10 +1302,8 @@ def dask_groupby_agg( sort: bool = True, chunks_cohorts=None, ) -> tuple[DaskArray, tuple[np.ndarray | DaskArray]]: - import dask.array from dask.array.core import slices_from_chunks - from dask.highlevelgraph import HighLevelGraph # I think _tree_reduce expects this assert isinstance(axis, Sequence) @@ -1200,11 +1328,16 @@ def dask_groupby_agg( # chunk numpy arrays like the input array # This removes an extra rechunk-merge layer that would be # added otherwise - by = dask.array.from_array(by, chunks=tuple(array.chunks[ax] for ax in range(-by.ndim, 0))) + chunks = tuple(array.chunks[ax] if by.shape[ax] != 1 else (1,) for ax in range(-by.ndim, 0)) + + by = dask.array.from_array(by, chunks=chunks) _, (array, by) = dask.array.unify_chunks(array, inds, by, inds[-by.ndim :]) - # preprocess the array: for argreductions, this zips the index together with the array block - if agg.preprocess: + # preprocess the array: + # - for argreductions, this zips the index together with the array block + # - not necessary for blockwise with argreductions + # - if this is needed later, we can fix this then + if agg.preprocess and method != "blockwise": array = agg.preprocess(array, axis=axis) # 1. We first apply the groupby-reduction blockwise to generate "intermediates" @@ -1219,7 +1352,8 @@ def dask_groupby_agg( # This allows us to discover groups at compute time, support argreductions, lower intermediate # memory usage (but method="cohorts" would also work to reduce memory in some cases) - do_simple_combine = method != "blockwise" and reindex and not _is_arg_reduction(agg) + do_simple_combine = not _is_arg_reduction(agg) + if method == "blockwise": # use the "non dask" code path, but applied blockwise blockwise_method = partial( @@ -1243,10 +1377,13 @@ def dask_groupby_agg( partial( blockwise_method, axis=axis, - expected_groups=None if method in ["split-reduce", "cohorts"] else expected_groups, + expected_groups=None if method == "cohorts" else expected_groups, engine=engine, sort=sort, ), + # output indices are the same as input indices + # Unlike xhistogram, we don't always know what the size of the group + # dimension will be unless reindex=True inds, array, inds, @@ -1256,83 +1393,76 @@ def dask_groupby_agg( dtype=array.dtype, # this is purely for show meta=array._meta, align_arrays=False, - token=f"{name}-chunk-{token}", + name=f"{name}-chunk-{token}", ) - if expected_groups is None: - if is_duck_dask_array(by_input): - expected_groups = None - else: - expected_groups = _get_expected_groups(by_input, sort=sort) - group_chunks: tuple[tuple[Union[int, float], ...]] = ( - (len(expected_groups),) if expected_groups is not None else (np.nan,), - ) + group_chunks: tuple[tuple[int | float, ...]] - if method in ["map-reduce", "cohorts", "split-reduce"]: + if method in ["map-reduce", "cohorts"]: combine: Callable[..., IntermediateDict] if do_simple_combine: - combine = _simple_combine + combine = partial(_simple_combine, reindex=reindex) + combine_name = "simple-combine" else: combine = partial(_grouped_combine, engine=engine, sort=sort) + combine_name = "grouped-combine" - # Each chunk of `reduced`` is really a dict mapping - # 1. reduction name to array - # 2. "groups" to an array of group labels - # Note: it does not make sense to interpret axis relative to - # shape of intermediate results after the blockwise call tree_reduce = partial( dask.array.reductions._tree_reduce, - combine=partial(combine, agg=agg), - name=f"{name}-reduce-{method}", + name=f"{name}-reduce-{method}-{combine_name}", dtype=array.dtype, axis=axis, keepdims=True, concatenate=False, ) - aggregate = partial( - _aggregate, combine=combine, agg=agg, fill_value=fill_value, reindex=reindex - ) + aggregate = partial(_aggregate, combine=combine, agg=agg, fill_value=fill_value) + + # Each chunk of `reduced`` is really a dict mapping + # 1. reduction name to array + # 2. "groups" to an array of group labels + # Note: it does not make sense to interpret axis relative to + # shape of intermediate results after the blockwise call if method == "map-reduce": reduced = tree_reduce( intermediate, - aggregate=partial(aggregate, expected_groups=expected_groups), + combine=partial(combine, agg=agg), + aggregate=partial(aggregate, expected_groups=expected_groups, reindex=reindex), ) if is_duck_dask_array(by_input) and expected_groups is None: - groups = _extract_unknown_groups(reduced, group_chunks=group_chunks, dtype=by.dtype) + groups = _extract_unknown_groups(reduced, dtype=by.dtype) + group_chunks = ((np.nan,),) else: if expected_groups is None: expected_groups_ = _get_expected_groups(by_input, sort=sort) else: expected_groups_ = expected_groups groups = (expected_groups_.to_numpy(),) + group_chunks = ((len(expected_groups_),),) - elif method in ["cohorts", "split-reduce"]: + elif method == "cohorts": chunks_cohorts = find_group_cohorts( by_input, [array.chunks[ax] for ax in axis], merge=True ) reduced_ = [] groups_ = [] for blks, cohort in chunks_cohorts.items(): + index = pd.Index(cohort) subset = subset_to_blocks(intermediate, blks, array.blocks.shape[-len(axis) :]) - if do_simple_combine: - # reindex so that reindex can be set to True later - reindexed = dask.array.map_blocks( - reindex_intermediates, - subset, - agg=agg, - unique_groups=cohort, - meta=subset._meta, - ) - else: - reindexed = subset - + reindexed = dask.array.map_blocks( + reindex_intermediates, subset, agg=agg, unique_groups=index, meta=subset._meta + ) + # now that we have reindexed, we can set reindex=True explicitlly reduced_.append( tree_reduce( reindexed, - aggregate=partial(aggregate, expected_groups=cohort, reindex=reindex), + combine=partial(combine, agg=agg, reindex=True), + aggregate=partial(aggregate, expected_groups=index, reindex=True), ) ) - groups_.append(cohort) + # This is done because pandas promotes to 64-bit types when an Index is created + # So we use the index to generate the return value for consistency with "map-reduce" + # This is important on windows + groups_.append(index.values) reduced = dask.array.concatenate(reduced_, axis=-1) groups = (np.concatenate(groups_),) @@ -1344,95 +1474,152 @@ def dask_groupby_agg( # find number of groups in each chunk, this is needed for output chunks # along the reduced axis slices = slices_from_chunks(tuple(array.chunks[ax] for ax in axis)) - if expected_groups is None: - groups_in_block = tuple(_unique(by_input[slc]) for slc in slices) - else: - # For cohorts, we could be indexing a block with groups that - # are not in the cohort (usually for nD `by`) - # Only keep the expected groups. - groups_in_block = tuple( - np.intersect1d(by_input[slc], expected_groups) for slc in slices - ) + groups_in_block = tuple(_unique(by_input[slc]) for slc in slices) groups = (np.concatenate(groups_in_block),) - ngroups_per_block = tuple(len(grp) for grp in groups_in_block) group_chunks = (ngroups_per_block,) - else: raise ValueError(f"Unknown method={method}.") - # extract results from the dict + out_inds = inds[: -len(axis)] + (inds[-1],) output_chunks = reduced.chunks[: -len(axis)] + group_chunks + if method == "blockwise" and len(axis) > 1: + # The final results are available but the blocks along axes + # need to be reshaped to axis=-1 + # I don't know that this is possible with blockwise + # All other code paths benefit from an unmaterialized Blockwise layer + reduced = _collapse_blocks_along_axes(reduced, axis, group_chunks) + + # Can't use map_blocks because it forces concatenate=True along drop_axes, + result = dask.array.blockwise( + _extract_result, + out_inds, + reduced, + inds, + adjust_chunks=dict(zip(out_inds, output_chunks)), + dtype=agg.dtype["final"], + key=agg.name, + name=f"{name}-{token}", + concatenate=False, + ) + + return (result, groups) + + +def _collapse_blocks_along_axes(reduced: DaskArray, axis: T_Axes, group_chunks) -> DaskArray: + import dask.array + from dask.highlevelgraph import HighLevelGraph + + nblocks = tuple(reduced.numblocks[ax] for ax in axis) + output_chunks = reduced.chunks[: -len(axis)] + ((1,) * (len(axis) - 1),) + group_chunks + + # extract results from the dict ochunks = tuple(range(len(chunks_v)) for chunks_v in output_chunks) layer2: dict[tuple, tuple] = {} - agg_name = f"{name}-{token}" - for ochunk in itertools.product(*ochunks): - if method == "blockwise": - if len(axis) == 1: - inchunk = ochunk - else: - nblocks = tuple(len(array.chunks[ax]) for ax in axis) - inchunk = ochunk[:-1] + np.unravel_index(ochunk[-1], nblocks) - else: - inchunk = ochunk[:-1] + (0,) * (len(axis) - 1) + (ochunk[-1],) + name = f"reshape-{reduced.name}" - layer2[(agg_name, *ochunk)] = (operator.getitem, (reduced.name, *inchunk), agg.name) + for ochunk in itertools.product(*ochunks): + inchunk = ochunk[: -len(axis)] + np.unravel_index(ochunk[-1], nblocks) + layer2[(name, *ochunk)] = (reduced.name, *inchunk) - result = dask.array.Array( - HighLevelGraph.from_collections(agg_name, layer2, dependencies=[reduced]), - agg_name, + return dask.array.Array( + HighLevelGraph.from_collections(name, layer2, dependencies=[reduced]), + name, chunks=output_chunks, - dtype=agg.dtype[agg.name], + dtype=reduced.dtype, ) - return (result, groups) + +def _extract_result(result_dict: FinalResultsDict, key) -> np.ndarray: + from dask.array.core import deepfirst + + # deepfirst should be not be needed here but sometimes we receive a list of dict? + return deepfirst(result_dict)[key] -def _validate_reindex(reindex: bool | None, func, method: T_Method, expected_groups) -> bool | None: - if reindex is True: +def _validate_reindex( + reindex: bool | None, + func, + method: T_Method, + expected_groups, + any_by_dask: bool, + is_dask_array: bool, +) -> bool: + all_numpy = not is_dask_array and not any_by_dask + if reindex is True and not all_numpy: if _is_arg_reduction(func): raise NotImplementedError - if method == "blockwise": - raise NotImplementedError + if method in ["blockwise", "cohorts"]: + raise ValueError( + "reindex=True is not a valid choice for method='blockwise' or method='cohorts'." + ) + if func in ["first", "last"]: + raise ValueError("reindex must be None or False when func is 'first' or 'last.") - if method == "blockwise" or _is_arg_reduction(func): - reindex = False + if reindex is None: + if all_numpy: + return True - if reindex is None and expected_groups is not None: - reindex = True + if func in ["first", "last"]: + # have to do the grouped_combine since there's no good fill_value + reindex = False - if method in ["split-reduce", "cohorts"] and reindex is False: - raise NotImplementedError + if method == "blockwise" or _is_arg_reduction(func): + reindex = False + + elif method == "cohorts": + reindex = False - if method in ["split-reduce", "cohorts"] and reindex is None: - reindex = True + elif method == "map-reduce": + if expected_groups is None and any_by_dask: + reindex = False + else: + reindex = True + + assert isinstance(reindex, bool) - # TODO: Should reindex be a bool-only at this point? Would've been nice but - # None's are relied on after this function as well. return reindex -def _assert_by_is_aligned(shape, by): +def _assert_by_is_aligned(shape: tuple[int, ...], by: T_Bys) -> None: + assert all(b.ndim == by[0].ndim for b in by[1:]) for idx, b in enumerate(by): - if shape[-b.ndim :] != b.shape: + if not all(j in [i, 1] for i, j in zip(shape[-b.ndim :], b.shape)): raise ValueError( - "`array` and `by` arrays must be aligned " - "i.e. array.shape[-by.ndim :] == by.shape. " - "for every array in `by`." + "`array` and `by` arrays must be 'aligned' " + "so that such that by_ is broadcastable to array.shape[-by.ndim:] " + "for every array `by_` in `by`. " + "Either array.shape[-by_.ndim :] == by_.shape or the only differences " + "should be size-1 dimensions in by_." f"Received array of shape {shape} but " f"array {idx} in `by` has shape {b.shape}." ) +@overload +def _convert_expected_groups_to_index( + expected_groups: tuple[None, ...], isbin: Sequence[bool], sort: bool +) -> tuple[None, ...]: + ... + + +@overload +def _convert_expected_groups_to_index( + expected_groups: T_ExpectTuple, isbin: Sequence[bool], sort: bool +) -> T_ExpectIndexTuple: + ... + + def _convert_expected_groups_to_index( - expected_groups: T_ExpectedGroups, isbin: Sequence[bool], sort: bool -) -> tuple[pd.Index | None, ...]: - out: list[pd.Index | None] = [] + expected_groups: T_ExpectOptTuple, isbin: Sequence[bool], sort: bool +) -> T_ExpectIndexOptTuple: + out: list[T_ExpectIndexOpt] = [] for ex, isbin_ in zip(expected_groups, isbin): - if isinstance(ex, pd.IntervalIndex) or (isinstance(ex, pd.Index) and not isbin): + if isinstance(ex, pd.IntervalIndex) or (isinstance(ex, pd.Index) and not isbin_): if sort: - ex = ex.sort_values() - out.append(ex) + out.append(ex.sort_values()) + else: + out.append(ex) elif ex is not None: if isbin_: out.append(pd.IntervalIndex.from_breaks(ex)) @@ -1446,47 +1633,119 @@ def _convert_expected_groups_to_index( return tuple(out) -def _lazy_factorize_wrapper(*by, **kwargs): +def _lazy_factorize_wrapper(*by: T_By, **kwargs) -> np.ndarray: group_idx, *rest = factorize_(by, **kwargs) return group_idx -def _factorize_multiple(by, expected_groups, by_is_dask, reindex): - kwargs = dict( - expected_groups=expected_groups, - axis=None, # always None, we offset later if necessary. - fastpath=True, - reindex=reindex, - ) - if by_is_dask: +def _factorize_multiple( + by: T_Bys, + expected_groups: T_ExpectIndexOptTuple, + any_by_dask: bool, + reindex: bool, + sort: bool = True, +) -> tuple[tuple[np.ndarray], tuple[np.ndarray, ...], tuple[int, ...]]: + if any_by_dask: import dask.array + # unifying chunks will make sure all arrays in `by` are dask arrays + # with compatible chunks, even if there was originally a numpy array + inds = tuple(range(by[0].ndim)) + chunks, by_ = dask.array.unify_chunks(*itertools.chain(*zip(by, (inds,) * len(by)))) + group_idx = dask.array.map_blocks( _lazy_factorize_wrapper, - *np.broadcast_arrays(*by), + *by_, + chunks=tuple(chunks.values()), meta=np.array((), dtype=np.int64), - **kwargs, - ) - found_groups = tuple( - None if is_duck_dask_array(b) else pd.unique(b.reshape(-1)) for b in by + axes=(), # always (), we offset later if necessary. + expected_groups=expected_groups, + fastpath=True, + reindex=reindex, + sort=sort, ) - grp_shape = tuple(len(e) for e in expected_groups) + + fg, gs = [], [] + for by_, expect in zip(by, expected_groups): + if expect is None: + if is_duck_dask_array(by_): + raise ValueError( + "Please provide expected_groups when grouping by a dask array." + ) + + found_group = pd.unique(by_.reshape(-1)) + else: + found_group = expect.to_numpy() + + fg.append(found_group) + gs.append(len(found_group)) + + found_groups = tuple(fg) + grp_shape = tuple(gs) else: - group_idx, found_groups, grp_shape = factorize_(by, **kwargs) + group_idx, found_groups, grp_shape, ngroups, size, props = factorize_( + by, + axes=(), # always (), we offset later if necessary. + expected_groups=expected_groups, + fastpath=True, + reindex=reindex, + sort=sort, + ) - final_groups = tuple( - found if expect is None else expect.to_numpy() - for found, expect in zip(found_groups, expected_groups) - ) + return (group_idx,), found_groups, grp_shape + + +@overload +def _validate_expected_groups(nby: int, expected_groups: None) -> tuple[None, ...]: + ... + + +@overload +def _validate_expected_groups(nby: int, expected_groups: T_ExpectedGroups) -> T_ExpectTuple: + ... + + +def _validate_expected_groups(nby: int, expected_groups: T_ExpectedGroupsOpt) -> T_ExpectOptTuple: + if expected_groups is None: + return (None,) * nby + + if nby == 1 and not isinstance(expected_groups, tuple): + if isinstance(expected_groups, (pd.Index, np.ndarray)): + return (expected_groups,) + else: + array = np.asarray(expected_groups) + if np.issubdtype(array.dtype, np.integer): + # preserve default dtypes + # on pandas 1.5/2, on windows + # when a list is passed + array = array.astype(np.int64) + return (array,) + + if nby > 1 and not isinstance(expected_groups, tuple): # TODO: test for list + raise ValueError( + "When grouping by multiple variables, expected_groups must be a tuple " + "of either arrays or objects convertible to an array (like lists). " + "For example `expected_groups=(np.array([1, 2, 3]), ['a', 'b', 'c'])`." + f"Received a {type(expected_groups).__name__} instead. " + "When grouping by a single variable, you can pass an array or something " + "convertible to an array for convenience: `expected_groups=['a', 'b', 'c']`." + ) + + if TYPE_CHECKING: + assert isinstance(expected_groups, tuple) + + if len(expected_groups) != nby: + raise ValueError( + f"Must have same number of `expected_groups` (received {len(expected_groups)}) " + f" and variables to group by (received {nby})." + ) - if any(grp is None for grp in final_groups): - raise ValueError("Please provide expected_groups when grouping by a dask array.") - return (group_idx,), final_groups, grp_shape + return expected_groups def groupby_reduce( array: np.ndarray | DaskArray, - *by: np.ndarray | DaskArray, + *by: T_By, func: T_Agg, expected_groups: T_ExpectedGroupsOpt = None, sort: bool = True, @@ -1498,8 +1757,8 @@ def groupby_reduce( method: T_Method = "map-reduce", engine: T_Engine = "numpy", reindex: bool | None = None, - finalize_kwargs: Mapping | None = None, -) -> tuple[DaskArray, np.ndarray | DaskArray]: + finalize_kwargs: dict[Any, Any] | None = None, +) -> tuple[DaskArray, Unpack[tuple[np.ndarray | DaskArray, ...]]]: # type: ignore[misc] # Unpack not in mypy yet """ GroupBy reductions using tree reductions for dask.array @@ -1507,7 +1766,7 @@ def groupby_reduce( ---------- array : ndarray or DaskArray Array to be reduced, possibly nD - by : ndarray or DaskArray + *by : ndarray or DaskArray Array of labels to group over. Must be aligned with ``array`` so that ``array.shape[-by.ndim :] == by.shape`` func : str or Aggregation @@ -1526,7 +1785,7 @@ def groupby_reduce( Negative integers are normalized using array.ndim fill_value : Any Value to assign when a label in ``expected_groups`` is not present. - dtype: data-type , optional + dtype : data-type , optional DType for the output. Can be anything that is accepted by ``np.dtype``. min_count : int, default: None The required number of valid values to perform the operation. If @@ -1572,11 +1831,11 @@ def groupby_reduce( * ``"numba"``: Use the implementations in ``numpy_groupies.aggregate_numba``. reindex : bool, optional - Whether to "reindex" the blockwise results to `expected_groups` (possibly automatically detected). + Whether to "reindex" the blockwise results to ``expected_groups`` (possibly automatically detected). If True, the intermediate result of the blockwise groupby-reduction has a value for all expected groups, and the final result is a simple reduction of those intermediates. In nearly all cases, this is a significant boost in computation speed. For cases like time grouping, this may result in large intermediates relative to the - original block size. Avoid that by using method="cohorts". By default, it is turned off for argreductions. + original block size. Avoid that by using ``method="cohorts"``. By default, it is turned off for argreductions. finalize_kwargs : dict, optional Kwargs passed to finalize the reduction such as ``ddof`` for var, std. @@ -1597,15 +1856,22 @@ def groupby_reduce( "argreductions not supported for engine='flox' yet." "Try engine='numpy' or engine='numba' instead." ) - reindex = _validate_reindex(reindex, func, method, expected_groups) - bys = tuple(np.asarray(b) if not is_duck_array(b) else b for b in by) + bys: T_Bys = tuple(np.asarray(b) if not is_duck_array(b) else b for b in by) nby = len(bys) - by_is_dask = any(is_duck_dask_array(b) for b in bys) + by_is_dask = tuple(is_duck_dask_array(b) for b in bys) + any_by_dask = any(by_is_dask) - if method in ["split-reduce", "cohorts"] and by_is_dask: + if method in ["split-reduce", "cohorts"] and any_by_dask: raise ValueError(f"method={method!r} can only be used when grouping by numpy arrays.") + if method == "split-reduce": + method = "cohorts" + + reindex = _validate_reindex( + reindex, func, method, expected_groups, any_by_dask, is_duck_dask_array(array) + ) + if not is_duck_array(array): array = np.asarray(array) is_bool_array = np.issubdtype(array.dtype, bool) @@ -1615,36 +1881,43 @@ def groupby_reduce( isbins = isbin else: isbins = (isbin,) * nby - if expected_groups is None: - expected_groups = (None,) * nby _assert_by_is_aligned(array.shape, bys) - if nby == 1 and not isinstance(expected_groups, tuple): - expected_groups = (np.asarray(expected_groups),) - elif len(expected_groups) != nby: - raise ValueError( - f"Must have same number of `expected_groups` (received {len(expected_groups)}) " - f" and variables to group by (received {nby})." - ) + expected_groups = _validate_expected_groups(nby, expected_groups) + + for idx, (expect, is_dask) in enumerate(zip(expected_groups, by_is_dask)): + if is_dask and (reindex or nby > 1) and expect is None: + raise ValueError( + f"`expected_groups` for array {idx} in `by` cannot be None since it is a dask.array." + ) # We convert to pd.Index since that lets us know if we are binning or not # (pd.IntervalIndex or not) expected_groups = _convert_expected_groups_to_index(expected_groups, isbins, sort) - # TODO: could restrict this to dask-only - factorize_early = (nby > 1) or ( - any(isbins) and method in ["split-reduce", "cohorts"] and is_duck_dask_array(array) + # Don't factorize "early only when + # grouping by dask arrays, and not having expected_groups + factorize_early = not ( + # can't do it if we are grouping by dask array but don't have expected_groups + any(is_dask and ex_ is None for is_dask, ex_ in zip(by_is_dask, expected_groups)) ) if factorize_early: bys, final_groups, grp_shape = _factorize_multiple( - bys, expected_groups, by_is_dask=by_is_dask, reindex=reindex + bys, + expected_groups, + any_by_dask=any_by_dask, + # This is the only way it makes sense I think. + # reindex controls what's actually allocated in chunk_reduce + # At this point, we care about an accurate conversion to codes. + reindex=True, + sort=sort, ) expected_groups = (pd.RangeIndex(math.prod(grp_shape)),) assert len(bys) == 1 - by_ = bys[0] - expected_groups = expected_groups[0] + (by_,) = bys + (expected_groups,) = expected_groups if axis is None: axis_ = tuple(array.ndim + np.arange(-by_.ndim, 0)) @@ -1653,15 +1926,24 @@ def groupby_reduce( axis_ = np.core.numeric.normalize_axis_tuple(axis, array.ndim) # type: ignore nax = len(axis_) - if method in ["blockwise", "cohorts", "split-reduce"] and nax != by_.ndim: - raise NotImplementedError( - "Must reduce along all dimensions of `by` when method != 'map-reduce'." - f"Received method={method!r}" - ) + has_dask = is_duck_dask_array(array) or is_duck_dask_array(by_) + + if _is_first_last_reduction(func): + if has_dask and nax != 1: + raise ValueError( + "For dask arrays: first, last, nanfirst, nanlast reductions are " + "only supported along a single axis. Please reshape appropriately." + ) + + elif nax not in [1, by_.ndim]: + raise ValueError( + "first, last, nanfirst, nanlast reductions are only supported " + "along a single axis or when reducing across all dimensions of `by`." + ) # TODO: make sure expected_groups is unique if nax == 1 and by_.ndim > 1 and expected_groups is None: - if not by_is_dask: + if not any_by_dask: expected_groups = _get_expected_groups(by_, sort) else: # When we reduce along all axes, we are guaranteed to see all @@ -1682,8 +1964,6 @@ def groupby_reduce( axis_ = tuple(array.ndim + np.arange(-nax, 0)) nax = len(axis_) - has_dask = is_duck_dask_array(array) or is_duck_dask_array(by_) - # When axis is a subset of possible values; then npg will # apply it to groups that don't exist along a particular axis (for e.g.) # since these count as a group that is absent. thoo! @@ -1692,17 +1972,22 @@ def groupby_reduce( # Consider np.sum([np.nan]) = np.nan, np.nansum([np.nan]) = 0 if min_count is None: if nax < by_.ndim or fill_value is not None: - min_count = 1 + min_count_: int = 1 + else: + min_count_ = 0 + else: + min_count_ = min_count # TODO: set in xarray? - if min_count is not None and func in ["nansum", "nanprod"] and fill_value is None: + if min_count_ > 0 and func in ["nansum", "nanprod"] and fill_value is None: # nansum, nanprod have fill_value=0, 1 # overwrite than when min_count is set fill_value = np.nan kwargs = dict(axis=axis_, fill_value=fill_value, engine=engine) - agg = _initialize_aggregation(func, dtype, array.dtype, fill_value, min_count, finalize_kwargs) + agg = _initialize_aggregation(func, dtype, array.dtype, fill_value, min_count_, finalize_kwargs) + groups: tuple[np.ndarray | DaskArray, ...] if not has_dask: results = _reduce_blockwise( array, by_, agg, expected_groups=expected_groups, reindex=reindex, sort=sort, **kwargs @@ -1721,6 +2006,12 @@ def groupby_reduce( f"\n\n Received: {func}" ) + if method in ["blockwise", "cohorts"] and nax != by_.ndim: + raise NotImplementedError( + "Must reduce along all dimensions of `by` when method != 'map-reduce'." + f"Received method={method!r}" + ) + # TODO: just do this in dask_groupby_agg # we always need some fill_value (see above) so choose the default if needed if kwargs["fill_value"] is None: @@ -1745,7 +2036,7 @@ def groupby_reduce( assert len(groups) == 1 sorted_idx = np.argsort(groups[0]) # This optimization helps specifically with resampling - if not (sorted_idx[1:] <= sorted_idx[:-1]).all(): + if not (sorted_idx[:-1] <= sorted_idx[1:]).all(): result = result[..., sorted_idx] groups = (groups[0][sorted_idx],) @@ -1758,6 +2049,6 @@ def groupby_reduce( ).reshape(result.shape[:-1] + grp_shape) groups = final_groups - if _is_minmax_reduction(func) and is_bool_array: + if is_bool_array and (_is_minmax_reduction(func) or _is_first_last_reduction(func)): result = result.astype(bool) - return (result, *groups) + return (result, *groups) # type: ignore[return-value] # Unpack not in mypy yet diff --git a/flox/visualize.py b/flox/visualize.py index fd712fd4b..7d44c7d91 100644 --- a/flox/visualize.py +++ b/flox/visualize.py @@ -6,7 +6,7 @@ import numpy as np import pandas as pd -from .core import find_group_cohorts +from .core import _unique, find_group_cohorts def draw_mesh( @@ -21,12 +21,12 @@ def draw_mesh( colors=None, randomize=True, x0=0, + y0=0, append=False, ): - dx = 2 xpts = x0 + np.arange(0, (ncol + nspaces) * dx, dx) - ypts = np.arange(0, nrow * dx, dx) + ypts = y0 + np.arange(0, nrow * dx, dx) if colors is None: colors = mpl.cm.Set2.colors[:4] @@ -39,6 +39,7 @@ def draw_mesh( ax.set_aspect(1) ax.set_axis_off() + # ncolors = len(colors) if not randomize: colors = iter(colors) @@ -55,7 +56,7 @@ def draw_mesh( counter[fcolor] += 1 ax.add_patch( mpl.patches.Rectangle( - (x, y - 0.5 * dx), + (x, y), dx, dx, edgecolor="w", @@ -66,14 +67,15 @@ def draw_mesh( if draw_line_at is not None and icolor > 0 and icolor % draw_line_at == 0: plt.plot([x, x], [y - 0.75 * dx, y + 0.75 * dx], color="k", lw=2) - ax.set_xlim((0, max(xpts) + dx)) - ax.set_ylim((-0.75 * dx, max(ypts) + 0.75 * dx)) + # assert n + 1 == ncolors, (n, ncolors) + ax.set_xlim((0, max(xpts) + 2 * dx)) + ax.set_ylim((-0.75 * dx + min(ypts), max(ypts) + 0.75 * dx)) if not append: plt.gcf().set_size_inches((ncol * pxin, (nrow + 2) * pxin)) -def visualize_groups_1d(array, labels, axis=-1, colors=None, cmap=None): +def visualize_groups_1d(array, labels, axis=-1, colors=None, cmap=None, append=True, x0=0): """ Visualize group distribution for a 1D array of group labels. """ @@ -93,7 +95,8 @@ def visualize_groups_1d(array, labels, axis=-1, colors=None, cmap=None): if len(unique_labels) > len(colors): raise ValueError("Not enough unique colors") - plt.figure() + if not append: + fig = plt.figure() i0 = 0 for i in chunks: lab = labels[i0 : i0 + i] @@ -103,17 +106,17 @@ def visualize_groups_1d(array, labels, axis=-1, colors=None, cmap=None): len(lab) + 1, colors=col, randomize=False, - append=True, - x0=i0 * 2.3, # + (i0 - 1) * 0.025, + append=append, + x0=x0 + i0 * 2.3, # + (i0 - 1) * 0.025, ) i0 += i - pxin = 0.8 - plt.gcf().set_size_inches((len(labels) * pxin, 1 * pxin)) + if not append: + pxin = 0.8 + fig.set_size_inches((len(labels) * pxin, 1 * pxin)) def get_colormap(N): - cmap = mpl.cm.get_cmap("tab20_r").copy() ncolors = len(cmap.colors) q = N // ncolors @@ -124,21 +127,20 @@ def get_colormap(N): def factorize_cohorts(by, cohorts): - factorized = np.full(by.shape, -1) for idx, cohort in enumerate(cohorts): factorized[np.isin(by, cohort)] = idx return factorized -def visualize_cohorts_2d(by, array, method="cohorts"): +def visualize_cohorts_2d(by, array): assert by.ndim == 2 print("finding cohorts...") before_merged = find_group_cohorts( - by, [array.chunks[ax] for ax in range(-by.ndim, 0)], merge=False, method=method + by, [array.chunks[ax] for ax in range(-by.ndim, 0)], merge=False ).values() merged = find_group_cohorts( - by, [array.chunks[ax] for ax in range(-by.ndim, 0)], merge=True, method=method + by, [array.chunks[ax] for ax in range(-by.ndim, 0)], merge=True ).values() print("finished cohorts...") @@ -149,16 +151,12 @@ def visualize_cohorts_2d(by, array, method="cohorts"): ax = ax.ravel() ax[1].set_visible(False) ax = ax[[0, 2, 3]] - flat = by.ravel() - ngroups = len(np.unique(flat[~np.isnan(flat)])) + ngroups = len(_unique(by)) h0 = ax[0].imshow(by, cmap=get_colormap(ngroups)) - h1 = ax[1].imshow( - factorize_cohorts(by, before_merged), - vmin=0, - cmap=get_colormap(len(before_merged)), - ) - h2 = ax[2].imshow(factorize_cohorts(by, merged), vmin=0, cmap=get_colormap(len(merged))) + h1 = _visualize_cohorts(by, before_merged, ax=ax[1]) + h2 = _visualize_cohorts(by, merged, ax=ax[2]) + for axx in ax: axx.grid(True, which="both") axx.set_xticks(xticks) @@ -170,3 +168,26 @@ def visualize_cohorts_2d(by, array, method="cohorts"): ax[1].set_title(f"{len(before_merged)} cohorts") ax[2].set_title(f"{len(merged)} merged cohorts") f.set_size_inches((6, 6)) + + +def _visualize_cohorts(by, cohorts, ax=None): + if ax is None: + _, ax = plt.subplots(1, 1) + + ax.imshow(factorize_cohorts(by, cohorts), vmin=0, cmap=get_colormap(len(cohorts))) + + +def visualize_groups_2d(labels, y0=0, **kwargs): + colors = mpl.cm.tab10_r + for i, chunk in enumerate(labels): + chunk = np.atleast_2d(chunk) + draw_mesh( + *chunk.shape, + colors=tuple(colors(label) for label in np.flipud(chunk).ravel()), + randomize=False, + append=True, + y0=y0, + **kwargs, + ) + y0 = y0 + 2 * chunk.shape[0] + 2 + plt.ylim([-1, y0]) diff --git a/flox/xarray.py b/flox/xarray.py index 55eefd812..487850ca0 100644 --- a/flox/xarray.py +++ b/flox/xarray.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from typing import TYPE_CHECKING, Any, Hashable, Iterable, Sequence, Union import numpy as np @@ -13,31 +12,19 @@ from .core import ( _convert_expected_groups_to_index, _get_expected_groups, + _validate_expected_groups, groupby_reduce, - rechunk_for_blockwise as rechunk_array_for_blockwise, - rechunk_for_cohorts as rechunk_array_for_cohorts, ) +from .core import rechunk_for_blockwise as rechunk_array_for_blockwise +from .core import rechunk_for_cohorts as rechunk_array_for_cohorts from .xrutils import _contains_cftime_datetimes, _to_pytimedelta, datetime_to_numeric if TYPE_CHECKING: - from xarray.core.resample import Resample from xarray.core.types import T_DataArray, T_Dataset - Dims = Union[str, Iterable[Hashable], None] - + from .core import T_ExpectedGroupsOpt, T_ExpectIndex, T_ExpectOpt -def _get_input_core_dims(group_names, dim, ds, grouper_dims): - input_core_dims = [[], []] - for g in group_names: - if g in dim: - continue - if g in ds.dims: - input_core_dims[0].extend([g]) - if g in grouper_dims: - input_core_dims[1].extend([g]) - input_core_dims[0].extend(dim) - input_core_dims[1].extend(dim) - return input_core_dims + Dims = Union[str, Iterable[Hashable], None] def _restore_dim_order(result, obj, by): @@ -54,11 +41,31 @@ def lookup_order(dimension): return result.transpose(*new_order) +def _broadcast_size_one_dims(*arrays, core_dims): + """Broadcast by adding size-1 dimensions in the right place. + + Workaround because apply_ufunc doesn't support this yet. + https://github.com/pydata/xarray/issues/3032#issuecomment-503337637 + + Specialized to the groupby problem. + """ + array_dims = set(core_dims[0]) + broadcasted = [arrays[0]] + for dims, array in zip(core_dims[1:], arrays[1:]): + assert set(dims).issubset(array_dims) + order = [dims.index(d) for d in core_dims[0] if d in dims] + array = array.transpose(*order) + axis = [core_dims[0].index(d) for d in core_dims[0] if d not in dims] + broadcasted.append(np.expand_dims(array, axis)) + + return broadcasted + + def xarray_reduce( obj: T_Dataset | T_DataArray, *by: T_DataArray | Hashable, func: str | Aggregation, - expected_groups=None, + expected_groups: T_ExpectedGroupsOpt = None, isbin: bool | Sequence[bool] = False, sort: bool = True, dim: Dims | ellipsis = None, @@ -97,7 +104,7 @@ def xarray_reduce( fill_value Value used for missing groups in the output i.e. when one of the labels in ``expected_groups`` is not actually present in ``by``. - dtype: data-type, optional + dtype : data-type, optional DType for the output. Can be anything accepted by ``np.dtype``. method : {"map-reduce", "blockwise", "cohorts", "split-reduce"}, optional Strategy for reduction of dask arrays only: @@ -155,7 +162,7 @@ def xarray_reduce( and the final result is a simple reduction of those intermediates. In nearly all cases, this is a significant boost in computation speed. For cases like time grouping, this may result in large intermediates relative to the original block size. Avoid that by using method="cohorts". By default, it is turned off for arg reductions. - **finalize_kwargs : + **finalize_kwargs kwargs passed to the finalize function, like ``ddof`` for var, std. Returns @@ -210,19 +217,13 @@ def xarray_reduce( else: isbins = (isbin,) * nby - if expected_groups is None: - expected_groups = (None,) * nby - if isinstance(expected_groups, (np.ndarray, list)): # TODO: test for list - if nby == 1: - expected_groups = (expected_groups,) - else: - raise ValueError("Needs better message.") + expected_groups_valid = _validate_expected_groups(nby, expected_groups) if not sort: - raise NotImplementedError + raise NotImplementedError("sort must be True for xarray_reduce") # eventually drop the variables we are grouping by - maybe_drop = [b for b in by if isinstance(b, Hashable)] + maybe_drop = {b for b in by if isinstance(b, Hashable)} unindexed_dims = tuple( b for b, isbin_ in zip(by, isbins) @@ -242,7 +243,19 @@ def xarray_reduce( else: ds = obj._to_temp_dataset() - ds = ds.drop_vars([var for var in maybe_drop if var in ds.variables]) + try: + from xarray.indexes import PandasMultiIndex + except ImportError: + PandasMultiIndex = tuple() # type: ignore + + more_drop = set() + for var in maybe_drop: + maybe_midx = ds._indexes.get(var, None) + if isinstance(maybe_midx, PandasMultiIndex): + idx_coord_names = set(maybe_midx.index.names + [maybe_midx.dim]) + idx_other_names = idx_coord_names - set(maybe_drop) + more_drop.update(idx_other_names) + maybe_drop.update(more_drop) if dim is Ellipsis: if nby > 1: @@ -255,24 +268,29 @@ def xarray_reduce( elif dim is not None: dim_tuple = _atleast_1d(dim) else: - dim_tuple = tuple() + dim_tuple = tuple(grouper_dims) - # broadcast all variables against each other along all dimensions in `by` variables - # don't exclude `dim` because it need not be a dimension in any of the `by` variables! - # in the case where dim is Ellipsis, and by.ndim < obj.ndim - # then we also broadcast `by` to all `obj.dims` - # TODO: avoid this broadcasting + # broadcast to make sure grouper dimensions are present in the array. exclude_dims = tuple(d for d in ds.dims if d not in grouper_dims and d not in dim_tuple) - ds_broad, *by_broad = xr.broadcast(ds, *by_da, exclude=exclude_dims) - - # all members of by_broad have the same dimensions - # so we just pull by_broad[0].dims if dim is None - if not dim_tuple: - dim_tuple = tuple(by_broad[0].dims) if any(d not in grouper_dims and d not in obj.dims for d in dim_tuple): raise ValueError(f"Cannot reduce over absent dimensions {dim}.") + try: + xr.align(ds, *by_da, join="exact", copy=False) + except ValueError as e: + raise ValueError( + "Object being grouped must be exactly aligned with every array in `by`." + ) from e + + needs_broadcast = any( + not set(grouper_dims).issubset(set(variable.dims)) for variable in ds.data_vars.values() + ) + if needs_broadcast: + ds_broad = xr.broadcast(ds, *by_da, exclude=exclude_dims)[0] + else: + ds_broad = ds + dims_not_in_groupers = tuple(d for d in dim_tuple if d not in grouper_dims) if dims_not_in_groupers == tuple(dim_tuple) and not any(isbins): # reducing along a dimension along which groups do not vary @@ -291,44 +309,52 @@ def xarray_reduce( else: return result + ds = ds.drop_vars([var for var in maybe_drop if var in ds.variables]) + axis = tuple(range(-len(dim_tuple), 0)) # Set expected_groups and convert to index since we need coords, sizes # for output xarray objects - expected_groups = list(expected_groups) + expected_groups_valid_list: list[T_ExpectIndex] = [] group_names: tuple[Any, ...] = () group_sizes: dict[Any, int] = {} - for idx, (b_, expect, isbin_) in enumerate(zip(by_broad, expected_groups, isbins)): - group_name = b_.name if not isbin_ else f"{b_.name}_bins" + for idx, (b_, expect, isbin_) in enumerate(zip(by_da, expected_groups_valid, isbins)): + group_name = ( + f"{b_.name}_bins" if isbin_ or isinstance(expect, pd.IntervalIndex) else b_.name + ) group_names += (group_name,) if isbin_ and isinstance(expect, int): raise NotImplementedError( "flox does not support binning into an integer number of bins yet." ) + + expect1: T_ExpectOpt if expect is None: if isbin_: raise ValueError( f"Please provided bin edges for group variable {idx} " f"named {group_name} in expected_groups." ) - expect_ = _get_expected_groups(b_.data, sort=sort) + expect1 = _get_expected_groups(b_.data, sort=sort) else: - expect_ = expect - expect_index = _convert_expected_groups_to_index((expect_,), (isbin_,), sort=sort)[0] + expect1 = expect + expect_index = _convert_expected_groups_to_index((expect1,), (isbin_,), sort=sort)[0] # The if-check is for type hinting mainly, it narrows down the return # type of _convert_expected_groups_to_index to pure pd.Index: if expect_index is not None: - expected_groups[idx] = expect_index + expected_groups_valid_list.append(expect_index) group_sizes[group_name] = len(expect_index) else: # This will never be reached raise ValueError("expect_index cannot be None") - def wrapper(array, *by, func, skipna, **kwargs): + def wrapper(array, *by, func, skipna, core_dims, **kwargs): + array, *by = _broadcast_size_one_dims(array, *by, core_dims=core_dims) + # Handle skipna here because I need to know dtype to make a good default choice. - # We cannnot handle this easily for xarray Datasets in xarray_reduce + # We cannot handle this easily for xarray Datasets in xarray_reduce if skipna and func in ["all", "any", "count"]: raise ValueError(f"skipna cannot be truthy for {func} reductions.") @@ -348,8 +374,8 @@ def wrapper(array, *by, func, skipna, **kwargs): # xarray always uses np.datetime64[ns] for np.datetime64 data dtype = "timedelta64[ns]" array = datetime_to_numeric(array, offset) - elif _contains_cftime_datetimes(array): - offset = min(array) + elif is_cftime: + offset = array.min() array = datetime_to_numeric(array, offset, datetime_unit="us") result, *groups = groupby_reduce(array, *by, func=func, **kwargs) @@ -374,17 +400,21 @@ def wrapper(array, *by, func, skipna, **kwargs): if is_missing_dim: missing_dim[k] = v - input_core_dims = _get_input_core_dims(group_names, dim_tuple, ds_broad, grouper_dims) - input_core_dims += [input_core_dims[-1]] * (nby - 1) + # dim_tuple contains dimensions we are reducing over. These need to be the last + # core dimensions to be synchronized with axis. + input_core_dims = [[d for d in grouper_dims if d not in dim_tuple] + list(dim_tuple)] + input_core_dims += [list(b.dims) for b in by_da] + output_core_dims = [d for d in input_core_dims[0] if d not in dim_tuple] + output_core_dims.extend(group_names) actual = xr.apply_ufunc( wrapper, ds_broad.drop_vars(tuple(missing_dim)).transpose(..., *grouper_dims), - *by_broad, + *by_da, input_core_dims=input_core_dims, # for xarray's test_groupby_duplicate_coordinate_labels exclude_dims=set(dim_tuple), - output_core_dims=[group_names], + output_core_dims=[output_core_dims], dask="allowed", dask_gufunc_kwargs=dict( output_sizes=group_sizes, output_dtypes=[dtype] if dtype is not None else None @@ -400,35 +430,48 @@ def wrapper(array, *by, func, skipna, **kwargs): "skipna": skipna, "engine": engine, "reindex": reindex, - "expected_groups": tuple(expected_groups), + "expected_groups": tuple(expected_groups_valid_list), "isbin": isbins, "finalize_kwargs": finalize_kwargs, "dtype": dtype, + "core_dims": input_core_dims, }, ) # restore non-dim coord variables without the core dimension # TODO: shouldn't apply_ufunc handle this? - for var in set(ds_broad.variables) - set(ds_broad.dims): + for var in set(ds_broad._coord_names) - set(ds_broad._indexes) - set(ds_broad.dims): if all(d not in ds_broad[var].dims for d in dim_tuple): actual[var] = ds_broad[var] - for name, expect, by_ in zip(group_names, expected_groups, by_broad): - # Can't remove this till xarray handles IntervalIndex - if isinstance(expect, pd.IntervalIndex): - expect = expect.to_numpy() + expect3: T_ExpectIndex | np.ndarray + for name, expect2, by_ in zip(group_names, expected_groups_valid_list, by_da): + # Can't remove this until xarray handles IntervalIndex: + if isinstance(expect2, pd.IntervalIndex): + # TODO: Only place where expect3 is an ndarray, remove the type if xarray + # starts supporting IntervalIndex. + expect3 = expect2.to_numpy() + else: + expect3 = expect2 if isinstance(actual, xr.Dataset) and name in actual: actual = actual.drop_vars(name) # When grouping by MultiIndex, expect is an pd.Index wrapping # an object array of tuples - if name in ds_broad.indexes and isinstance(ds_broad.indexes[name], pd.MultiIndex): + if ( + name in ds_broad.indexes + and isinstance(ds_broad.indexes[name], pd.MultiIndex) + and not isinstance(expect3, pd.RangeIndex) + ): levelnames = ds_broad.indexes[name].names - expect = pd.MultiIndex.from_tuples(expect.values, names=levelnames) - actual[name] = expect + if isinstance(expect3, np.ndarray): + # TODO: workaoround for IntervalIndex issue. + raise NotImplementedError + expect3 = pd.MultiIndex.from_tuples(expect3.values, names=levelnames) + actual[name] = expect3 if Version(xr.__version__) > Version("2022.03.0"): actual = actual.set_coords(levelnames) else: - actual[name] = expect + actual[name] = expect3 if keep_attrs: actual[name].attrs = by_.attrs @@ -443,7 +486,7 @@ def wrapper(array, *by, func, skipna, **kwargs): template = obj if actual[var].ndim > 1: - actual[var] = _restore_dim_order(actual[var], template, by_broad[0]) + actual[var] = _restore_dim_order(actual[var], template, by_da[0]) if missing_dim: for k, v in missing_dim.items(): @@ -487,7 +530,7 @@ def rechunk_for_cohorts( Labels at which we always start a new chunk. For the example ``labels`` array, this would be `1`. chunksize : int, optional - nominal chunk size. Chunk size is exceded when the label + nominal chunk size. Chunk size is exceeded when the label in ``force_new_chunk_at`` is less than ``chunksize//2`` elements away. If None, uses median chunksize along ``dim``. @@ -511,7 +554,7 @@ def rechunk_for_cohorts( def rechunk_for_blockwise(obj: T_DataArray | T_Dataset, dim: str, labels: T_DataArray): """ Rechunks array so that group boundaries line up with chunk boundaries, allowing - embarassingly parallel group reductions. + embarrassingly parallel group reductions. This only works when the groups are sequential (e.g. labels = ``[0,0,0,1,1,1,1,2,2]``). @@ -553,44 +596,3 @@ def _rechunk(func, obj, dim, labels, **kwargs): ) return obj - - -def resample_reduce( - resampler: Resample, - func: str | Aggregation, - keep_attrs: bool = True, - **kwargs, -): - - warnings.warn( - "flox.xarray.resample_reduce is now deprecated. Please use Xarray's resample method directly.", - DeprecationWarning, - ) - - obj = resampler._obj - dim = resampler._group_dim - - # this creates a label DataArray since resample doesn't do that somehow - tostack = [] - for idx, slicer in enumerate(resampler._group_indices): - if slicer.stop is None: - stop = resampler._obj.sizes[dim] - else: - stop = slicer.stop - tostack.append(idx * np.ones((stop - slicer.start,), dtype=np.int32)) - by = xr.DataArray(np.hstack(tostack), dims=(dim,), name="__resample_dim__") - - result = ( - xarray_reduce( - obj, - by, - func=func, - method="blockwise", - keep_attrs=keep_attrs, - **kwargs, - ) - .rename({"__resample_dim__": dim}) - .transpose(dim, ...) - ) - result[dim] = resampler._unique_coord.data - return result diff --git a/flox/xrdtypes.py b/flox/xrdtypes.py index b333580c4..99dd08eb7 100644 --- a/flox/xrdtypes.py +++ b/flox/xrdtypes.py @@ -31,17 +31,6 @@ def __eq__(self, other): NINF = AlwaysLessThan() -# Pairs of types that, if both found, should be promoted to object dtype -# instead of following NumPy's own type-promotion rules. These type promotion -# rules match pandas instead. For reference, see the NumPy type hierarchy: -# https://docs.scipy.org/doc/numpy-1.13.0/reference/arrays.scalars.html -PROMOTE_TO_OBJECT = [ - {np.number, np.character}, # numpy promotes to character - {np.bool_, np.character}, # numpy promotes to character - {np.bytes_, np.unicode_}, # numpy promotes to unicode -] - - def maybe_promote(dtype): """Simpler equivalent of pandas.core.common._maybe_promote @@ -152,28 +141,3 @@ def get_neg_infinity(dtype, min_for_int=False): def is_datetime_like(dtype): """Check if a dtype is a subclass of the numpy datetime types""" return np.issubdtype(dtype, np.datetime64) or np.issubdtype(dtype, np.timedelta64) - - -def result_type(*arrays_and_dtypes): - """Like np.result_type, but with type promotion rules matching pandas. - - Examples of changed behavior: - number + string -> object (not string) - bytes + unicode -> object (not unicode) - - Parameters - ---------- - *arrays_and_dtypes : list of arrays and dtypes - The dtype is extracted from both numpy and dask arrays. - - Returns - ------- - numpy.dtype for the result. - """ - types = {np.result_type(t).type for t in arrays_and_dtypes} - - for left, right in PROMOTE_TO_OBJECT: - if any(issubclass(t, left) for t in types) and any(issubclass(t, right) for t in types): - return np.dtype(object) - - return np.result_type(*arrays_and_dtypes) diff --git a/flox/xrutils.py b/flox/xrutils.py index 3e6edd89e..958bd3976 100644 --- a/flox/xrutils.py +++ b/flox/xrutils.py @@ -1,12 +1,12 @@ # The functions defined here were copied based on the source code # defined in xarray - import datetime from typing import Any, Iterable import numpy as np import pandas as pd +from numpy.core.multiarray import normalize_axis_index # type: ignore[attr-defined] try: import cftime @@ -157,7 +157,7 @@ def datetime_to_numeric(array, offset=None, datetime_unit=None, dtype=float): if array.dtype.kind in "Mm": offset = _datetime_nanmin(array) else: - offset = min(array) + offset = array.min() # Compute timedelta object. # For np.datetime64, this can silently yield garbage due to overflow. @@ -181,7 +181,6 @@ def datetime_to_numeric(array, offset=None, datetime_unit=None, dtype=float): # Convert np.NaT to np.nan elif array.dtype.kind in "mM": - # Convert to specified timedelta units. if datetime_unit: array = array / np.timedelta64(1, datetime_unit) @@ -284,3 +283,36 @@ def _contains_cftime_datetimes(array) -> bool: return isinstance(sample, cftime.datetime) else: return False + + +def _select_along_axis(values, idx, axis): + other_ind = np.ix_(*[np.arange(s) for s in idx.shape]) + sl = other_ind[:axis] + (idx,) + other_ind[axis:] + return values[sl] + + +def nanfirst(values, axis, keepdims=False): + if isinstance(axis, tuple): + (axis,) = axis + values = np.asarray(values) + axis = normalize_axis_index(axis, values.ndim) + idx_first = np.argmax(~pd.isnull(values), axis=axis) + result = _select_along_axis(values, idx_first, axis) + if keepdims: + return np.expand_dims(result, axis=axis) + else: + return result + + +def nanlast(values, axis, keepdims=False): + if isinstance(axis, tuple): + (axis,) = axis + values = np.asarray(values) + axis = normalize_axis_index(axis, values.ndim) + rev = (slice(None),) * axis + (slice(None, None, -1),) + idx_last = -1 - np.argmax(~pd.isnull(values)[rev], axis=axis) + result = _select_along_axis(values, idx_last, axis) + if keepdims: + return np.expand_dims(result, axis=axis) + else: + return result diff --git a/pyproject.toml b/pyproject.toml index 32e55d712..fb27ee761 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,97 @@ +[project] +name = "flox" +description = "GroupBy operations for dask.array" +license = {file = "LICENSE"} +readme = "README.md" +requires-python = ">=3.8" +keywords = ["xarray", "dask", "groupby"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +dependencies = [ + "pandas", + "numpy>=1.20", + "numpy_groupies>=0.9.19", + "toolz", +] +dynamic=["version"] + + +[project.urls] +homepage = "https://flox.readthedocs.io" +documentation = "https://flox.readthedocs.io" +repository = "https://github.com/xarray-contrib/flox.git" +changelog = "https://github.com/xarray-contrib/flox/releases" + +[project.optional-dependencies] +all = ["cachey", "dask", "numba", "xarray"] +test = ["netCDF4"] + [build-system] requires = [ - "setuptools>=42", + "pandas", + "numpy>=1.20", + "numpy_groupies>=0.9.19", + "toolz", + "setuptools>=61.0.0", "wheel", - "setuptools_scm[toml]>=3.4", - "setuptools_scm_git_archive", + "setuptools_scm[toml]>=7.0", ] build-backend = "setuptools.build_meta" +[tool.setuptools] +packages = ["flox"] + +[tool.setuptools.dynamic] +version = {attr = "flox.__version__"} + [tool.setuptools_scm] fallback_version = "999" +write_to = "flox/_version.py" +write_to_template= '__version__ = "{version}"' [tool.black] line-length = 100 target-version = ["py38"] -[tool.isort] -profile = "black" -skip_gitignore = true -float_to_top = true -combine_as_imports = true -known_first_party = "flox" -known_third_party = [ +[tool.ruff] +target-version = "py38" +builtins = ["ellipsis"] +exclude = [ + ".eggs", + "doc", +] +# E402: module level import not at top of file +# E501: line too long - let black worry about that +# E731: do not assign a lambda expression, use a def +ignore = [ + "E402", + "E501", + "E731", +] +select = [ + # Pyflakes + "F", + # Pycodestyle + "E", + "W", + # isort + "I", + # Pyupgrade + "UP", +] + +[tool.ruff.isort] +known-first-party = ["flox"] +known-third-party = [ "dask", "numpy", "numpy_groupies", @@ -33,9 +104,9 @@ known_third_party = [ [tool.mypy] allow_redefinition = true -exclude = "properties|asv_bench|doc|tests|flycheck" -files = "flox/*.py" +files = "**/*.py" show_error_codes = true +warn_unused_ignores = true [[tool.mypy.overrides]] module=[ @@ -43,7 +114,8 @@ module=[ "cftime", "dask.*", "importlib_metadata", - "numpy_groupies", + "numba", + "numpy_groupies.*", "matplotlib.*", "pandas", "setuptools", @@ -53,3 +125,8 @@ ignore_missing_imports = true [tool.pytest.ini_options] addopts = "--tb=short" + + +[tool.codespell] +ignore-words-list = "nd,nax" +skip = "*.html" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3645e5bc7..000000000 --- a/setup.cfg +++ /dev/null @@ -1,61 +0,0 @@ -[metadata] -name = flox -author = flox Developers -author_email = deepak@cherian.net -license = Apache -description = GroupBy operations for dask.array -long_description = file: README.md -long_description_content_type=text/markdown - -url = https://github.com/xarray-contrib/flox -classifiers = - Development Status :: 4 - Beta - License :: OSI Approved :: Apache Software License - Operating System :: OS Independent - Intended Audience :: Science/Research - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Topic :: Scientific/Engineering - -[options] -packages = find: -zip_safe = False # https://mypy.readthedocs.io/en/latest/installed_packages.html -include_package_data = True -python_requires = >=3.8 -install_requires = - pandas - numpy >= '1.20' - numpy_groupies >= '0.9.15' - toolz - -[options.extras_require] -all = - cachey - dask - xarray -test = - netCDF4 - -[flake8] -ignore = - # whitespace before ':' - doesn't work well with black - E203 - E402 - # line too long - let black worry about that - E501 - # do not assign a lambda expression, use a def - E731 - # line break before binary operator - W503 - # too complex - C901 -per-file-ignores = - tests/*.py:F401,F811 -exclude= - .eggs - doc -builtins = - ellipsis diff --git a/tests/__init__.py b/tests/__init__.py index 7cf379a35..4c04a0fc8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,8 +1,8 @@ import importlib from contextlib import contextmanager -from distutils import version import numpy as np +import packaging.version import pandas as pd import pytest @@ -42,7 +42,7 @@ def LooseVersion(vstring): # Our development version is something like '0.10.9+aac7bfc' # This function just ignored the git commit id. vstring = vstring.split("+")[0] - return version.LooseVersion(vstring) + return packaging.version.Version(vstring) has_dask, requires_dask = _importorskip("dask") @@ -125,18 +125,3 @@ def assert_equal_tuple(a, b): np.testing.assert_array_equal(a_, b_) else: assert a_ == b_ - - -@pytest.fixture(scope="module", params=["numbagg"]) -def engine(request): - if request.param == "numba": - try: - import numba - except ImportError: - pytest.xfail() - if request.param == "numbagg": - try: - import numbagg - except ImportError: - pytest.xfail() - return request.param diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..d1cc301d7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import pytest + + +@pytest.fixture(scope="module", params=["numbagg"]) +def engine(request): + if request.param == "numba": + try: + import numba # noqa + except ImportError: + pytest.skip() + if request.param == "numbagg": + try: + import numbagg + except ImportError: + pytest.skip() + + return request.param diff --git a/tests/test_core.py b/tests/test_core.py index e31f11e56..83b823b07 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,18 +1,22 @@ from __future__ import annotations -from functools import reduce -from typing import TYPE_CHECKING +import itertools +import warnings +from functools import partial, reduce +from typing import TYPE_CHECKING, Callable import numpy as np import pandas as pd import pytest from numpy_groupies.aggregate_numpy import aggregate +from flox import xrutils from flox.aggregations import Aggregation from flox.core import ( _convert_expected_groups_to_index, _get_optimal_chunks_for_groups, _normalize_indexes, + _validate_reindex, factorize_, find_group_cohorts, groupby_reduce, @@ -24,7 +28,6 @@ from . import ( assert_equal, assert_equal_tuple, - engine, has_dask, raise_if_dask_computes, requires_dask, @@ -35,7 +38,6 @@ nan_labels[:5] = np.nan labels2d = np.array([labels[:5], np.flip(labels[:5])]) -# isort:off if has_dask: import dask import dask.array as da @@ -48,11 +50,12 @@ def dask_array_ones(*args): return None -# isort:on - ALL_FUNCS = ( "sum", "nansum", + "argmax", + "nanfirst", + "nanargmax", "prod", "nanprod", "mean", @@ -65,18 +68,32 @@ def dask_array_ones(*args): "nanmax", "min", "nanmin", - "argmax", - pytest.param("nanargmax", marks=(pytest.mark.skip,)), "argmin", - pytest.param("nanargmin", marks=(pytest.mark.skip,)), + "nanargmin", "any", "all", + "nanlast", pytest.param("median", marks=(pytest.mark.skip,)), pytest.param("nanmedian", marks=(pytest.mark.skip,)), ) if TYPE_CHECKING: - from flox.core import T_Engine, T_ExpectedGroupsOpt, T_Func2 + from flox.core import T_Agg, T_Engine, T_ExpectedGroupsOpt, T_Method + + +def _get_array_func(func: str) -> Callable: + if func == "count": + + def npfunc(x): + x = np.asarray(x) + return (~np.isnan(x)).sum() + + elif func in ["nanfirst", "nanlast"]: + npfunc = getattr(xrutils, func) + else: + npfunc = getattr(np, func) + + return npfunc def test_alignment_error(): @@ -89,7 +106,8 @@ def test_alignment_error(): @pytest.mark.parametrize("dtype", (float, int)) @pytest.mark.parametrize("chunk", [False, True]) -@pytest.mark.parametrize("expected_groups", [None, [0, 1, 2], np.array([0, 1, 2])]) +# TODO: make this intp when python 3.8 is dropped +@pytest.mark.parametrize("expected_groups", [None, [0, 1, 2], np.array([0, 1, 2], dtype=np.int64)]) @pytest.mark.parametrize( "func, array, by, expected", [ @@ -117,7 +135,7 @@ def test_alignment_error(): ) def test_groupby_reduce( engine: T_Engine, - func: T_Func2, + func: T_Agg, array: np.ndarray, by: np.ndarray, expected: list[float], @@ -133,13 +151,13 @@ def test_groupby_reduce( by = da.from_array(by, chunks=(3,) if by.ndim == 1 else (1, 3)) if func == "mean" or func == "nanmean": - expected_result = np.array(expected, dtype=float) + expected_result = np.array(expected, dtype=np.float64) elif func == "sum": expected_result = np.array(expected, dtype=dtype) elif func == "count": - expected_result = np.array(expected, dtype=int) + expected_result = np.array(expected, dtype=np.intp) - result, groups, = groupby_reduce( + (result, groups) = groupby_reduce( array, by, func=func, @@ -147,7 +165,14 @@ def test_groupby_reduce( fill_value=123, engine=engine, ) - g_dtype = by.dtype if expected_groups is None else np.asarray(expected_groups).dtype + # we use pd.Index(expected_groups).to_numpy() which is always int64 + # for the values in this tests + if expected_groups is None: + g_dtype = by.dtype + elif isinstance(expected_groups, np.ndarray): + g_dtype = expected_groups.dtype + else: + g_dtype = np.int64 assert_equal(groups, np.array([0, 1, 2], g_dtype)) assert_equal(expected_result, result) @@ -200,11 +225,27 @@ def test_groupby_reduce_all(nby, size, chunks, func, add_nan_by, engine): for kwargs in finalize_kwargs: flox_kwargs = dict(func=func, engine=engine, finalize_kwargs=kwargs, fill_value=fill_value) with np.errstate(invalid="ignore", divide="ignore"): - if "arg" in func and add_nan_by: - array[..., nanmask] = np.nan - expected = getattr(np, "nan" + func)(array, axis=-1, **kwargs) - else: - expected = getattr(np, func)(array[..., ~nanmask], axis=-1, **kwargs) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", r"All-NaN (slice|axis) encountered") + warnings.filterwarnings("ignore", r"Degrees of freedom <= 0 for slice") + warnings.filterwarnings("ignore", r"Mean of empty slice") + + # computing silences a bunch of dask warnings + array_ = array.compute() if chunks is not None else array + if "arg" in func and add_nan_by: + # NaNs are in by, but we can't call np.argmax([..., NaN, .. ]) + # That would return index of the NaN + # This way, we insert NaNs where there are NaNs in by, and + # call np.nanargmax + func_ = f"nan{func}" if "nan" not in func else func + array_[..., nanmask] = np.nan + expected = getattr(np, func_)(array_, axis=-1, **kwargs) + # elif func in ["first", "last"]: + # expected = getattr(xrutils, f"nan{func}")(array_[..., ~nanmask], axis=-1, **kwargs) + elif func in ["nanfirst", "nanlast"]: + expected = getattr(xrutils, func)(array_[..., ~nanmask], axis=-1, **kwargs) + else: + expected = getattr(np, func)(array_[..., ~nanmask], axis=-1, **kwargs) for _ in range(nby): expected = np.expand_dims(expected, -1) @@ -218,12 +259,30 @@ def test_groupby_reduce_all(nby, size, chunks, func, add_nan_by, engine): assert actual.dtype.kind == "i" assert_equal(actual, expected, tolerance) - if not has_dask: + if not has_dask or chunks is None: continue - for method in ["map-reduce", "cohorts", "split-reduce"]: - if "arg" in func and method != "map-reduce": + + params = list(itertools.product(["map-reduce"], [True, False, None])) + params.extend(itertools.product(["cohorts"], [False, None])) + if chunks == -1: + params.extend([("blockwise", None)]) + + for method, reindex in params: + call = partial( + groupby_reduce, array, *by, method=method, reindex=reindex, **flox_kwargs + ) + if ("arg" in func or func in ["first", "last"]) and reindex is True: + # simple_combine with argreductions not supported right now + with pytest.raises(NotImplementedError): + call() continue - actual, *groups = groupby_reduce(array, *by, method=method, **flox_kwargs) + actual, *groups = call() + if method != "blockwise": + if "arg" not in func: + # make sure we use simple combine + assert any("simple-combine" in key for key in actual.dask.layers.keys()) + else: + assert any("grouped-combine" in key for key in actual.dask.layers.keys()) for actual_group, expect in zip(groups, expected_groups): assert_equal(actual_group, expect, tolerance) if "arg" in func: @@ -258,7 +317,7 @@ def test_groupby_reduce_count(): array = np.array([0, 0, np.nan, np.nan, np.nan, 1, 1]) labels = np.array(["a", "b", "b", "b", "c", "c", "c"]) result, _ = groupby_reduce(array, labels, func="count") - assert_equal(result, [1, 1, 2]) + assert_equal(result, np.array([1, 1, 2], dtype=np.intp)) def test_func_is_aggregation(): @@ -367,53 +426,51 @@ def test_groupby_agg_dask(func, shape, array_chunks, group_chunks, add_nan, dtyp kwargs["expected_groups"] = [0, 2, 1] with raise_if_dask_computes(): actual, groups = groupby_reduce(array, by, engine=engine, **kwargs, sort=False) - assert_equal(groups, [0, 2, 1]) + assert_equal(groups, np.array([0, 2, 1], dtype=np.int64)) assert_equal(expected, actual[..., [0, 2, 1]]) - kwargs["expected_groups"] = [0, 2, 1] with raise_if_dask_computes(): actual, groups = groupby_reduce(array, by, engine=engine, **kwargs, sort=True) - assert_equal(groups, [0, 1, 2]) + assert_equal(groups, np.array([0, 1, 2], np.int64)) assert_equal(expected, actual) def test_numpy_reduce_axis_subset(engine): # TODO: add NaNs by = labels2d - array = np.ones_like(by) + array = np.ones_like(by, dtype=np.int64) kwargs = dict(func="count", engine=engine, fill_value=0) result, _ = groupby_reduce(array, by, **kwargs, axis=1) - assert_equal(result, [[2, 3], [2, 3]]) + assert_equal(result, np.array([[2, 3], [2, 3]], dtype=np.intp)) by = np.broadcast_to(labels2d, (3, *labels2d.shape)) array = np.ones_like(by) result, _ = groupby_reduce(array, by, **kwargs, axis=1) - subarr = np.array([[1, 1], [1, 1], [0, 2], [1, 1], [1, 1]]) + subarr = np.array([[1, 1], [1, 1], [0, 2], [1, 1], [1, 1]], dtype=np.intp) expected = np.tile(subarr, (3, 1, 1)) assert_equal(result, expected) result, _ = groupby_reduce(array, by, **kwargs, axis=2) - subarr = np.array([[2, 3], [2, 3]]) + subarr = np.array([[2, 3], [2, 3]], dtype=np.intp) expected = np.tile(subarr, (3, 1, 1)) assert_equal(result, expected) result, _ = groupby_reduce(array, by, **kwargs, axis=(1, 2)) - expected = np.array([[4, 6], [4, 6], [4, 6]]) + expected = np.array([[4, 6], [4, 6], [4, 6]], dtype=np.intp) assert_equal(result, expected) result, _ = groupby_reduce(array, by, **kwargs, axis=(2, 1)) assert_equal(result, expected) result, _ = groupby_reduce(array, by[0, ...], **kwargs, axis=(1, 2)) - expected = np.array([[4, 6], [4, 6], [4, 6]]) + expected = np.array([[4, 6], [4, 6], [4, 6]], dtype=np.intp) assert_equal(result, expected) @requires_dask def test_dask_reduce_axis_subset(): - by = labels2d - array = np.ones_like(by) + array = np.ones_like(by, dtype=np.int64) with raise_if_dask_computes(): result, _ = groupby_reduce( da.from_array(array, chunks=(2, 3)), @@ -422,11 +479,11 @@ def test_dask_reduce_axis_subset(): axis=1, expected_groups=[0, 2], ) - assert_equal(result, [[2, 3], [2, 3]]) + assert_equal(result, np.array([[2, 3], [2, 3]], dtype=np.intp)) by = np.broadcast_to(labels2d, (3, *labels2d.shape)) array = np.ones_like(by) - subarr = np.array([[1, 1], [1, 1], [123, 2], [1, 1], [1, 1]]) + subarr = np.array([[1, 1], [1, 1], [123, 2], [1, 1], [1, 1]], dtype=np.intp) expected = np.tile(subarr, (3, 1, 1)) with raise_if_dask_computes(): result, _ = groupby_reduce( @@ -439,7 +496,7 @@ def test_dask_reduce_axis_subset(): ) assert_equal(result, expected) - subarr = np.array([[2, 3], [2, 3]]) + subarr = np.array([[2, 3], [2, 3]], dtype=np.intp) expected = np.tile(subarr, (3, 1, 1)) with raise_if_dask_computes(): result, _ = groupby_reduce( @@ -460,6 +517,28 @@ def test_dask_reduce_axis_subset(): ) +@pytest.mark.parametrize("func", ["first", "last", "nanfirst", "nanlast"]) +@pytest.mark.parametrize("axis", [(0, 1)]) +def test_first_last_disallowed(axis, func): + with pytest.raises(ValueError): + groupby_reduce(np.empty((2, 3, 2)), np.ones((2, 3, 2)), func=func, axis=axis) + + +@requires_dask +@pytest.mark.parametrize("func", ["nanfirst", "nanlast"]) +@pytest.mark.parametrize("axis", [None, (0, 1, 2)]) +def test_nanfirst_nanlast_disallowed_dask(axis, func): + with pytest.raises(ValueError): + groupby_reduce(dask.array.empty((2, 3, 2)), np.ones((2, 3, 2)), func=func, axis=axis) + + +@requires_dask +@pytest.mark.parametrize("func", ["first", "last"]) +def test_first_last_disallowed_dask(func): + with pytest.raises(NotImplementedError): + groupby_reduce(dask.array.empty((2, 3, 2)), np.ones((2, 3, 2)), func=func, axis=-1) + + @requires_dask @pytest.mark.parametrize("func", ALL_FUNCS) @pytest.mark.parametrize( @@ -469,8 +548,12 @@ def test_groupby_reduce_axis_subset_against_numpy(func, axis, engine): if "arg" in func and engine == "flox": pytest.skip() - if not isinstance(axis, int) and "arg" in func and (axis is None or len(axis) > 1): - pytest.skip() + if not isinstance(axis, int): + if "arg" in func and (axis is None or len(axis) > 1): + pytest.skip() + if ("first" in func or "last" in func) and (axis is not None and len(axis) not in [1, 3]): + pytest.skip() + if func in ["all", "any"]: fill_value = False else: @@ -487,21 +570,49 @@ def test_groupby_reduce_axis_subset_against_numpy(func, axis, engine): kwargs = dict( func=func, axis=axis, expected_groups=[0, 2], fill_value=fill_value, engine=engine ) + expected, _ = groupby_reduce(array, by, **kwargs) + if engine == "flox": + kwargs.pop("engine") + expected_npg, _ = groupby_reduce(array, by, **kwargs, engine="numpy") + assert_equal(expected_npg, expected) + + if func in ["all", "any"]: + fill_value = False + else: + fill_value = 123 + + if "var" in func or "std" in func: + tolerance = {"rtol": 1e-14, "atol": 1e-16} + else: + tolerance = None + # tests against the numpy output to make sure dask compute matches + by = np.broadcast_to(labels2d, (3, *labels2d.shape)) + rng = np.random.default_rng(12345) + array = rng.random(by.shape) + kwargs = dict( + func=func, axis=axis, expected_groups=[0, 2], fill_value=fill_value, engine=engine + ) + expected, _ = groupby_reduce(array, by, **kwargs) + if engine == "flox": + kwargs.pop("engine") + expected_npg, _ = groupby_reduce(array, by, **kwargs, engine="numpy") + assert_equal(expected_npg, expected) + + if ("first" in func or "last" in func) and ( + axis is None or (not isinstance(axis, int) and len(axis) != 1) + ): + return + with raise_if_dask_computes(): actual, _ = groupby_reduce( da.from_array(array, chunks=(-1, 2, 3)), da.from_array(by, chunks=(-1, 2, 2)), **kwargs, ) - expected, _ = groupby_reduce(array, by, **kwargs) - if engine == "flox": - kwargs.pop("engine") - expected_npg, _ = groupby_reduce(array, by, **kwargs, engine="numpy") - assert_equal(expected_npg, expected) assert_equal(actual, expected, tolerance) -@pytest.mark.parametrize("chunks", [None, (2, 2, 3)]) +@pytest.mark.parametrize("reindex,chunks", [(None, None), (False, (2, 2, 3)), (True, (2, 2, 3))]) @pytest.mark.parametrize( "axis, groups, expected_shape", [ @@ -510,7 +621,7 @@ def test_groupby_reduce_axis_subset_against_numpy(func, axis, engine): (None, [0], (1,)), # global reduction; 0 shaped group axis; 1 group ], ) -def test_groupby_reduce_nans(chunks, axis, groups, expected_shape, engine): +def test_groupby_reduce_nans(reindex, chunks, axis, groups, expected_shape, engine): def _maybe_chunk(arr): if chunks: if not has_dask: @@ -533,6 +644,7 @@ def _maybe_chunk(arr): axis=axis, fill_value=0, engine=engine, + reindex=reindex, ) assert_equal(result, np.zeros(expected_shape, dtype=np.intp)) @@ -545,7 +657,10 @@ def _maybe_chunk(arr): @requires_dask -def test_groupby_all_nan_blocks(engine): +@pytest.mark.parametrize( + "expected_groups, reindex", [(None, None), (None, False), ([0, 1, 2], True), ([0, 1, 2], False)] +) +def test_groupby_all_nan_blocks_dask(expected_groups, reindex, engine): labels = np.array([0, 0, 2, 2, 2, 1, 1, 2, 2, 1, 1, 0]) nan_labels = labels.astype(float) # copy nan_labels[:5] = np.nan @@ -560,8 +675,10 @@ def test_groupby_all_nan_blocks(engine): da.from_array(array, chunks=(1, 3)), da.from_array(by, chunks=(1, 3)), func="sum", - expected_groups=None, + expected_groups=expected_groups, engine=engine, + reindex=reindex, + method="map-reduce", ) assert_equal(actual, expected) @@ -613,14 +730,21 @@ def test_npg_nanarg_bug(func): assert_equal(actual, expected) -@pytest.mark.parametrize("method", ["split-reduce", "cohorts", "map-reduce"]) +@pytest.mark.parametrize( + "kwargs", + ( + dict(expected_groups=np.array([1, 2, 4, 5]), isbin=True), + dict(expected_groups=pd.IntervalIndex.from_breaks([1, 2, 4, 5])), + ), +) +@pytest.mark.parametrize("method", ["cohorts", "map-reduce"]) @pytest.mark.parametrize("chunk_labels", [False, True]) @pytest.mark.parametrize("chunks", ((), (1,), (2,))) -def test_groupby_bins(chunk_labels, chunks, engine, method) -> None: +def test_groupby_bins(chunk_labels, kwargs, chunks, engine, method) -> None: array = [1, 1, 1, 1, 1, 1] labels = [0.2, 1.5, 1.9, 2, 3, 20] - if method in ["split-reduce", "cohorts"] and chunk_labels: + if method == "cohorts" and chunk_labels: pytest.xfail() if chunks: @@ -632,16 +756,9 @@ def test_groupby_bins(chunk_labels, chunks, engine, method) -> None: with raise_if_dask_computes(): actual, groups = groupby_reduce( - array, - labels, - func="count", - expected_groups=np.array([1, 2, 4, 5]), - isbin=True, - fill_value=0, - engine=engine, - method=method, + array, labels, func="count", fill_value=0, engine=engine, method=method, **kwargs ) - expected = np.array([3, 1, 0]) + expected = np.array([3, 1, 0], dtype=np.intp) for left, right in zip(groups, pd.IntervalIndex.from_arrays([1, 2, 4], [2, 4, 5]).to_numpy()): assert left == right assert_equal(actual, expected) @@ -719,15 +836,7 @@ def test_fill_value_behaviour(func, chunks, fill_value, engine): if chunks is not None and not has_dask: pytest.skip() - if func == "count": - - def npfunc(x): - x = np.asarray(x) - return (~np.isnan(x)).sum() - - else: - npfunc = getattr(np, func) - + npfunc = _get_array_func(func) by = np.array([1, 2, 3, 1, 2, 3]) array = np.array([np.nan, 1, 1, np.nan, 1, 1]) if chunks: @@ -735,7 +844,9 @@ def npfunc(x): actual, _ = groupby_reduce( array, by, func=func, engine=engine, fill_value=fill_value, expected_groups=[0, 1, 2, 3] ) - expected = np.array([fill_value, fill_value, npfunc([1.0, 1.0]), npfunc([1.0, 1.0])]) + expected = np.array( + [fill_value, fill_value, npfunc([1.0, 1.0], axis=0), npfunc([1.0, 1.0], axis=0)] + ) assert_equal(actual, expected) @@ -758,15 +869,21 @@ def test_dtype_preservation(dtype, func, engine): @requires_dask -@pytest.mark.parametrize("method", ["split-reduce", "map-reduce", "cohorts"]) -def test_cohorts(method): - repeats = [4, 4, 12, 2, 3, 4] - labels = np.repeat(np.arange(6), repeats) - array = dask.array.from_array(labels, chunks=(4, 8, 4, 9, 4)) +@pytest.mark.parametrize("dtype", [np.float32, np.float64, np.int32, np.int64]) +@pytest.mark.parametrize("labels_dtype", [np.float32, np.float64, np.int32, np.int64]) +@pytest.mark.parametrize("method", ["map-reduce", "cohorts"]) +def test_cohorts_map_reduce_consistent_dtypes(method, dtype, labels_dtype): + repeats = np.array([4, 4, 12, 2, 3, 4], dtype=np.int32) + labels = np.repeat(np.arange(6, dtype=labels_dtype), repeats) + array = dask.array.from_array(labels.astype(dtype), chunks=(4, 8, 4, 9, 4)) actual, actual_groups = groupby_reduce(array, labels, func="count", method=method) - assert_equal(actual_groups, np.arange(6)) - assert_equal(actual, repeats) + assert_equal(actual_groups, np.arange(6, dtype=labels.dtype)) + assert_equal(actual, repeats.astype(np.intp)) + + actual, actual_groups = groupby_reduce(array, labels, func="sum", method=method) + assert_equal(actual_groups, np.arange(6, dtype=labels.dtype)) + assert_equal(actual, np.array([0, 4, 24, 6, 12, 20], dtype)) @requires_dask @@ -778,7 +895,7 @@ def test_cohorts_nd_by(func, method, axis, engine): o2 = dask.array.ones((2, 3), chunks=-1) array = dask.array.block([[o, 2 * o], [3 * o2, 4 * o2]]) - by = array.compute().astype(int) + by = array.compute().astype(np.int64) by[0, 1] = 30 by[2, 1] = 40 by[0, 4] = 31 @@ -794,6 +911,8 @@ def test_cohorts_nd_by(func, method, axis, engine): if axis is not None and method != "map-reduce": pytest.xfail() + if axis is None and ("first" in func or "last" in func): + pytest.skip() kwargs = dict(func=func, engine=engine, method=method, axis=axis, fill_value=fill_value) actual, groups = groupby_reduce(array, by, **kwargs) @@ -802,10 +921,7 @@ def test_cohorts_nd_by(func, method, axis, engine): assert_equal(actual, expected) actual, groups = groupby_reduce(array, by, sort=False, **kwargs) - if method == "map-reduce": - assert_equal(groups, [1, 30, 2, 31, 3, 4, 40]) - else: - assert_equal(groups, [1, 30, 2, 31, 3, 40, 4]) + assert_equal(groups, np.array([1, 30, 2, 31, 3, 4, 40], dtype=np.int64)) reindexed = reindex_(actual, groups, pd.Index(sorted_groups)) assert_equal(reindexed, expected) @@ -848,9 +964,10 @@ def test_datetime_binning(): expected = pd.IntervalIndex.from_arrays(time_bins[:-1], time_bins[1:]) assert_equal(actual, expected) - ret = factorize_((by.to_numpy(),), axis=0, expected_groups=(actual,)) + ret = factorize_((by.to_numpy(),), axes=(0,), expected_groups=(actual,)) group_idx = ret[0] - expected = pd.cut(by, time_bins).codes.copy() + # Ignore pd.cut's dtype as it won't match np.digitize: + expected = pd.cut(by, time_bins).codes.copy().astype(group_idx.dtype) expected[0] = 14 # factorize doesn't return -1 for nans assert_equal(group_idx, expected) @@ -861,7 +978,8 @@ def test_bool_reductions(func, engine): pytest.skip() groups = np.array([1, 1, 1]) data = np.array([True, True, False]) - expected = np.expand_dims(getattr(np, func)(data), -1) + npfunc = _get_array_func(func) + expected = np.expand_dims(npfunc(data, axis=0), -1) actual, _ = groupby_reduce(data, groups, func=func, engine=engine) assert_equal(expected, actual) @@ -874,14 +992,14 @@ def test_map_reduce_blockwise_mixed() -> None: dask.array.from_array(data.values, chunks=365), t.dt.month, func="mean", - method="split-reduce", + method="map-reduce", ) expected, _ = groupby_reduce(data, t.dt.month, func="mean") assert_equal(expected, actual) @requires_dask -@pytest.mark.parametrize("method", ["split-reduce", "blockwise", "map-reduce", "cohorts"]) +@pytest.mark.parametrize("method", ["blockwise", "map-reduce", "cohorts"]) def test_group_by_datetime(engine, method): kwargs = dict( func="mean", @@ -916,10 +1034,10 @@ def test_group_by_datetime(engine, method): def test_factorize_values_outside_bins(): - + # pd.factorize returns intp vals = factorize_( (np.arange(10).reshape(5, 2), np.arange(10).reshape(5, 2)), - axis=(0, 1), + axes=(0, 1), expected_groups=( pd.IntervalIndex.from_breaks(np.arange(2, 8, 1)), pd.IntervalIndex.from_breaks(np.arange(2, 8, 1)), @@ -928,67 +1046,140 @@ def test_factorize_values_outside_bins(): fastpath=True, ) actual = vals[0] - expected = np.array([[-1, -1], [-1, 0], [6, 12], [18, 24], [-1, -1]]) + expected = np.array([[-1, -1], [-1, 0], [6, 12], [18, 24], [-1, -1]], np.intp) assert_equal(expected, actual) -def test_multiple_groupers() -> None: +@pytest.mark.parametrize("chunk", [True, False]) +def test_multiple_groupers_bins(chunk) -> None: + if chunk and not has_dask: + pytest.skip() + + xp = dask.array if chunk else np + array_kwargs = {"chunks": 2} if chunk else {} + array = xp.ones((5, 2), **array_kwargs, dtype=np.int64) + actual, *_ = groupby_reduce( - np.ones((5, 2)), - np.arange(10).reshape(5, 2), + array, np.arange(10).reshape(5, 2), + xp.arange(10).reshape(5, 2), axis=(0, 1), expected_groups=( pd.IntervalIndex.from_breaks(np.arange(2, 8, 1)), pd.IntervalIndex.from_breaks(np.arange(2, 8, 1)), ), - reindex=True, func="count", ) - expected = np.eye(5, 5, dtype=int) + # output from `count` is intp + expected = np.eye(5, 5, dtype=np.intp) + assert_equal(expected, actual) + + +@pytest.mark.parametrize("expected_groups", [None, (np.arange(5), [2, 3]), (None, [2, 3])]) +@pytest.mark.parametrize( + "by1", [np.arange(5)[:, None], np.broadcast_to(np.arange(5)[:, None], (5, 2))] +) +@pytest.mark.parametrize( + "by2", + [ + np.arange(2, 4).reshape(1, 2), + np.broadcast_to(np.arange(2, 4).reshape(1, 2), (5, 2)), + np.arange(2, 4).reshape(1, 2), + ], +) +@pytest.mark.parametrize("chunk", [True, False]) +def test_multiple_groupers(chunk, by1, by2, expected_groups) -> None: + if chunk and (not has_dask or expected_groups is None): + pytest.skip() + + xp = dask.array if chunk else np + array_kwargs = {"chunks": 2} if chunk else {} + array = xp.ones((5, 2), **array_kwargs, dtype=np.int64) + + if chunk: + by2 = dask.array.from_array(by2) + + # output from `count` is intp + expected = np.ones((5, 2), dtype=np.intp) + actual, *_ = groupby_reduce( + array, by1, by2, axis=(0, 1), func="count", expected_groups=expected_groups + ) assert_equal(expected, actual) +@pytest.mark.parametrize( + "expected_groups", + ( + [None, None, None], + (None,), + ), +) +def test_validate_expected_groups(expected_groups): + with pytest.raises(ValueError): + groupby_reduce( + np.ones((10,)), + np.ones((10,)), + np.ones((10,)), + expected_groups=expected_groups, + func="mean", + ) + + +@requires_dask +def test_validate_expected_groups_not_none_dask() -> None: + with pytest.raises(ValueError): + groupby_reduce( + dask.array.ones((5, 2)), + np.arange(10).reshape(5, 2), + dask.array.arange(10).reshape(5, 2), + axis=(0, 1), + expected_groups=None, + func="count", + ) + + def test_factorize_reindex_sorting_strings(): + # pd.factorize seems to return intp so int32 on 32bit arch kwargs = dict( by=(np.array(["El-Nino", "La-Nina", "boo", "Neutral"]),), - axis=-1, + axes=(-1,), expected_groups=(np.array(["El-Nino", "Neutral", "foo", "La-Nina"]),), ) expected = factorize_(**kwargs, reindex=True, sort=True)[0] - assert_equal(expected, [0, 1, 4, 2]) + assert_equal(expected, np.array([0, 1, 4, 2], dtype=np.intp)) expected = factorize_(**kwargs, reindex=True, sort=False)[0] - assert_equal(expected, [0, 3, 4, 1]) + assert_equal(expected, np.array([0, 3, 4, 1], dtype=np.intp)) expected = factorize_(**kwargs, reindex=False, sort=False)[0] - assert_equal(expected, [0, 1, 2, 3]) + assert_equal(expected, np.array([0, 1, 2, 3], dtype=np.intp)) expected = factorize_(**kwargs, reindex=False, sort=True)[0] - assert_equal(expected, [0, 1, 3, 2]) + assert_equal(expected, np.array([0, 1, 3, 2], dtype=np.intp)) def test_factorize_reindex_sorting_ints(): + # pd.factorize seems to return intp so int32 on 32bit arch kwargs = dict( by=(np.array([-10, 1, 10, 2, 3, 5]),), - axis=-1, - expected_groups=(np.array([0, 1, 2, 3, 4, 5]),), + axes=(-1,), + expected_groups=(np.array([0, 1, 2, 3, 4, 5], np.int64),), ) expected = factorize_(**kwargs, reindex=True, sort=True)[0] - assert_equal(expected, [6, 1, 6, 2, 3, 5]) + assert_equal(expected, np.array([6, 1, 6, 2, 3, 5], dtype=np.intp)) expected = factorize_(**kwargs, reindex=True, sort=False)[0] - assert_equal(expected, [6, 1, 6, 2, 3, 5]) + assert_equal(expected, np.array([6, 1, 6, 2, 3, 5], dtype=np.intp)) kwargs["expected_groups"] = (np.arange(5, -1, -1),) expected = factorize_(**kwargs, reindex=True, sort=True)[0] - assert_equal(expected, [6, 1, 6, 2, 3, 5]) + assert_equal(expected, np.array([6, 1, 6, 2, 3, 5], dtype=np.intp)) expected = factorize_(**kwargs, reindex=True, sort=False)[0] - assert_equal(expected, [6, 4, 6, 3, 2, 0]) + assert_equal(expected, np.array([6, 4, 6, 3, 2, 0], dtype=np.intp)) @requires_dask @@ -1125,3 +1316,152 @@ def test_subset_block_2d(flatblocks, expectidx): subset = subset_to_blocks(array, flatblocks) assert len(subset.dask.layers) == 2 assert_equal(subset, array.compute()[expectidx]) + + +@pytest.mark.parametrize( + "dask_expected, reindex, func, expected_groups, any_by_dask", + [ + # argmax only False + [False, None, "argmax", None, False], + # True when by is numpy but expected is None + [True, None, "sum", None, False], + # False when by is dask but expected is None + [False, None, "sum", None, True], + # if expected_groups then always True + [True, None, "sum", [1, 2, 3], False], + [True, None, "sum", ([1], [2]), False], + [True, None, "sum", ([1], [2]), True], + [True, None, "sum", ([1], None), False], + [True, None, "sum", ([1], None), True], + ], +) +def test_validate_reindex_map_reduce( + dask_expected, reindex, func, expected_groups, any_by_dask +) -> None: + actual = _validate_reindex( + reindex, func, "map-reduce", expected_groups, any_by_dask, is_dask_array=True + ) + assert actual is dask_expected + + # always reindex with all numpy inputs + actual = _validate_reindex( + reindex, func, "map-reduce", expected_groups, any_by_dask=False, is_dask_array=False + ) + assert actual + + actual = _validate_reindex( + True, func, "map-reduce", expected_groups, any_by_dask=False, is_dask_array=False + ) + assert actual + + +def test_validate_reindex() -> None: + methods: list[T_Method] = ["map-reduce", "cohorts"] + for method in methods: + with pytest.raises(NotImplementedError): + _validate_reindex( + True, "argmax", method, expected_groups=None, any_by_dask=False, is_dask_array=True + ) + + methods: list[T_Method] = ["blockwise", "cohorts"] + for method in methods: + with pytest.raises(ValueError): + _validate_reindex( + True, "sum", method, expected_groups=None, any_by_dask=False, is_dask_array=True + ) + + for func in ["sum", "argmax"]: + actual = _validate_reindex( + None, func, method, expected_groups=None, any_by_dask=False, is_dask_array=True + ) + assert actual is False + + +@requires_dask +def test_1d_blockwise_sort_optimization(): + # Make sure for resampling problems sorting isn't done. + time = pd.Series(pd.date_range("2020-09-01", "2020-12-31 23:59", freq="3H")) + array = dask.array.ones((len(time),), chunks=(224,)) + + actual, _ = groupby_reduce(array, time.dt.dayofyear.values, method="blockwise", func="count") + assert all("getitem" not in k for k in actual.dask) + + actual, _ = groupby_reduce( + array, time.dt.dayofyear.values[::-1], sort=True, method="blockwise", func="count" + ) + assert any("getitem" in k for k in actual.dask.layers) + + actual, _ = groupby_reduce( + array, time.dt.dayofyear.values[::-1], sort=False, method="blockwise", func="count" + ) + assert all("getitem" not in k for k in actual.dask.layers) + + +@requires_dask +def test_negative_index_factorize_race_condition(): + # shape = (10, 2000) + # chunks = ((shape[0]-1,1), 10) + shape = (101, 174000) + chunks = ((101,), 8760) + eps = dask.array.random.random_sample(shape, chunks=chunks) + N2 = dask.array.random.random_sample(shape, chunks=chunks) + S2 = dask.array.random.random_sample(shape, chunks=chunks) + + bins = np.arange(-5, -2.05, 0.1) + func = ["mean", "count", "sum"] + + out = [ + groupby_reduce( + eps, + N2, + S2, + func=f, + expected_groups=(bins, bins), + isbin=(True, True), + ) + for f in func + ] + [dask.compute(out, scheduler="threads") for _ in range(5)] + + +@pytest.mark.parametrize("sort", [True, False]) +def test_expected_index_conversion_passthrough_range_index(sort): + index = pd.RangeIndex(100) + actual = _convert_expected_groups_to_index( + expected_groups=(index,), isbin=(False,), sort=(sort,) + ) + assert actual[0] is index + + +def test_method_check_numpy(): + bins = [-2, -1, 0, 1, 2] + field = np.ones((5, 3)) + by = np.array([[-1.5, -1.5, 0.5, 1.5, 1.5] * 3]).reshape(5, 3) + actual, _ = groupby_reduce( + field, + by, + expected_groups=pd.IntervalIndex.from_breaks(bins), + func="count", + method="cohorts", + fill_value=np.nan, + ) + expected = np.array([6, np.nan, 3, 6]) + assert_equal(actual, expected) + + actual, _ = groupby_reduce( + field, + by, + expected_groups=pd.IntervalIndex.from_breaks(bins), + func="count", + fill_value=np.nan, + method="cohorts", + axis=0, + ) + expected = np.array( + [ + [2.0, np.nan, 1.0, 2.0], + [2.0, np.nan, 1.0, 2.0], + [2.0, np.nan, 1.0, 2.0], + ] + ) + assert_equal(actual, expected) diff --git a/tests/test_xarray.py b/tests/test_xarray.py index 0bee41c15..7a343d962 100644 --- a/tests/test_xarray.py +++ b/tests/test_xarray.py @@ -6,16 +6,14 @@ xr = pytest.importorskip("xarray") # isort: on -from flox.xarray import rechunk_for_blockwise, resample_reduce, xarray_reduce +from flox.xarray import rechunk_for_blockwise, xarray_reduce -from . import assert_equal, engine, has_dask, raise_if_dask_computes, requires_dask +from . import assert_equal, has_dask, raise_if_dask_computes, requires_dask -# isort: off if has_dask: import dask dask.config.set(scheduler="sync") -# isort: on try: # Should test against legacy xarray implementation @@ -168,17 +166,26 @@ def test_xarray_reduce_multiple_groupers_2(pass_expected_groups, chunk, engine): @requires_dask -def test_dask_groupers_error(): +@pytest.mark.parametrize( + "expected_groups", + (None, (None, None), [[1, 2], [1, 2]]), +) +def test_validate_expected_groups(expected_groups): da = xr.DataArray( [1.0, 2.0], dims="x", coords={"labels": ("x", [1, 2]), "labels2": ("x", [1, 2])} ) with pytest.raises(ValueError): - xarray_reduce(da.chunk({"x": 2, "z": 1}), "labels", "labels2", func="count") + xarray_reduce( + da.chunk({"x": 1}), + "labels", + "labels2", + func="count", + expected_groups=expected_groups, + ) @requires_dask def test_xarray_reduce_single_grouper(engine): - # DataArray ds = xr.tutorial.open_dataset("rasm", chunks={"time": 9}) actual = xarray_reduce(ds.Tair, ds.time.dt.month, func="mean", engine=engine) @@ -223,7 +230,6 @@ def test_xarray_reduce_single_grouper(engine): def test_xarray_reduce_errors(): - da = xr.DataArray(np.ones((12,)), dims="x") by = xr.DataArray(np.ones((12,)), dims="x") @@ -239,47 +245,6 @@ def test_xarray_reduce_errors(): xarray_reduce(da, by.chunk(), func="mean") -@pytest.mark.parametrize("isdask", [True, False]) -@pytest.mark.parametrize("dataarray", [True, False]) -@pytest.mark.parametrize("chunklen", [27, 4 * 31 + 1, 4 * 31 + 20]) -def test_xarray_resample(chunklen, isdask, dataarray, engine): - if isdask: - if not has_dask: - pytest.skip() - ds = xr.tutorial.open_dataset("air_temperature", chunks={"time": chunklen}) - else: - ds = xr.tutorial.open_dataset("air_temperature") - - if dataarray: - ds = ds.air - - resampler = ds.resample(time="M") - with pytest.warns(DeprecationWarning): - actual = resample_reduce(resampler, "mean", engine=engine) - expected = resampler.mean() - xr.testing.assert_allclose(actual, expected) - - with xr.set_options(use_flox=True): - actual = resampler.mean() - xr.testing.assert_allclose(actual, expected) - - -@requires_dask -def test_xarray_resample_dataset_multiple_arrays(engine): - # regression test for #35 - times = pd.date_range("2000", periods=5) - foo = xr.DataArray(range(5), dims=["time"], coords=[times], name="foo") - bar = xr.DataArray(range(1, 6), dims=["time"], coords=[times], name="bar") - ds = xr.merge([foo, bar]).chunk({"time": 4}) - - resampler = ds.resample(time="4D") - # The separate computes are necessary here to force xarray - # to compute all variables in result at the same time. - expected = resampler.mean().compute() - result = resample_reduce(resampler, "mean", engine=engine).compute() - xr.testing.assert_allclose(expected, result) - - @requires_dask @pytest.mark.parametrize( "inchunks, expected", @@ -336,6 +301,8 @@ def test_multi_index_groupby_sum(engine): expected = ds.sum("z") stacked = ds.stack(space=["x", "y"]) actual = xarray_reduce(stacked, "space", dim="z", func="sum", engine=engine) + expected_xarray = stacked.groupby("space").sum("z") + assert_equal(expected_xarray, actual) assert_equal(expected, actual.unstack("space")) actual = xarray_reduce(stacked.foo, "space", dim="z", func="sum", engine=engine) @@ -430,22 +397,9 @@ def test_cache(): assert len(cache.data) == 2 -@pytest.mark.parametrize("use_cftime", [True, False]) -@pytest.mark.parametrize("func", ["count", "mean"]) -def test_datetime_array_reduce(use_cftime, func, engine): - - time = xr.DataArray( - xr.date_range("2009-01-01", "2012-12-31", use_cftime=use_cftime), - dims=("time",), - name="time", - ) - expected = getattr(time.resample(time="YS"), func)() - actual = resample_reduce(time.resample(time="YS"), func=func, engine=engine) - assert_equal(expected, actual) - - @requires_dask -def test_groupby_bins_indexed_coordinate(): +@pytest.mark.parametrize("method", ["cohorts", "map-reduce"]) +def test_groupby_bins_indexed_coordinate(method): ds = ( xr.tutorial.open_dataset("air_temperature") .isel(time=slice(100)) @@ -460,7 +414,17 @@ def test_groupby_bins_indexed_coordinate(): expected_groups=([40, 50, 60, 70],), isbin=(True,), func="mean", - method="split-reduce", + method=method, + ) + xr.testing.assert_allclose(expected, actual) + + actual = xarray_reduce( + ds, + ds.lat, + dim=ds.air.dims, + expected_groups=pd.IntervalIndex.from_breaks([40, 50, 60, 70]), + func="mean", + method=method, ) xr.testing.assert_allclose(expected, actual) @@ -499,6 +463,12 @@ def test_mixed_grouping(chunk): assert (r.sel(v1=[3, 4, 5]) == 0).all().data +def test_alignment_error(): + da = xr.DataArray(np.arange(10), dims="x", coords={"x": np.arange(10)}) + with pytest.raises(ValueError): + xarray_reduce(da, da.x.sel(x=slice(5)), func="count") + + @pytest.mark.parametrize("add_nan", [True, False]) @pytest.mark.parametrize("dtype_out", [np.float64, "float64", np.dtype("float64")]) @pytest.mark.parametrize("dtype", [np.float32, np.float64]) @@ -570,3 +540,34 @@ def test_dtype_accumulation(use_flox, chunk): assert np.issubdtype(actual.dtype, np.float64) assert np.issubdtype(actual.compute().dtype, np.float64) xr.testing.assert_allclose(expected, actual, **tolerance64) + + +def test_preserve_multiindex(): + """Regression test for GH issue #215""" + + vort = xr.DataArray( + name="vort", + data=np.random.uniform(size=(4, 2)), + dims=["i", "face"], + coords={"i": ("i", np.arange(4)), "face": ("face", np.arange(2))}, + ) + + vort = ( + vort.coarsen(i=2) + .construct(i=("i_region_coarse", "i_region")) + .stack(region=["face", "i_region_coarse"]) + ) + + bins = [np.linspace(0, 1, 10)] + bin_intervals = tuple(pd.IntervalIndex.from_breaks(b) for b in bins) + + hist = xarray_reduce( + xr.DataArray(1), # weights + vort, # variables we want to bin + func="count", # count occurrences falling in bins + expected_groups=bin_intervals, # bins for each variable + dim=["i_region"], # broadcast dimensions + fill_value=0, # fill empty bins with 0 counts + ) + + assert "region" in hist.coords