Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
55d8df5
refactor fit!(::LinearMixedModel) to support a more flexible optimiza…
palday Jan 25, 2025
55f291c
Update src/optsummary.jl
palday Jan 25, 2025
eba1859
fix crossref
palday Jan 25, 2025
179dd7d
NEWS and version bump
palday Jan 25, 2025
06bdf1a
OptSummary show specialization
palday Jan 25, 2025
2c0580b
update PRIMA backend to use new infrastructure
palday Jan 25, 2025
847334e
Update src/optsummary.jl
palday Jan 25, 2025
cbad1c4
cleanup
palday Jan 25, 2025
921f0da
Merge branch 'pa/backend' of github.com:JuliaStats/MixedModels.jl int…
palday Jan 25, 2025
095ef33
:facepalm:
palday Jan 25, 2025
e250d9c
more cleanup
palday Jan 25, 2025
3e3cd09
test other PRIMA optimizers
palday Jan 25, 2025
9052d47
nlopt for GLMM
palday Jan 26, 2025
56af378
prima for GLMM
palday Jan 26, 2025
dc5ae34
use scaling
palday Jan 26, 2025
8d41563
updated show methods
palday Jan 26, 2025
b136447
cleanup
palday Jan 26, 2025
4613eb2
test fix
palday Jan 26, 2025
1c947f3
fix??
palday Jan 26, 2025
af5f896
:facepalm:
palday Jan 26, 2025
705542c
make thinning a noop
palday Jan 26, 2025
92e2f17
Update NEWS.md
palday Jan 26, 2025
49d5478
BlueStyle
palday Jan 26, 2025
d105dea
docs update
palday Jan 26, 2025
fe30063
more coverage
palday Jan 26, 2025
8549f8a
Update src/generalizedlinearmixedmodel.jl
palday Jan 26, 2025
e437477
move push! to init
palday Jan 26, 2025
f9fd6dc
Merge branch 'pa/backend' of github.com:JuliaStats/MixedModels.jl int…
palday Jan 26, 2025
4bbd1f5
rm redundant newline
palday Jan 26, 2025
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
7 changes: 7 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
MixedModels v4.30.0 Release Notes
==============================
- Refactor calls to backend optimizer to make it easier to add and use different optimization backends.
The structure of `OptSummary` has been accordingly expanded and `prfit!` has been updated to use this new structure. [#802]
- Make the `thin` argument to `fit!` a no-op. It complicated several bits of logic without having any real performance benefit in the majority of cases. This argument has been replaced with a `fitlog::Bool=false` that determines whether a log is kept.[#802]

MixedModels v4.29.1 Release Notes
==============================
- Populate `optsum` in `prfit!` call. [#801]
Expand Down Expand Up @@ -595,3 +601,4 @@ Package dependencies
[#795]: https://github.com/JuliaStats/MixedModels.jl/issues/795
[#799]: https://github.com/JuliaStats/MixedModels.jl/issues/799
[#801]: https://github.com/JuliaStats/MixedModels.jl/issues/801
[#802]: https://github.com/JuliaStats/MixedModels.jl/issues/802
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "MixedModels"
uuid = "ff71e718-51f3-5ec2-a782-8ffcbfa3c316"
author = ["Phillip Alday <me@phillipalday.com>", "Douglas Bates <dmbates@gmail.com>"]
version = "4.29.1"
version = "4.30.0"

[deps]
Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45"
Expand Down
6 changes: 3 additions & 3 deletions docs/src/optimization.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,11 @@ DisplayAs.Text(ans) # hide
```

More detailed information about the intermediate steps of the nonlinear optimizer can be obtained the `fitlog` field.
By default, `fitlog` contains entries for only the initial and final steps, but additional information about every nth step can be obtained with the `thin` keyword-argument to `fit`, `fit!` and `refit!`:
By default, `fitlog` is not populated, but passing the keyword argument `fitlog=true` to `fit!` and `refit!` will result in it being populated with the values obtained at each step of optimization:

```@example Main
refit!(fm2; thin=1)
fm2.optsum.fitlog[1:10]
refit!(fm2; fitlog=true)
first(fm2.optsum.fitlog, 5)
DisplayAs.Text(ans) # hide
```

Expand Down
121 changes: 100 additions & 21 deletions ext/MixedModelsPRIMAExt.jl
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
module MixedModelsPRIMAExt

using MixedModels: MixedModels, LinearMixedModel, objective!
using MixedModels: ProgressMeter, ProgressUnknown
using MixedModels
using MixedModels: Statistics
using MixedModels: ProgressMeter, ProgressUnknown, objective!, _objective!
using LinearAlgebra: PosDefException
using PRIMA: PRIMA

function __init__()
push!(MixedModels.OPTIMIZATION_BACKENDS, :prima)
return nothing
end

const PRIMABackend = Val{:prima}

function MixedModels.prfit!(m::LinearMixedModel;
progress::Bool=true,
REML::Bool=m.optsum.REML,
σ::Union{Real,Nothing}=m.optsum.sigma,
thin::Int=1)
optsum = m.optsum
copyto!(optsum.final, optsum.initial)
optsum.REML = REML
optsum.sigma = σ
optsum.finitial = objective!(m, optsum.initial)
kwargs...)

MixedModels.unfit!(m)
m.optsum.optimizer = :bobyqa
m.optsum.backend = :prima

return fit!(m; kwargs...)
end

prima_optimizer!(::Val{:bobyqa}, args...; kwargs...) = PRIMA.bobyqa!(args...; kwargs...)
prima_optimizer!(::Val{:cobyla}, args...; kwargs...) = PRIMA.cobyla!(args...; kwargs...)
prima_optimizer!(::Val{:lincoa}, args...; kwargs...) = PRIMA.lincoa!(args...; kwargs...)

function MixedModels.optimize!(m::LinearMixedModel, ::PRIMABackend;
progress::Bool=true, fitlog::Bool=false, kwargs...)
optsum = m.optsum
prog = ProgressUnknown(; desc="Minimizing", showspeed=true)
# start from zero for the initial call to obj before optimization
iter = 0
fitlog = empty!(optsum.fitlog)
fitlog && empty!(optsum.fitlog)

function obj(x)
iter += 1
val = if isone(iter) && x == optsum.initial
val = if x == optsum.initial
# fast path since we've already evaluated the initial value
optsum.finitial
else
try
Expand All @@ -37,19 +51,84 @@
end
end
progress && ProgressMeter.next!(prog; showvalues=[(:objective, val)])
if isone(iter) || iszero(rem(iter, thin))
push!(fitlog, (copy(x), val))
fitlog && push!(optsum.fitlog, (copy(x), val))
return val
end

maxfun = optsum.maxfeval > 0 ? optsum.maxfeval : 500 * length(optsum.initial)
info = prima_optimizer!(Val(optsum.optimizer), obj, optsum.final;
xl=optsum.lowerbd, maxfun,
optsum.rhoend, optsum.rhobeg)
ProgressMeter.finish!(prog)
optsum.feval = info.nf
optsum.fmin = info.fx
optsum.returnvalue = Symbol(info.status)
_check_prima_return(info)
return optsum.final, optsum.fmin
end

function MixedModels.optimize!(m::GeneralizedLinearMixedModel, ::PRIMABackend;
progress::Bool=true, fitlog::Bool=false,
fast::Bool=false, verbose::Bool=false, nAGQ=1,
kwargs...)
optsum = m.optsum
prog = ProgressUnknown(; desc="Minimizing", showspeed=true)
fitlog && empty!(opstum.fitlog)

function obj(x)
val = try
_objective!(m, x, Val(fast); verbose, nAGQ)
catch ex
# this allows us to recover from models where e.g. the link isn't
# as constraining as it should be
ex isa Union{PosDefException,DomainError} || rethrow()
x == optsum.initial && rethrow()

Check warning on line 85 in ext/MixedModelsPRIMAExt.jl

View check run for this annotation

Codecov / codecov/patch

ext/MixedModelsPRIMAExt.jl#L84-L85

Added lines #L84 - L85 were not covered by tests
m.optsum.finitial
end
fitlog && push!(optsum.fitlog, (copy(x), val))
verbose && println(round(val; digits=5), " ", x)
progress && ProgressMeter.next!(prog; showvalues=[(:objective, val)])
return val
end

optsum.finitial = _objective!(m, optsum.initial, Val(fast); verbose, nAGQ)
maxfun = optsum.maxfeval > 0 ? optsum.maxfeval : 500 * length(optsum.initial)
scale = if fast
nothing
else
# scale by the standard deviation of the columns of the fixef model matrix
# when including the fixef in the nonlinear opt
sc = [map(std, eachcol(modelmatrix(m))); fill(1, length(m.θ))]
for (i, x) in enumerate(sc)
# for nearly constant things, e.g. intercept, we don't want to scale to zero...
# also, since we're scaling the _parameters_ and not the data,
# we need to invert the scale
sc[i] = ifelse(iszero(x), one(x), inv(x))
end
sc
end
info = prima_optimizer!(Val(optsum.optimizer), obj, optsum.final;
xl=optsum.lowerbd, maxfun,
optsum.rhoend, optsum.rhobeg,
scale)
ProgressMeter.finish!(prog)
info = PRIMA.bobyqa!(obj, optsum.final; xl=m.optsum.lowerbd)

optsum.feval = info.nf
optsum.fmin = info.fx
optsum.returnvalue = Symbol(info.status)
optsum.optimizer = :PRIMA_BOBYQA
return m
_check_prima_return(info)

return optsum.final, optsum.fmin
end

function _check_prima_return(info::PRIMA.Info)
if !PRIMA.issuccess(info)
@warn "PRIMA optimization failure: $(info.status)\n$(PRIMA.reason(info))"
end

return nothing
end

MixedModels.opt_params(::PRIMABackend) = (:rhobeg, :rhoend, :maxfeval)

end # module
1 change: 1 addition & 0 deletions src/MixedModels.jl
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ include("grouping.jl")
include("mimeshow.jl")
include("serialization.jl")
include("profile/profile.jl")
include("nlopt.jl")
include("prima.jl")

# COV_EXCL_START
Expand Down
4 changes: 2 additions & 2 deletions src/bootstrap.jl
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ function parametricbootstrap(
end
β = convert(Vector{T}, β)
θ = convert(Vector{T}, θ)
# scratch -- note that this is the length of the unpivoted coef vector
# scratch -- note that this is the length of the unpivoted coef vector
βsc = coef(morig)
θsc = zeros(ftype, length(θ))
p = length(βsc)
Expand All @@ -254,7 +254,7 @@ function parametricbootstrap(
)
samp = replicate(n; progress) do
simulate!(rng, m; β, σ, θ)
refit!(m; progress=false)
refit!(m; progress=false, fitlog=false)
(
objective=ftype.(m.objective),
σ=ismissing(m.σ) ? missing : ftype(m.σ),
Expand Down
81 changes: 41 additions & 40 deletions src/generalizedlinearmixedmodel.jl
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,8 @@

If `verbose` is `true`, then both the intermediate results of both the nonlinear optimization and PIRLS are also displayed on standard output.

At every `thin`th iteration is recorded in `fitlog`, optimization progress is saved in `m.optsum.fitlog`.
The `thin` argument is ignored: it had no impact on the final model fit and the logic around
thinning the `fitlog` was needlessly complicated for a trivial performance gain.

By default, the starting values for model fitting are taken from a (non mixed,
i.e. marginal ) GLM fit. Experience with larger datasets (many thousands of
Expand Down Expand Up @@ -247,7 +248,10 @@
nAGQ::Integer=1,
progress::Bool=true,
thin::Int=typemax(Int),
fitlog::Bool=false,
init_from_lmm=Set(),
backend::Symbol=m.optsum.backend,
optimizer::Symbol=m.optsum.optimizer,
) where {T}
β = copy(m.β)
θ = copy(m.θ)
Expand Down Expand Up @@ -277,60 +281,33 @@
optsum.initial = vcat(β, lm.optsum.final)
optsum.final = copy(optsum.initial)
end
setpar! = fast ? setθ! : setβθ!
prog = ProgressUnknown(; desc="Minimizing", showspeed=true)
# start from zero for the initial call to obj before optimization
iter = 0
fitlog = optsum.fitlog
function obj(x, g)
isempty(g) || throw(ArgumentError("g should be empty for this objective"))
val = try
deviance(pirls!(setpar!(m, x), fast, verbose), nAGQ)
catch ex
# this allows us to recover from models where e.g. the link isn't
# as constraining as it should be
ex isa Union{PosDefException,DomainError} || rethrow()
iter == 1 && rethrow()
m.optsum.finitial
end
iszero(rem(iter, thin)) && push!(fitlog, (copy(x), val))
verbose && println(round(val; digits=5), " ", x)
progress && ProgressMeter.next!(prog; showvalues=[(:objective, val)])
iter += 1
return val
end
opt = Opt(optsum)
NLopt.min_objective!(opt, obj)
optsum.finitial = obj(optsum.initial, T[])
empty!(fitlog)
push!(fitlog, (copy(optsum.initial), optsum.finitial))
fmin, xmin, ret = NLopt.optimize(opt, copyto!(optsum.final, optsum.initial))
ProgressMeter.finish!(prog)

optsum.backend = backend
optsum.optimizer = optimizer

xmin, fmin = optimize!(m; progress, fitlog, fast, verbose, nAGQ)

## check if very small parameter values bounded below by zero can be set to zero
xmin_ = copy(xmin)
for i in eachindex(xmin_)
if iszero(optsum.lowerbd[i]) && zero(T) < xmin_[i] < optsum.xtol_zero_abs
xmin_[i] = zero(T)
end
end
loglength = length(fitlog)
if xmin ≠ xmin_
if (zeroobj = obj(xmin_, T[])) ≤ (fmin + optsum.ftol_zero_abs)
if (zeroobj = objective!(m, xmin_; nAGQ, fast, verbose)) ≤
(fmin + optsum.ftol_zero_abs)
fmin = zeroobj
copyto!(xmin, xmin_)
elseif length(fitlog) > loglength
# remove unused extra log entry
pop!(fitlog)
fitlog && push!(optsum.fitlog, (copy(xmin), fmin))
end
end

## ensure that the parameter values saved in m are xmin
pirls!(setpar!(m, xmin), fast, verbose)
optsum.nAGQ = nAGQ
optsum.feval = opt.numevals
objective!(m, xmin; fast, verbose, nAGQ)
optsum.final = xmin
optsum.fmin = fmin
optsum.returnvalue = ret
_check_nlopt_return(ret)
optsum.nAGQ = nAGQ
return m
end

Expand Down Expand Up @@ -540,6 +517,30 @@
return accum - (mapreduce(u -> sum(abs2, u), +, m.u) + logdet(m)) / 2
end

# Base.Fix1 doesn't forward kwargs
function objective!(m::GeneralizedLinearMixedModel; fast=false, kwargs...)
return x -> _objective!(m, x, Val(fast); kwargs...)

Check warning on line 522 in src/generalizedlinearmixedmodel.jl

View check run for this annotation

Codecov / codecov/patch

src/generalizedlinearmixedmodel.jl#L521-L522

Added lines #L521 - L522 were not covered by tests
end

function objective!(m::GeneralizedLinearMixedModel{T}, x; fast=false, kwargs...) where {T}
return _objective!(m, x, Val(fast); kwargs...)
end

# normally, it doesn't make sense to move a simple branch to dispatch
# HOWEVER, this winds up getting called in optimization a lot and
# moving this to a realization here allows us to avoid dynamic dispatch on setθ! / setθβ!
function _objective!(
m::GeneralizedLinearMixedModel{T}, x, ::Val{true}; nAGQ=1, verbose=false
) where {T}
return deviance(pirls!(setθ!(m, x), true, verbose), nAGQ)
end

function _objective!(
m::GeneralizedLinearMixedModel{T}, x, ::Val{false}; nAGQ=1, verbose=false
) where {T}
return deviance(pirls!(setβθ!(m, x), false, verbose), nAGQ)
end

function Base.propertynames(m::GeneralizedLinearMixedModel, private::Bool=false)
return (
:A,
Expand Down Expand Up @@ -725,7 +726,7 @@
io::IO, ::MIME"text/plain", m::GeneralizedLinearMixedModel{T,D}
) where {T,D}
if m.optsum.feval < 0
@warn("Model has not been fit")

Check warning on line 729 in src/generalizedlinearmixedmodel.jl

View workflow job for this annotation

GitHub Actions / Documentation

Model has not been fit
return nothing
end
nAGQ = m.LMM.optsum.nAGQ
Expand Down
Loading
Loading