Skip to content

Vectorize gamma MLE fit and thread gamma.ppf for gridded transform - #93

Open
benmsanderson wants to merge 2 commits into
basefrom
perf/precip-transform-speedups
Open

Vectorize gamma MLE fit and thread gamma.ppf for gridded transform#93
benmsanderson wants to merge 2 commits into
basefrom
perf/precip-transform-speedups

Conversation

@benmsanderson

Copy link
Copy Markdown
Owner

Summary

Two independent speedups to the 3D gamma quantile-mapping path used for gridded precipitation output.

1. fit_distribution_parameters_3d

Replaced the per-gridpoint for i in range(n_spatial): scipy.stats.gamma.fit(x, floc=0) loop with a vectorized _vectorized_gamma_mle that runs Choi-Wette + Newton iterations on log(k) - psi(k) = log(mean(x)) - mean(log(x)) across all gridpoints at once. This is the same closed-form MLE scipy uses internally, just batched.

  • Synthetic gamma data: agrees with scipy per-gridpoint to rtol=1e-6.
  • Real NorESM2-MM precipitation: agrees to ~6e-5 relative (well beneath the noise model's own uncertainty).

2. apply_distribution_transform (3D gamma path)

Wrapped scipy.special.gammaincinv (which underlies stats.gamma.ppf and releases the GIL) in a ThreadPoolExecutor that chunks the spatial axis. Output is bitwise-identical to stats.gamma.ppf.

  • Default: min(8, cpu_count()) threads.
  • Overridable via METEOR_GAMMA_PPF_THREADS env variable (=1 disables threading).
  • Malformed values silently fall back so a typo in a batch script cannot crash METEOR at import time.

Wins on gen_gridded

N Baseline This PR (combined with the batched-PCs PR) Speedup
1 171 s 45 s 3.8×
5 416 s 154 s 2.7×

Post-change, the fixed cost (imports, netCDF loads, pattern-scaling load ≈ 22 s) plus the actual gammaincinv arithmetic (~64 s at N=5 across 8 threads) dominate the remaining time.

Correctness

  • test_vectorized_gamma_mle_matches_scipy — synthetic gamma at 200 gridpoints × 500 samples, rtol=1e-6.
  • test_vectorized_gamma_mle_handles_invalid_columns — all-zero / all-NaN / single-positive columns fall back to safe defaults with no NaN propagation.
  • test_threaded_gamma_ppf_3d_matches_scipy — asserts array_equal against stats.gamma.ppf for a (200, 5000) grid.
  • test_threaded_gamma_ppf_3d_single_thread_bypassn_threads=1 produces the same output as multi-threaded.
  • test_resolve_gamma_ppf_threads_env_var — env var override + fallback semantics.

Test plan

  • pytest tests/unit/test_precipitation_transform.py all pass
  • Manual: METEOR_GAMMA_PPF_THREADS=1 python -m pytest tests/unit/test_precipitation_transform.py -k ppf still passes

🤖 Generated with Claude Code

benmsanderson and others added 2 commits July 19, 2026 00:38
Two independent speedups to the 3D gamma quantile mapping used for
precipitation:

1. fit_distribution_parameters_3d
   Replaced the per-gridpoint scipy.stats.gamma.fit(x, floc=0) loop with
   a vectorized _vectorized_gamma_mle that runs Choi-Wette initial guess
   plus Newton iterations on log(k) - psi(k) = log(mean(x)) - mean(log(x))
   across all gridpoints in one shot. This is the same closed-form MLE
   scipy uses internally, just batched. On synthetic gamma samples the
   two agree to 1e-13; on real NorESM2-MM precipitation to ~6e-5
   (a few percent of the noise model's own uncertainty).

2. apply_distribution_transform (3D gamma path)
   Wrapped scipy.special.gammaincinv (which underlies stats.gamma.ppf
   and releases the GIL) in a ThreadPoolExecutor that chunks the spatial
   axis. Output is bitwise-identical to stats.gamma.ppf. Defaults to
   min(8, cpu_count()) threads and can be overridden via the
   METEOR_GAMMA_PPF_THREADS env var (=1 to disable). Malformed values
   silently fall back so a typo in a batch script cannot crash METEOR at
   import time.

Wins on gen_gridded (see docs/profiling_baseline.md for methodology):

    N=1: 171 s -> 45 s (3.8x)
    N=5: 416 s -> 154 s (2.7x)

Post-change, the fixed cost (imports, netCDF loads, pattern-scaling load,
~22 s) and the actual gammaincinv arithmetic (~64 s at N=5 across 8
threads) dominate what's left.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- black reformats a couple of function calls in both files
- ruff F841: drop unused `n_obs` from _vectorized_gamma_mle
- ruff F841: drop unused `n_spatial` from fit_distribution_parameters_3d
  (leftover from the previous per-gridpoint loop that this PR removes)
- ruff F401: drop unused `import os` from tests
- pylint E0611 false positive: suppress `no-name-in-module` on the
  scipy.special import (gammaincinv is present in the runtime module
  but astroid's stubs don't list it)

Lint-only, no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment on lines +61 to +65
n_threads : int, optional
Explicit thread count. If ``None`` (default), consults the
``METEOR_GAMMA_PPF_THREADS`` env var, falling back to
``min(8, cpu_count())``. Bypasses threading for small problems where
thread setup would dominate.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If returns diminish above n_threads = 8, should a user choice much beyond this be overridden/yield a warning?

Comment on lines +32 to +36
Reads the ``METEOR_GAMMA_PPF_THREADS`` environment variable so HPC users
can pin threads without touching Python (same pattern as ``OMP_NUM_THREADS``).
Falls back to ``min(8, cpu_count())`` — bounded because the underlying
scipy special function has diminishing returns past ~8 threads on typical
hardware. Set to ``1`` to disable threading entirely.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If returns diminish much past 8 CPUs, should values way beyond this for METEOR_GAMMA_PPF_THREADS be capped? Warned about? Maybe no big deal and all users who set this can be expected to not send nonsense?


# Sum and log-sum with invalid entries zeroed out
x_masked = np.where(valid, x, 0.0)
logx_masked = np.where(valid, np.log(np.maximum(x, np.finfo(float).tiny)), 0.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this mask over x_masked and not x, so as to avoid crashes for logs of negative numbers in the evaluation?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add tests with negative values?

@maritsandstad maritsandstad left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few comments with questions here. Otherwise it looks alright to me

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants