Efficient analytic gradient of the LMM objective + gradient-based optimizers#903
Draft
palday wants to merge 19 commits into
Draft
Efficient analytic gradient of the LMM objective + gradient-based optimizers#903palday wants to merge 19 commits into
palday wants to merge 19 commits into
Conversation
Replace the per-parameter blocked-solve gradient evaluation with an
adjoint formulation: the objective is affine in log diag(L), so
grad_p = <S, dOmega_p> with S = L'^{-1} W L^{-1} shared by all
parameters. A single blocked computation of the lower blocks of L^{-1}
(GradientWorkspace + _invL!) followed by weighted Gram products against
the sparse structure of the A blocks yields the whole gradient, for ML,
REML, and fixed-sigma objectives.
- export objective_gradient!(g, m[, theta]), returning the objective value
- NLopt backend: fill the gradient slot for the (opt-in) LD_LBFGS,
LD_MMA, and LD_SLSQP optimizers; the default remains LN_NEWUOA;
profiling falls back to a derivative-free optimizer
- test/grad.jl: compare against the ForwardDiff extension across the
model zoo at initial, perturbed, and converged theta, plus REML,
fixed sigma, and gradient-based fit smoke tests
- remove the per-parameter WIP gradient code
The gradient evaluation is 73x faster than ForwardDiff on the maximal
kb07 model and 8x faster on insteval; gradient-based fits need far
fewer objective evaluations on many-parameter models (mrk17_exp1:
131 vs 2537; kb07 maximal: 71 vs 845).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The objective and its gradient scale with the number of observations, so on the deviance scale the identity is a poor initial inverse-Hessian estimate and the first line-search step of LBFGS overshoots wildly for large data sets (on ml1m, n = 10^6, the first step landed at theta of about -9800 and the line search eventually failed near the theta = 0 saddle). Handing the LD_* optimizers the per-observation objective and gradient fixes the geometry: LBFGS on ml1m now converges in 13 evaluations to the same optimum as LN_NEWUOA (previously FAILURE after 81 evaluations, 2430 above the optimum), and every benchmarked model needs fewer evaluations (mrk17_exp1: 91 vs 131; kb07 maximal: 49 vs 71). fitlog, the progress display, and the reported fmin stay on the deviance scale. ftol_rel is invariant under the scaling; ftol_abs now acts as a per-observation tolerance for the LD_* optimizers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
For two scalar random-effects terms whose Cholesky fill block is dense (the classic crossed-effects fill-in, e.g. ml1m), the dominant cost of the gradient is the cross term S[r,b] on the sparsity pattern of A[r,b]. The previous evaluation used one BLAS-1 column dot product per nonzero of A[r,b], which is memory-bandwidth bound (~2 Gflop/s measured). When the crossing is dense enough, evaluate the cross term instead with a BLAS-3 matrix product X[r,r]' X[r,b], a column panel at a time, so the transient product is bounded to q_r x GRAD_PANEL and never fully materialized. This runs at BLAS-3 throughput (~80 Gflop/s) and, despite computing more entries than are ultimately used, is faster whenever nnz(A[r,b]) exceeds ~3% of q_r*q_b (the BLAS-1/BLAS-3 rate ratio). The path is gated on that density and on the fill block being dense, so sparse crossings (e.g. insteval) keep the BLAS-1 path unchanged. Results are bitwise-identical to the sparse path; on ml1m the gradient is ~1.24x faster. Selected inversion (Takahashi), the originally proposed follow-up, does not help here: with dense Cholesky fill the factor pattern is dense, so forming X[r,b] directly is already cheaper than the selected-inverse alternative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unit-test the panelled kernel against a dense reference across a multi-panel span, and add a small partially-crossed model (sparse A[2,1], dense fill) that exercises the density gate, checking the BLAS-3 path agrees with the sparse path and with ForwardDiff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add generic (non-BLAS) fallback methods for rankUpdate! and cholUnblocked! and split setθ!, updateL!, pwrss and logdet into array-based methods with model-based wrappers, so that the ForwardDiff extension can run the same blocked-Cholesky pipeline on dual-promoted copies instead of maintaining parallel fd_* implementations. fd_deviance now profiles σ (or holds it at optsum.sigma when fixed) and includes the constant weights term, matching objective(), so ForwardDiff gradients and Hessians refer to the profiled objective and agree with objective_gradient! at any θ without syncing the model state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New `gradient` keyword for fit/fit!, stored in OptSummary: the default :analytic uses objective_gradient!, while :forwarddiff evaluates the gradient by forward-mode automatic differentiation and requires that ForwardDiff.jl be loaded. The ForwardDiff path caches the dual-promoted copies of the model's numerical fields together with the GradientConfig in a reusable workspace, so repeated gradient evaluations within an optimization do not re-promote. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compares LN_NEWUOA against LD_LBFGS with gradient=:analytic and gradient=:forwarddiff on kb07 (maximal, 20 covariance parameters) and ml1m (~1M rows, crossed scalar random effects), recording fit time, bytes allocated, allocation count and peak RSS. Each configuration runs in its own subprocess so that Sys.maxrss() reflects that configuration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both conflicting files (src/gradient.jl, test/grad.jl) were delete/modify conflicts: db/pa/gradient's commit e62ff21 refined the original per-parameter gradient draft (Omega_dot_diag_block!, copyskip!, initialize_blocks!, grad_blocks, and the "single_vector" testset), while this branch had already replaced that entire draft with the adjoint formulation (GradientWorkspace + _invL! + gram/contraction kernels, validated against ForwardDiff). Resolved by keeping the adjoint version, discarding the superseded per-parameter code and its tests; no other files differed between the branches.
Collaborator
|
When running the tests I keep getting a test failure of the form but if I rerun that example by hand I get a Is anyone else encountering this? For reference this is fitting the model |
Blocks of X = L⁻¹ in the GradientWorkspace now mirror the structure of the corresponding blocks of L: Diagonal and UniformBlockDiagonal diagonal blocks stay block-diagonal, and BlockedSparse off-diagonal blocks (nested grouping factors) are stored as SparseMatrixCSC mirrors sharing the pattern of the L block, gated by a construction-time check (block-diagonal fill block, block-aligned columns, pattern containment for intermediate terms). Pairs with a sparse A block between terms of any dimension evaluate only the entries of S on the pattern of A into a sparse buffer contracted blockwise, instead of allocating dense S/C1/C2 buffers. For the fggk21 model (1 + Test | Child nested in School, q ≈ 545k) the workspace previously requested a dense 541475² block (~2.3 PB); it is now ~1 GiB, about 2.3× the storage of L itself, and fit! with LD_LBFGS peaks at ~3.5 GB RSS. The kb07/ml1m benchmarks and all previous gradient paths are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Store the per-pair evaluation route explicitly in the workspace (GradientWorkspace.path) instead of re-deriving it at evaluation time from the runtime type of the S buffer, where a 0×0 placeholder implicitly meant the scalar path. - Drop the sparse'sparse _gramacc! method that allocated a sparse product per gradient evaluation; SparseArrays' five-argument mul! for adjoint(sparse) × sparse into a dense target is allocation-free, so the generic fallback covers it. - Share the per-entry inner products between the scalar and selected-entry accumulation paths (_xdot/_xydot) so the [Xy]-row weighting convention has a single definition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… into pa/gradient-fable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds an efficient analytic gradient of the profiled objective (deviance / REML criterion) for
LinearMixedModel, exposes it as the exported functionobjective_gradient!, and wires it into the NLopt backend so that the gradient-based optimizers:LD_LBFGS,:LD_MMA, and:LD_SLSQPcan be selected viafit(MixedModel, form, data; optimizer=:LD_LBFGS). The default optimizer remains the derivative-free:LN_NEWUOA.It also reworks the ForwardDiff extension to reuse the core linear-algebra pipeline instead of maintaining parallel
fd_*implementations, and adds agradientkeyword tofit/fit!(:analyticdefault,:forwarddiffopt-in) so that ForwardDiff can serve as an alternative gradient source for the LD optimizers — see the sections below the BLAS-3 one.Approach
The objective is an affine function of the logarithms of the diagonal elements of the blocked Cholesky factor,
obj = 2 Σⱼ wⱼ log Lⱼⱼ + const, with weights 1 on the random-effects rows, 1 (REML) or 0 (ML) on the fixed-effects rows, and n, n − p, or pwrss/σ² on the ℓ_yy element for ML, REML, and fixed-σ objectives respectively. Differentiating Ω = LLᵀ (Murray 2016, arXiv:1602.07527) gives∂obj/∂θₚ = tr(W L⁻¹ Ω̇ₚ L⁻ᵀ) = ⟨S, Ω̇ₚ⟩, S = L⁻ᵀWL⁻¹,
where S is shared by all parameters. A single blocked computation of the lower blocks of L⁻¹ (stored in a reusable
GradientWorkspace), followed by weighted Gram products contracted against the sparse structure of the A blocks, therefore yields the entire gradient: Ω̇ₚ is never formed and no solve is repeated per parameter. Blocks between scalar terms with sparse A, and Diagonal/UniformBlockDiagonal diagonal blocks, use selected-entry kernels that evaluate only the entries of S that the contraction touches.This replaces the earlier per-parameter draft in
src/gradient.jl, which performed two full blocked triangular solves per parameter and disagreed with ForwardDiff for vector-valued terms (Λ where Λᵀ was needed inOmega_dot_diag_block!, and a missing Λᵣᵀ premultiplication of the off-diagonal blocks).API
objective_gradient!(g, m)— gradient at the current parameter values; returns the objective value.objective_gradient!(g, m, θ)— installs θ viasetθ!/updateL!first, mirroringobjective!.MixedModels.GradientWorkspace(m)(internal) — preallocated storage for allocation-free repeated evaluation inside the optimizer loop.fitlog, the progress display, and the reportedfminstay on the deviance scale;ftol_relis invariant under the scaling andftol_absacts as a per-observation tolerance.Performance
Gradient evaluation vs the ForwardDiff extension: ~60× faster on the maximal kb07 model, ~9× on insteval, with far less allocation.
Fits, gradient-based vs derivative-free:
Gradient-based optimization wins decisively on many-parameter models: mrk17 fits ~5× faster and reaches an objective 0.031 lower; kb07 fits ~7× faster. On ml1m (n = 10⁶, 2 parameters) LD_LBFGS needs only 13 evaluations but each gradient costs ~4× an objective evaluation, so wall time is on par with the derivative-free default;
LD_MMAandLD_SLSQPalso converge cleanly there (19 and 25 evaluations). Without the per-observation scaling, LBFGS failed outright on ml1m — see the second commit.Dense-crossed cross term (BLAS-3)
For two scalar random-effects terms whose Cholesky factor has dense fill-in (the classic crossed-effects case, e.g.
ml1m), the dominant cost of the gradient is the cross termS[r,b]on the sparsity pattern ofA[r,b], previously one BLAS-1 column dot product per nonzero — memory-bandwidth bound (~2 Gflop/s measured). When the crossing is dense enough, this is now evaluated with a BLAS-3 matrix productX[r,r]ᵀX[r,b]a column-panel at a time (transient product bounded toq_r × 128, never fully materialized), running at ~80 Gflop/s. The path is gated on crossing density (nnz > 0.03·q_r·q_b) and dense fill, so sparse crossings (e.g.insteval) keep the BLAS-1 path; results are identical up to floating-point reassociation. Onml1mthe gradient is ~1.24× faster.Note on the original selected-inverse follow-up: a Takahashi-style selected inversion does not help these models. With dense Cholesky fill the factor pattern is dense, so forming
X[r,b]directly is already cheaper than the selected-inverse alternative (which needs the full denseZ[r,r]), and the dominant selected-entry extraction cost is unchanged. The measurement that establishes this is in the commit message; the achievable win was the BLAS-3 restructuring above.ForwardDiff extension rework
The extension previously duplicated the core objective pipeline in out-of-place
fd_*variants (fd_setθ!,fd_updateL!,fd_pwrss,fd_logdet,fd_cholUnblocked!,fd_rankUpdate!). Every divergence from core was one of: array arguments instead of model fields, a BLAS/LAPACK call requiringBlasFloat, or a::Float64/::Tassertion blocking dual numbers. All six are now deleted in favor of:rankUpdate!(densesyrk!and UBD +BlockedSparsesyr!paths; the BLAS methods are nowT<:BlasFloatspecializations and the dense-C signatures are narrowed toHermOrSym{T,Matrix{T}}to avoid ambiguity with theDiagonal/UniformBlockDiagonal-storage methods) and forcholUnblocked!(genericcholesky!(Hermitian(A, :L)); theDiagonalbound is relaxed fromAbstractFloattoReal);setθ!,updateL!,pwrss, and a_logdethelper, with the model-based methods as thin wrappers.Float64 dispatch still reaches the BLAS/LAPACK methods (verified with
which);updateL!time and allocations on kb07 are identical tomain, and no new method ambiguities are introduced.Semantics change:
fd_deviancenow profiles σ (or holds it atoptsum.sigmawhen fixed) and includes the constant weights term, exactly matchingobjective.ForwardDiff.gradient/hessiantherefore refer to the profiled objective and agree withobjective_gradient!at any θ (≤1e-12 on sleepstudy) without syncing the model state first — previously agreement relied on the envelope theorem at synced state. The Hessian reference values intest/forwarddiff.jlchanged accordingly.Gradient source selection
fit(MixedModel, form, data; optimizer=:LD_LBFGS, gradient=:forwarddiff)selects forward-mode AD as the gradient source; the defaultgradient=:analyticusesobjective_gradient!. The option is stored inOptSummary(serialization-aware) and validated infit!; requesting:forwarddiffwithout ForwardDiff.jl loaded throws an informative error. The ForwardDiff path caches the dual-promoted copies ofA/L/retermstogether with aGradientConfigand aDiffResultsbuffer in a reusable workspace (fd_gradient_workspace), so repeated gradient evaluations do not re-promote; value and gradient come from a single dual sweep. Both sources use the same per-observation scaling and take identical optimization paths (same evaluation counts, same optima).Analytic vs ForwardDiff gradient source
From
gradients/fd_vs_analytic.jl(one subprocess per configuration so peak RSS is per-configuration; measured on arefit!after a warm-up fit, so time/bytes/allocs exclude compilation):The analytic gradient dominates on both axes: 55× faster than ForwardDiff on kb07 and 6× on ml1m, and — contrary to the intuition that the one-shot
GradientWorkspaceis the memory-heavy option — ForwardDiff also allocates more (1.9× the bytes on ml1m) and has a ~310 MiB higher peak RSS there, because the cached dual arrays carry nθ partials per entry.:forwarddiffis thus primarily a correctness oracle and fallback, not a memory-saving alternative.Possible follow-ups
_invL!to bound peak memory (currently O(q_r·q_b) for the fill block); invasive to the hot path, memory-only benefit at scales beyondml1m.🤖 Generated with Claude Code