Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. The format

## Unreleased

- Add a ChainRulesCore extension defining `frule`s and `rrule`s for
`logmeanexp`, `logvarexp` and `logstdexp`, making them differentiable with
ChainRules-based AD packages (e.g. Zygote).
- Support immutable arrays (e.g. `StaticArrays`) and complex arrays.
- Results preserve the input eltype (e.g. `Float32` in → `Float32` out), and
the `log(N)` normalization is computed in the result's precision (so e.g.
Expand Down
7 changes: 7 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ projects = ["test", "docs"]
[deps]
LogExpFunctions = "2ab3a3ac-af41-5b50-aa03-7779005ae688"

[weakdeps]
ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4"

[extensions]
LogStatFunctionsChainRulesCoreExt = "ChainRulesCore"

[compat]
ChainRulesCore = "1"
LogExpFunctions = "0.3.26, 1.0"
julia = "1.10"
5 changes: 5 additions & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ logstdexp(A; corrected=false)
`logvarexp` and `logstdexp` also accept `logmean` to reuse a precomputed
`logmeanexp(A; dims)`.

All three functions have
[ChainRules](https://github.com/JuliaDiff/ChainRulesCore.jl) derivative rules
(loaded automatically when ChainRulesCore is in the environment), so they can be
differentiated with ChainRules-based AD packages such as Zygote.

## Reference

```@docs
Expand Down
74 changes: 74 additions & 0 deletions ext/LogStatFunctionsChainRulesCoreExt.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
module LogStatFunctionsChainRulesCoreExt

using LogStatFunctions: logmeanexp, logvarexp, logstdexp
import ChainRulesCore

function ChainRulesCore.frule((_, Δx), ::typeof(logmeanexp), x::AbstractArray{<:Real}; dims = :)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Qualify or implement the advertised complex-input rules

The package already supports complex arrays for logmeanexp, but every new rule is restricted to AbstractArray{<:Real} while the added documentation says all three functions have ChainRules derivatives without stating that limitation. Consequently, rrule(logmeanexp, complex_x) has no method from this extension and complex users do not receive the advertised AD support; either add the correctly conjugated complex rule or document the real-input restriction.

Useful? React with 👍 / 👎.

Ω = logmeanexp(x; dims)
n = length(x) ÷ length(Ω)
ΔΩ = sum(exp.(x .- Ω) .* Δx; dims) ./ n
return Ω, ΔΩ
end

function ChainRulesCore.rrule(::typeof(logmeanexp), x::AbstractArray{<:Real}; dims = :)
Ω = logmeanexp(x; dims)
n = length(x) ÷ length(Ω)
return Ω, _∂x_pullback(exp.(x .- Ω) ./ n, x)
end

function ChainRulesCore.frule(
(_, Δx), ::typeof(logvarexp), x::AbstractArray{<:Real};
dims = :, corrected::Bool = true, logmean = logmeanexp(x; dims)
)
Ω = logvarexp(x; dims, corrected, logmean)
ΔΩ = sum(_∂x_logvarexp(x, logmean, dims) .* Δx; dims)
return Ω, ΔΩ
end

function ChainRulesCore.rrule(
::typeof(logvarexp), x::AbstractArray{<:Real};
dims = :, corrected::Bool = true, logmean = logmeanexp(x; dims)
)
Ω = logvarexp(x; dims, corrected, logmean)
return Ω, _∂x_pullback(_∂x_logvarexp(x, logmean, dims), x)
end

function ChainRulesCore.frule(
(_, Δx), ::typeof(logstdexp), x::AbstractArray{<:Real};
dims = :, corrected::Bool = true, logmean = logmeanexp(x; dims)
)
Ω = logstdexp(x; dims, corrected, logmean)
ΔΩ = sum(_∂x_logvarexp(x, logmean, dims) ./ 2 .* Δx; dims)
return Ω, ΔΩ
end

function ChainRulesCore.rrule(
::typeof(logstdexp), x::AbstractArray{<:Real};
dims = :, corrected::Bool = true, logmean = logmeanexp(x; dims)
)
Ω = logstdexp(x; dims, corrected, logmean)
return Ω, _∂x_pullback(_∂x_logvarexp(x, logmean, dims) / 2, x)
end

# ∂/∂xⱼ log(var(exp.(x))) = 2 exp(xⱼ) (exp(xⱼ) - m) / Σᵢ (exp(xᵢ) - m)², with m = exp(logmean).
# The m-dependence on x drops out because Σᵢ (exp(xᵢ) - m) = 0, and `corrected` only shifts
# the result by a constant, so the gradient is the same either way.
function _∂x_logvarexp(x::AbstractArray{<:Real}, logmean, dims)
d = x .- logmean
e = expm1.(d)
return (2 .* exp.(d) .* e) ./ sum(abs2, e; dims)
Comment on lines +57 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the variance gradient denominator numerically scaled

For nearly equal but representably distinct inputs, squaring e can underflow even though both the primal and its derivative are finite; for example, with Float64[-1e-200, 1e-200], sum(abs2, e) becomes zero while the expected log-variance gradient is on the order of 1e200, so these rules return Inf/NaN. Compute the ratio with log-domain arithmetic or scale e before forming the sum of squares.

Useful? React with 👍 / 👎.

end

function _∂x_pullback(∂x, x)
project_x = ChainRulesCore.ProjectTo(x)
function pullback(Ω̄)
x̄ = ChainRulesCore.InplaceableThunk(
Δ -> Δ .+= Ω̄ .* ∂x,
ChainRulesCore.@thunk(project_x(Ω̄ .* ∂x)),
Comment on lines +66 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Unthunk the incoming cotangent before broadcasting

When an upstream ChainRules pullback supplies Ω̄ as a supported Thunk, both branches capture that wrapper and later attempt Ω̄ .* ∂x; evaluating the returned tangent then fails because the thunk itself is not the cotangent array or scalar. Unthunk Ω̄ before using it, or explicitly compose the thunks so this rule works in reverse-mode chains that preserve lazy cotangents.

Useful? React with 👍 / 👎.

)
return ChainRulesCore.NoTangent(), x̄
end
return pullback
end

end # module
2 changes: 2 additions & 0 deletions test/Project.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[deps]
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
ChainRulesTestUtils = "cdddcdb0-9152-4a09-a978-84456f9df70a"
ExplicitImports = "7d51a73a-1435-4ff3-83d9-f097790105c7"
LogStatFunctions = "1b3c2205-da69-4893-b2df-717075b12251"
StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
Expand All @@ -8,6 +9,7 @@ Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[compat]
Aqua = "0.8"
ChainRulesTestUtils = "1"
ExplicitImports = "1.15"
StaticArrays = "1"
Statistics = "1.10"
Expand Down
17 changes: 17 additions & 0 deletions test/chainrules.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Test: @testset
using ChainRulesTestUtils: test_frule, test_rrule
using LogStatFunctions: logmeanexp, logvarexp, logstdexp

@testset "chainrules" begin
for x in (randn(10), randn(10, 8)), dims in (:, 1, 1:2, 2)
dims isa Colon || all(d ≤ ndims(x) for d in dims) || continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Handle scalar dims before iterating

When the loop reaches dims = 1 or dims = 2, this condition attempts to iterate an Int, which raises a MethodError in Julia before any derivative checks run. Normalize an integer dimension to a one-element tuple, or handle dims isa Integer separately, so the new test suite can complete.

Useful? React with 👍 / 👎.

test_frule(logmeanexp, x; fkwargs = (; dims))
test_rrule(logmeanexp, x; fkwargs = (; dims))
for corrected in (true, false)
test_frule(logvarexp, x; fkwargs = (; dims, corrected))
test_rrule(logvarexp, x; fkwargs = (; dims, corrected))
test_frule(logstdexp, x; fkwargs = (; dims, corrected))
test_rrule(logstdexp, x; fkwargs = (; dims, corrected))
end
end
end
3 changes: 3 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
module functions_tests
include("functions.jl")
end
module chainrules_tests
include("chainrules.jl")
end
module explicit_imports_tests
include("explicit_imports.jl")
end
Expand Down