Skip to content

Add backend-agnostic lgamma op - #23541

Merged
hertschuh merged 3 commits into
keras-team:masterfrom
SamanehSaadat:lgamma
Sep 2, 2026
Merged

Add backend-agnostic lgamma op#23541
hertschuh merged 3 commits into
keras-team:masterfrom
SamanehSaadat:lgamma

Conversation

@SamanehSaadat

Copy link
Copy Markdown
Member

Description

This PR adds backend-agnostic lgamma op. Note that the lgamma op doesn't have backend-specific implementations.

Contributor Agreement

Please review our AI-Assisted Contribution Policy and check all boxes below before submitting your PR for review:

  • [x ] I am a human, and not a bot.
  • [x ] I will be responsible for responding to review comments in a timely manner.
  • [ x] I will work with the maintainers to push this PR forward until submission.

Note: Failing to adhere to this agreement may result in your future PRs no longer being reviewed.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request adds the lgamma mathematical operation to Keras ops, implementing a backend-agnostic fallback using the Lanczos approximation, along with corresponding unit tests. The feedback highlights a few critical improvements: first, scipy.special must be imported locally in the new test cases to prevent NameError failures; second, native lgamma implementations should be added to the respective backend wrappers (TensorFlow, JAX, PyTorch, and NumPy) to prefer optimized native execution over the fallback; and finally, several mathematical constants should be precomputed rather than calculated at runtime to reduce symbolic overhead.

Comment thread keras/src/ops/math_test.py
Comment thread keras/src/ops/math_test.py
Comment thread keras/src/ops/math_test.py
Comment thread keras/src/ops/math.py
Comment on lines +1435 to +1439
def _lgamma(x):
if not config._use_backend_agnostic_ops() and hasattr(
backend.math, "lgamma"
):
return backend.math.lgamma(x)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

While the backend-agnostic Lanczos approximation is a great fallback, relying on it for all backends is inefficient and potentially less numerically stable than using native implementations. TensorFlow (tf.math.lgamma), JAX (jax.lax.lgamma), and PyTorch (torch.lgamma) all provide highly optimized, native lgamma implementations.

To align with existing repository patterns (such as erf, erfc, etc.), please implement lgamma in the respective backend wrappers (e.g., keras/src/backend/tensorflow/math.py, keras/src/backend/jax/math.py, keras/src/backend/torch/math.py, and keras/src/backend/numpy/math.py using scipy.special.gammaln) so that hasattr(backend.math, "lgamma") evaluates to True and the native implementations are preferred.

Comment thread keras/src/ops/math.py Outdated
Comment on lines +1459 to +1461
_LOG_LANCZOS_GAMMA_PLUS_HALF = ops.cast(
ops.log(_LANCZOS_GAMMA_PLUS_HALF), compute_dtype
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Instead of computing ops.log on a constant value at runtime, we can precompute this constant to avoid unnecessary symbolic operations and runtime overhead.

    _LOG_LANCZOS_GAMMA_PLUS_HALF = ops.cast(2.0149030205422647, compute_dtype)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I kept it this way to improve readability

Comment thread keras/src/ops/math.py Outdated
Comment on lines +1469 to +1471
log_sqrt_two_pi = ops.cast(
(ops.log(2.0) + ops.log(_PI)) / 2.0, compute_dtype
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Instead of computing ops.log on constant values at runtime, we can precompute log(sqrt(2π)) as a constant to avoid unnecessary symbolic operations and runtime overhead.

    log_sqrt_two_pi = ops.cast(0.9189385332046727, compute_dtype)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

for readability

Comment thread keras/src/ops/math.py
Comment on lines +1479 to +1483
reflection = ops.where(
ops.isfinite(reflection_denom),
ops.cast(ops.log(_PI), compute_dtype) - reflection_denom - log_y,
-reflection_denom,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Instead of computing ops.log(_PI) at runtime, we can precompute log(π) as a constant to avoid unnecessary symbolic operations and runtime overhead.

Suggested change
reflection = ops.where(
ops.isfinite(reflection_denom),
ops.cast(ops.log(_PI), compute_dtype) - reflection_denom - log_y,
-reflection_denom,
)
reflection = ops.where(
ops.isfinite(reflection_denom),
ops.cast(1.1447298858494002, compute_dtype) - reflection_denom - log_y,
-reflection_denom,
)

@codecov-commenter

codecov-commenter commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.11765% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.03%. Comparing base (15b3141) to head (de063be).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
keras/src/ops/math.py 94.11% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master   #23541   +/-   ##
=======================================
  Coverage   85.02%   85.03%           
=======================================
  Files         468      468           
  Lines       71087    71142   +55     
  Branches    11788    11792    +4     
=======================================
+ Hits        60442    60494   +52     
- Misses       7632     7634    +2     
- Partials     3013     3014    +1     
Flag Coverage Δ
keras 84.84% <94.11%> (+<0.01%) ⬆️
keras-cpu 84.11% <94.11%> (+<0.01%) ⬆️
keras-gpu 69.98% <94.11%> (+0.01%) ⬆️
keras-jax 58.66% <94.11%> (+0.02%) ⬆️
keras-numpy 53.98% <94.11%> (+0.02%) ⬆️
keras-openvino 59.68% <94.11%> (+0.02%) ⬆️
keras-tensorflow 60.25% <94.11%> (+0.02%) ⬆️
keras-torch 59.76% <94.11%> (+0.02%) ⬆️
keras-tpu 57.49% <94.11%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

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

@hertschuh hertschuh 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.

Thanks for adding this!

Can you also add the backend specific implementations?

  • Numpy: scipy.special.gammaln
  • JAX: jax.scipy.special.gammaln
  • Tensorflow: tf.math.lgamma
  • Torch: torch.lgamma

I think they'll be faster. But the fallback will still be used by OpenVino and new backends.

Comment thread keras/src/ops/math.py Outdated

# If the input is less than 0.5 use Euler's reflection formula:
# gamma(x) = pi / (sin(pi * x) * gamma(1 - x))
need_to_reflect = x < 0.5

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.

You're using a lot of Python built-in ops (+, /, - etc.). I think those are mostly fine (i.e. they work with native tensors in all backends).

But for the comparison, I'd rather use the op:

need_to_reflect = ops.less(x, 0.5)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done

Comment thread keras/src/ops/math.py Outdated
Comment on lines +1458 to +1466
_LANCZOS_GAMMA_PLUS_HALF = _LANCZOS_GAMMA + 0.5
_LOG_LANCZOS_GAMMA_PLUS_HALF = ops.cast(
ops.log(_LANCZOS_GAMMA_PLUS_HALF), compute_dtype
)

t = z + _LANCZOS_GAMMA_PLUS_HALF
log_t = _LOG_LANCZOS_GAMMA_PLUS_HALF + ops.log1p(
z / _LANCZOS_GAMMA_PLUS_HALF
)

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.

Up until here, the constants were consistently cast to compute_dtype. But starting from here, some casts are missing.

For instance when you do t = z + _LANCZOS_GAMMA_PLUS_HALF, the _LANCZOS_GAMMA_PLUS_HALF is still the raw Python float64 and wasn't cast to compute_dtype.

Same line 1465 z / _LANCZOS_GAMMA_PLUS_HALF.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for catching that! Done!

Comment thread keras/src/ops/math.py Outdated
Comment on lines +1468 to +1471
# log(sqrt(2π)) = (log(2) + log(pi)) / 2
log_sqrt_two_pi = ops.cast(
(ops.log(2.0) + ops.log(_PI)) / 2.0, compute_dtype
)

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.

I agree with you on the readability of not hardcoding a constant for this. The flip side is that we don't want to re-compute the constants on each call. So here is a compromise. It computes the constants once for all using the Python math function (import math).

line 1433:

_LOG_SQRT_TWO_PI = (math.log(2.0) + math.log(_PI)) / 2.0

Then

log_sqrt_two_pi = ops.cast(_LOG_SQRT_TWO_PI, compute_dtype)

You can do the same with _LANCZOS_GAMMA_PLUS_HALF and _LOG_LANCZOS_GAMMA_PLUS_HALF.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good idea! Done!

Comment thread keras/src/ops/math.py Outdated

abs_x = ops.abs(x)
abs_frac_x = abs_x - ops.floor(abs_x)
reduced_frac_x = ops.where(abs_frac_x > 0.5, 1.0 - abs_frac_x, abs_frac_x)

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.

ops.greater(abs_frac_x, 0.5)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done

@SamanehSaadat SamanehSaadat left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for the review, Fabien!
Is it okay if I add backend-specific implementations in a separate PR and keep this one focused on the backend-agnostic implementation?

Comment thread keras/src/ops/math.py Outdated

# If the input is less than 0.5 use Euler's reflection formula:
# gamma(x) = pi / (sin(pi * x) * gamma(1 - x))
need_to_reflect = x < 0.5

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done

Comment thread keras/src/ops/math.py Outdated
Comment on lines +1458 to +1466
_LANCZOS_GAMMA_PLUS_HALF = _LANCZOS_GAMMA + 0.5
_LOG_LANCZOS_GAMMA_PLUS_HALF = ops.cast(
ops.log(_LANCZOS_GAMMA_PLUS_HALF), compute_dtype
)

t = z + _LANCZOS_GAMMA_PLUS_HALF
log_t = _LOG_LANCZOS_GAMMA_PLUS_HALF + ops.log1p(
z / _LANCZOS_GAMMA_PLUS_HALF
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for catching that! Done!

Comment thread keras/src/ops/math.py Outdated
Comment on lines +1468 to +1471
# log(sqrt(2π)) = (log(2) + log(pi)) / 2
log_sqrt_two_pi = ops.cast(
(ops.log(2.0) + ops.log(_PI)) / 2.0, compute_dtype
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good idea! Done!

Comment thread keras/src/ops/math.py Outdated

abs_x = ops.abs(x)
abs_frac_x = abs_x - ops.floor(abs_x)
reduced_frac_x = ops.where(abs_frac_x > 0.5, 1.0 - abs_frac_x, abs_frac_x)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done

@hertschuh hertschuh 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.

Thanks!

@google-ml-butler google-ml-butler Bot added kokoro:force-run ready to pull Ready to be merged into the codebase labels Sep 1, 2026
@hertschuh
hertschuh merged commit c934679 into keras-team:master Sep 2, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready to pull Ready to be merged into the codebase size:M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants