Skip to content

support for GLMMs with a dispersion parameter - #914

Draft
palday wants to merge 18 commits into
mainfrom
pa/dispersion-again
Draft

support for GLMMs with a dispersion parameter #914
palday wants to merge 18 commits into
mainfrom
pa/dispersion-again

Conversation

@palday

@palday palday commented Aug 12, 2026

Copy link
Copy Markdown
Member
  • I've bumped the version appropriately
  • add entry in NEWS.md
  • after opening this PR, add a reference and run docs/NEWS-update.jl to update the cross-references.

closes #206, closes #291, closes #787

@palday
palday force-pushed the pa/dispersion-again branch from 52893df to 348b884 Compare August 12, 2026 14:35
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.02%. Comparing base (176b328) to head (cc008f9).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #914      +/-   ##
==========================================
+ Coverage   94.88%   95.02%   +0.14%     
==========================================
  Files          38       38              
  Lines        3946     4004      +58     
==========================================
+ Hits         3744     3805      +61     
+ Misses        202      199       -3     
Flag Coverage Δ
current 94.72% <100.00%> (+0.15%) ⬆️
minimum 94.97% <100.00%> (+0.20%) ⬆️
nightly 94.71% <100.00%> (+0.15%) ⬆️

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.

The Laplace and AGQ objectives carried the penalty as sum(u²) when it
should be sum(u²)/ϕ.  θ is relative to the scale parameter in this
package -- Var(b) = ϕΛΛ', which is the σ factor σs and VarCorr were
already applying -- so u ~ N(0, ϕI) and the penalty belongs on the ϕ
scale along with the data term.

The check that pins this down: for a Normal family pwrss/n is the
conditional MLE of ϕ, so profiling the objective over ϕ at fixed β and θ
must return pwrss/n.  It does with uss/ϕ; with uss it misses by 16%.
Equivalently, the corrected expression specialises to
objective(::LinearMixedModel) for a Normal response and identity link,
which the previous one did not.  That identity is now a test.

For AGQ the same rescaling applies in three coupled places: the
integrand becomes (u² + Σdev)/ϕ, the quadrature nodes are spaced by
√ϕ/L.diag because the conditional sd of u carries the same ϕ as the
prior, and Cϕ picks up a compensating nᵤ·log ϕ for the resulting
Jacobian term.  _agq_deviance(m, 1) == _laplace_deviance(m) still holds
exactly, which is what fixes those constants.

This changes θ̂ and ϕ̂ for every dispersion-family fit; the references in
test/pirls.jl are re-recorded.  On the sleepstudy Gamma fit θ̂ goes from
[0.944, -0.061, 0.143] to [1.248, -0.006, 0.217] and σ̂ from 0.0885 to
0.0808.  The new values deliberately do not match lme4: glmer optimises
the ϕ ≡ 1 penalty while reporting VarCorr on the relative scale, and
that inconsistency is what issue #206 is about.

Families without a dispersion parameter are untouched, bit for bit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@palday
palday force-pushed the pa/dispersion-again branch 2 times, most recently from 2212770 to 3fc2457 Compare August 12, 2026 17:19
ϕ is now estimated one of two ways, selected by `fast`:

  fast=true   plugs in the Pearson moment estimator pwrss/n, matching
              lme4's sigma().  This is what every dispersion fit did
              previously, and it is unchanged.
  fast=false  carries log ϕ as a parameter of the outer optimisation
              alongside β and θ, so it converges to the conditional MLE.

The two are the same estimator only for Normal, where pwrss/n provably
is the conditional MLE -- the outer optimisation just rediscovers it,
and agreeing to 7e-5 on the sleepstudy fit is a useful check on the
whole arrangement.  For Gamma and InverseGaussian the MLE solves a
digamma equation instead of a moment condition, and the two answers
separate (0.18% apart on the sleepstudy Gamma fit).  Solving that
equation numerically through the outer optimiser avoids having to
special-case the score for each family.

log ϕ rather than ϕ keeps the parameter unbounded and comparably scaled
to θ (ϕ̂ ≈ 0.0065 there, three orders below the θ entries).  It also
keeps ϕ out of the zero-snapping pass in fit!, which is keyed on a lower
bound of zero and would otherwise be free to send ϕ to 1.

Note that ϕ still does not enter PIRLS.  The penalised objective is
(Σwᵢrᵢ² + ‖u‖²)/ϕ, so the 1/ϕ factors out of the minimisation over u:
the conditional modes are the same in both regimes and only the deviance
differs.  _dispersion is the single place either estimator is read from,
so the objective, loglikelihood and dispersion cannot drift apart.

Supporting changes: GeneralizedLinearMixedModel gains a ϕ field, empty
when ϕ is plugged in and one element when it is free, which is also the
flag distinguishing the regimes; setβθ! reads the trailing log ϕ when
present; unfit! clears it so refit! can move between regimes;
restoreoptsum! recognises the third parameter-vector length; and the
PRIMA scale vector is sized for the extra parameter.

Tests cover both regimes, that Normal reproduces pwrss/n while Gamma
does not, and that the regime survives refit! and a saveoptsum /
restoreoptsum! round trip.

ϕ̂ from the joint fit is only reproducible to ~0.15% -- between runs, and
between a fit and its own refit! -- because β and θ move and ϕ̂ follows.
That is too loose to lock ϕ̂ against a literal value, and close enough to
the 0.18% moment-vs-MLE gap to be worth distinguishing.  Conditionally on
the fitted β and θ, though, the outer optimiser matches the true
conditional MLE to 0.0009%, and the gap to the moment estimator is worth
3e-4 in deviance against an ftol that resolves 2e-9.  The tests therefore
compare ϕ̂ to a golden-section conditional MLE within a single fit, and
assert the moment estimator is distinguishable from it, rather than
pinning either to a constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@palday
palday force-pushed the pa/dispersion-again branch from 3fc2457 to bb8ebb2 Compare August 13, 2026 06:37
palday and others added 8 commits August 13, 2026 08:38
How ϕ was estimated is a property of the fitted model, so `show` is a
better place for it than an `@info` in `fit!`: it stays available for as
long as the model does, rather than scrolling past once, and it no longer
prints one copy per replicate when `parametricbootstrap` refits in a loop.

The line is gated on `dispersion_parameter` and reads the same flag the
objective does, so it cannot disagree with the ϕ actually used:

  Distribution: Gamma{Float64}
  Link: LogLink()
  Dispersion parameter ϕ: estimated jointly with β and θ

The `@suppress` wrappers added to the dispersion tests are removed again --
they were working around this noise, and suppressing stdout in tests also
hides genuine warnings.  `@test_logs` on the fit now asserts that nothing
is emitted at all, and a new testset covers the `show` output for both
estimators and for a family without a dispersion parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`nothing` versus a value says what the two ϕ regimes are far more directly
than an empty versus a one-element vector, and it matches how `sigma` is
spelled in `OptSummary`.

The vector was never a positive choice: `GeneralizedLinearMixedModel` is an
immutable struct, and ϕ has to be written on every objective evaluation by
`setβθ!` as well as switched between regimes by `fit!`, `unfit!`, `refit!`
and `_simulate!`.  A bare `Union{T,Nothing}` field cannot be reassigned, so
the vector was standing in for a mutable cell.  A `Ref` is that cell, and
says so.  The union is read once per objective evaluation, which is nothing
next to the PIRLS iteration around it.

  isempty(m.ϕ)      ->  isnothing(m.ϕ[])
  first(m.ϕ)        ->  m.ϕ[]
  push!(m.ϕ, x)     ->  m.ϕ[] = x
  empty!(m.ϕ)       ->  m.ϕ[] = nothing
  length(m.ϕ)       ->  !isnothing(m.ϕ[])   (PRIMA scale vector)

No behavioural change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_dispersion` gains a leading branch: a non-nothing `optsum.sigma` wins
over both estimators and returns σ² directly.  `fit!` then leaves ϕ out
of the parameter vector entirely, so a fixed-ϕ fit optimises over β and θ
alone.  On the sleepstudy Gamma fit with σ = 0.09 that is 5 parameters
rather than 6, ϕ̂ is 0.0081 exactly, and the deviance is 1735.099 against
1732.002 for the unconstrained fit -- worse, as a constrained fit must be,
which is the check that ϕ is really pinned and not quietly re-estimated.
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.

Deviance for families with dispersion parameter

1 participant