From 57ec89bbc04a34360fba1b83dc6413f438050932 Mon Sep 17 00:00:00 2001 From: Maximilian Ernst Date: Sun, 15 Feb 2026 18:13:15 +0100 Subject: [PATCH 01/10] add helper functions for heterogenous lossfuns and scaling corrections --- src/additional_functions/helper.jl | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/additional_functions/helper.jl b/src/additional_functions/helper.jl index d6a1fc6c8..b6de2e34c 100644 --- a/src/additional_functions/helper.jl +++ b/src/additional_functions/helper.jl @@ -89,3 +89,37 @@ function nonunique(values::AbstractVector) end return res end + +# check that a model only has a single lossfun +function check_single_lossfun(model::AbstractSemSingle; throw_error) + if (length(model.loss.functions) > 1) & throw_error + @error "The model has $(length(sem.loss.functions)) loss functions. + Only a single loss function is supported." + end + return isone(length(model.loss.functions)) +end + +# check that all models use the same single loss function +function check_single_lossfun(models::AbstractSemSingle...; throw_error) + uniform = true + lossfun = models[1].loss.functions[1] + L = typeof(lossfun) + for (i, model) in enumerate(models) + uniform &= check_single_lossfun(model; throw_error = throw_error) + cur_lossfun = model.loss.functions[1] + if !isa(cur_lossfun, L) & throw_error + @error "Loss function for group #$i model is $(typeof(cur_lossfun)), expected $L. + Heterogeneous loss functions are not supported." + end + uniform &= isa(cur_lossfun, L) + end + return uniform +end + +check_single_lossfun(model::SemEnsemble; throw_error) = + check_single_lossfun(model.sems...; throw_error) + +# sclaing corrections for fit measures and multigroup models +dof_correction(::SemFIML) = 0 +dof_correction(::SemML) = -1 +dof_correction(::SemWLS) = -1 \ No newline at end of file From 1db3106cd2c501b95846a9d59b42450f25f13c71 Mon Sep 17 00:00:00 2001 From: Maximilian Ernst Date: Sun, 15 Feb 2026 18:14:09 +0100 Subject: [PATCH 02/10] adapt default multigroup weights and give info about defaults used --- src/types.jl | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/types.jl b/src/types.jl index 777165f37..5dc7c524c 100644 --- a/src/types.jl +++ b/src/types.jl @@ -192,10 +192,7 @@ end function SemEnsemble(models...; weights = nothing, groups = nothing, kwargs...) n = length(models) # default weights - if isnothing(weights) - nsamples_total = sum(nsamples, models) - weights = [nsamples(model) / nsamples_total for model in models] - end + weights = isnothing(weights) ? multigroup_weights(models, n) : weights # default group labels groups = isnothing(groups) ? Symbol.(:g, 1:n) : groups # check parameters equality @@ -226,7 +223,25 @@ function SemEnsemble(; specification, data, groups, column = :group, kwargs...) model = Sem(; specification = ram_matrices, data = data_group, kwargs...) push!(models, model) end - return SemEnsemble(models...; weights = nothing, groups = groups, kwargs...) + return SemEnsemble(models...; groups = groups, kwargs...) +end + +function multigroup_weights(models, n) + nsamples_total = sum(nsamples, models) + uniform_lossfun = check_single_lossfun(models...; throw_error = false) + if !uniform_lossfun + @info "Your ensemble model contains heterogeneous loss functions. + Default weights of (#samples per group/#total samples) will be used". + return [(nsamples(model)) / (nsamples_total) for model in models] + end + lossfun = models[1].loss.functions[1] + if !applicable(dof_correction, lossfun) + @info "We don't know how to choose group weights for the specified loss function. + Default weights of (#samples per group/#total samples) will be used". + return [(nsamples(model)) / (nsamples_total) for model in models] + end + dc = dof_correction(lossfun) + return [(nsamples(model)-dc) / (nsamples_total-n*dc) for model in models] end param_labels(ensemble::SemEnsemble) = ensemble.param_labels From 8b5de2ea5f2ae8584da2b89a04a887c28cda9fe2 Mon Sep 17 00:00:00 2001 From: Maximilian Ernst Date: Sun, 15 Feb 2026 18:20:02 +0100 Subject: [PATCH 03/10] refactor minus2ll --- src/frontend/fit/fitmeasures/minus2ll.jl | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/src/frontend/fit/fitmeasures/minus2ll.jl b/src/frontend/fit/fitmeasures/minus2ll.jl index 9b211fb44..888993817 100644 --- a/src/frontend/fit/fitmeasures/minus2ll.jl +++ b/src/frontend/fit/fitmeasures/minus2ll.jl @@ -3,36 +3,31 @@ Return the negative 2* log likelihood. """ -function minus2ll end +minus2ll(fit::SemFit) = minus2ll(fit, fit.model) ############################################################################################ # Single Models ############################################################################################ -minus2ll(fit::SemFit) = minus2ll(fit, fit.model) - function minus2ll(fit::SemFit, model::AbstractSemSingle) - minimum = objective(model, fit.solution) - return minus2ll(minimum, model) + check_single_lossfun(model; throw_error = true) + return minus2ll(model.loss.functions[1], fit, model) end -minus2ll(minimum::Number, model::AbstractSemSingle) = - sum(lossfun -> minus2ll(lossfun, minimum, model), model.loss.functions) - # SemML ------------------------------------------------------------------------------------ -function minus2ll(lossfun::SemML, minimum::Number, model::AbstractSemSingle) +function minus2ll(::SemML, fit::SemFit, model::AbstractSemSingle) obs = observed(model) - return nsamples(obs) * (minimum + log(2π) * nobserved_vars(obs)) + return nsamples(obs) * (fit.minimum + log(2π) * nobserved_vars(obs)) end # WLS -------------------------------------------------------------------------------------- -minus2ll(lossfun::SemWLS, minimum::Number, model::AbstractSemSingle) = missing +minus2ll(::SemWLS, ::SemFit, ::AbstractSemSingle) = missing # compute likelihood for missing data - H0 ------------------------------------------------- -# -2ll = (∑ log(2π)*(nᵢ + mᵢ)) + F*n -function minus2ll(lossfun::SemFIML, minimum::Number, model::AbstractSemSingle) +# -2ll = (∑ log(2π)*(nᵢ*mᵢ)) + F*n +function minus2ll(::SemFIML, fit::SemFit, model::AbstractSemSingle) obs = observed(model)::SemObservedMissing - F = minimum * nsamples(obs) + F = fit.minimum * nsamples(obs) F += log(2π) * sum(pat -> nsamples(pat) * nmeasured_vars(pat), obs.patterns) return F end From f13704ed8f7057f763279478f2ea0dd9088d1ade Mon Sep 17 00:00:00 2001 From: Maximilian Ernst Date: Sun, 15 Feb 2026 18:32:45 +0100 Subject: [PATCH 04/10] refactor minus2ll --- src/frontend/fit/fitmeasures/minus2ll.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/frontend/fit/fitmeasures/minus2ll.jl b/src/frontend/fit/fitmeasures/minus2ll.jl index 888993817..4547738b8 100644 --- a/src/frontend/fit/fitmeasures/minus2ll.jl +++ b/src/frontend/fit/fitmeasures/minus2ll.jl @@ -62,4 +62,7 @@ end # Collection ############################################################################################ -minus2ll(fit::SemFit, model::SemEnsemble) = sum(Base.Fix1(minus2ll, fit), model.sems) +function minus2ll(fit::SemFit, model::SemEnsemble) + check_single_lossfun(model; throw_error = true) + return sum(Base.Fix1(minus2ll, fit), model.sems) +end From 618e2df755cdf18fdee4131661c2ae76c5e52482 Mon Sep 17 00:00:00 2001 From: Maximilian Ernst Date: Sun, 15 Feb 2026 18:32:59 +0100 Subject: [PATCH 05/10] refactor chi2 --- src/frontend/fit/fitmeasures/chi2.jl | 44 ++++++++++------------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/src/frontend/fit/fitmeasures/chi2.jl b/src/frontend/fit/fitmeasures/chi2.jl index dc19467fc..bd76b6ce1 100644 --- a/src/frontend/fit/fitmeasures/chi2.jl +++ b/src/frontend/fit/fitmeasures/chi2.jl @@ -9,20 +9,21 @@ Return the χ² value. # Single Models ############################################################################################ -χ²(fit::SemFit, model::AbstractSemSingle) = - sum(loss -> χ²(loss, fit, model), model.loss.functions) +function χ²(fit::SemFit, model::AbstractSemSingle) + check_single_lossfun(model; throw_error = true) + return χ²(model.loss.functions[1], fit::SemFit, model::AbstractSemSingle) +end -# RAM + SemML -χ²(lossfun::SemML, fit::SemFit, model::AbstractSemSingle) = +χ²(::SemML, fit::SemFit, model::AbstractSemSingle) = (nsamples(fit) - 1) * (fit.minimum - logdet(obs_cov(observed(model))) - nobserved_vars(observed(model))) # bollen, p. 115, only correct for GLS weight matrix -χ²(lossfun::SemWLS, fit::SemFit, model::AbstractSemSingle) = +χ²(::SemWLS, fit::SemFit, model::AbstractSemSingle) = (nsamples(fit) - 1) * fit.minimum # FIML -function χ²(lossfun::SemFIML, fit::SemFit, model::AbstractSemSingle) +function χ²(::SemFIML, fit::SemFit, model::AbstractSemSingle) ll_H0 = minus2ll(fit) ll_H1 = minus2ll(observed(model)) return ll_H0 - ll_H1 @@ -32,38 +33,25 @@ end # Collections ############################################################################################ -function χ²(fit::SemFit, models::SemEnsemble) - isempty(models.sems) && return 0.0 - - lossfun = models.sems[1].loss.functions[1] - # check that all models use the same single loss function - L = typeof(lossfun) - for (i, sem) in enumerate(models.sems) - if length(sem.loss.functions) > 1 - @error "Model for group #$i has $(length(sem.loss.functions)) loss functions. Only the single one is supported" - end - cur_lossfun = sem.loss.functions[1] - if !isa(cur_lossfun, L) - @error "Loss function for group #$i model is $(typeof(cur_lossfun)), expected $L. Heterogeneous loss functions are not supported" - end - end - - return χ²(lossfun, fit, models) +function χ²(fit::SemFit, model::SemEnsemble) + check_single_lossfun(model; throw_error = true) + lossfun = model.sems[1].loss.functions[1] + return χ²(lossfun, fit, model) end -function χ²(lossfun::SemWLS, fit::SemFit, models::SemEnsemble) - return (nsamples(models) - 1) * fit.minimum +function χ²(::SemWLS, fit::SemFit, models::SemEnsemble) + return (nsamples(models) - models.n) * fit.minimum end -function χ²(lossfun::SemML, fit::SemFit, models::SemEnsemble) +function χ²(::SemML, fit::SemFit, models::SemEnsemble) G = sum(zip(models.weights, models.sems)) do (w, model) data = observed(model) w * (logdet(obs_cov(data)) + nobserved_vars(data)) end - return (nsamples(models) - 1) * (fit.minimum - G) + return (nsamples(models) - models.n) * (fit.minimum - G) end -function χ²(lossfun::SemFIML, fit::SemFit, models::SemEnsemble) +function χ²(::SemFIML, fit::SemFit, models::SemEnsemble) ll_H0 = minus2ll(fit) ll_H1 = sum(minus2ll ∘ observed, models.sems) return ll_H0 - ll_H1 From c63ca4c4e4a8babe75bcafc06897afb8c579310f Mon Sep 17 00:00:00 2001 From: Maximilian Ernst Date: Sun, 15 Feb 2026 18:33:16 +0100 Subject: [PATCH 06/10] refactor RMSEA --- src/frontend/fit/fitmeasures/RMSEA.jl | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/frontend/fit/fitmeasures/RMSEA.jl b/src/frontend/fit/fitmeasures/RMSEA.jl index f9dae84ed..553bdca85 100644 --- a/src/frontend/fit/fitmeasures/RMSEA.jl +++ b/src/frontend/fit/fitmeasures/RMSEA.jl @@ -7,13 +7,20 @@ function RMSEA end RMSEA(fit::SemFit) = RMSEA(fit, fit.model) -RMSEA(fit::SemFit, model::AbstractSemSingle) = RMSEA(dof(fit), χ²(fit), nsamples(fit)) +function RMSEA(fit::SemFit, model::AbstractSemSingle) + check_uniform_lossfun(model) + return RMSEA(dof(fit), χ²(fit), nsamples(fit)-dof_correction(model.loss.functions[1])) +end -RMSEA(fit::SemFit, model::SemEnsemble) = - sqrt(length(model.sems)) * RMSEA(dof(fit), χ²(fit), nsamples(fit)) +function RMSEA(fit::SemFit, model::SemEnsemble) + check_single_lossfun(model; throw_error = true) + n = nsamples(fit)-model.n*dof_correction(model.sems[1].loss.functions[1]) + return sqrt(length(model.sems)) * RMSEA(dof(fit), χ²(fit), n) +end -function RMSEA(dof, chi2, nsamples) - rmsea = (chi2 - dof) / (nsamples * dof) - rmsea > 0 ? nothing : rmsea = 0 +function RMSEA(dof, chi2, c) + rmsea = (chi2 - dof) / (c * dof) + rmsea = rmsea > 0 ? rmsea : 0 return sqrt(rmsea) end + From e8c867b7d7e1f300782b8cf13bdf88f2c3a82448 Mon Sep 17 00:00:00 2001 From: Maximilian Ernst Date: Mon, 16 Feb 2026 15:00:26 +0100 Subject: [PATCH 07/10] adapt mg models and fitmeasures --- src/additional_functions/helper.jl | 8 ++++---- src/frontend/fit/fitmeasures/RMSEA.jl | 14 +++++++++----- src/frontend/fit/fitmeasures/chi2.jl | 13 ++++++++----- src/frontend/fit/fitmeasures/minus2ll.jl | 14 +++++++------- src/types.jl | 6 +++--- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/additional_functions/helper.jl b/src/additional_functions/helper.jl index b6de2e34c..0bd5b53f3 100644 --- a/src/additional_functions/helper.jl +++ b/src/additional_functions/helper.jl @@ -119,7 +119,7 @@ end check_single_lossfun(model::SemEnsemble; throw_error) = check_single_lossfun(model.sems...; throw_error) -# sclaing corrections for fit measures and multigroup models -dof_correction(::SemFIML) = 0 -dof_correction(::SemML) = -1 -dof_correction(::SemWLS) = -1 \ No newline at end of file +# scaling corrections for multigroup models +mg_correction(::SemFIML) = 0 +mg_correction(::SemML) = 0 +mg_correction(::SemWLS) = -1 \ No newline at end of file diff --git a/src/frontend/fit/fitmeasures/RMSEA.jl b/src/frontend/fit/fitmeasures/RMSEA.jl index 553bdca85..9059aa1db 100644 --- a/src/frontend/fit/fitmeasures/RMSEA.jl +++ b/src/frontend/fit/fitmeasures/RMSEA.jl @@ -8,19 +8,23 @@ function RMSEA end RMSEA(fit::SemFit) = RMSEA(fit, fit.model) function RMSEA(fit::SemFit, model::AbstractSemSingle) - check_uniform_lossfun(model) - return RMSEA(dof(fit), χ²(fit), nsamples(fit)-dof_correction(model.loss.functions[1])) + check_single_lossfun(model; throw_error = true) + return RMSEA(dof(fit), χ²(fit), nsamples(fit)+rmsea_correction(model.loss.functions[1])) end function RMSEA(fit::SemFit, model::SemEnsemble) check_single_lossfun(model; throw_error = true) - n = nsamples(fit)-model.n*dof_correction(model.sems[1].loss.functions[1]) + n = nsamples(fit)+model.n*rmsea_correction(model.sems[1].loss.functions[1]) return sqrt(length(model.sems)) * RMSEA(dof(fit), χ²(fit), n) end -function RMSEA(dof, chi2, c) - rmsea = (chi2 - dof) / (c * dof) +function RMSEA(dof, chi2, N⁻) + rmsea = (chi2 - dof) / (N⁻ * dof) rmsea = rmsea > 0 ? rmsea : 0 return sqrt(rmsea) end +# scaling corrections +rmsea_correction(::SemFIML) = 0 +rmsea_correction(::SemML) = -1 +rmsea_correction(::SemWLS) = -1 \ No newline at end of file diff --git a/src/frontend/fit/fitmeasures/chi2.jl b/src/frontend/fit/fitmeasures/chi2.jl index bd76b6ce1..9ebb06bd9 100644 --- a/src/frontend/fit/fitmeasures/chi2.jl +++ b/src/frontend/fit/fitmeasures/chi2.jl @@ -16,7 +16,7 @@ end χ²(::SemML, fit::SemFit, model::AbstractSemSingle) = (nsamples(fit) - 1) * - (fit.minimum - logdet(obs_cov(observed(model))) - nobserved_vars(observed(model))) + (fit.minimum - logdet(obs_cov(observed(model))) - nobserved_vars(model)) # bollen, p. 115, only correct for GLS weight matrix χ²(::SemWLS, fit::SemFit, model::AbstractSemSingle) = @@ -44,11 +44,14 @@ function χ²(::SemWLS, fit::SemFit, models::SemEnsemble) end function χ²(::SemML, fit::SemFit, models::SemEnsemble) - G = sum(zip(models.weights, models.sems)) do (w, model) - data = observed(model) - w * (logdet(obs_cov(data)) + nobserved_vars(data)) + F = 0 + for model in models.sems + Fᵢ = objective(model, fit.solution) + Fᵢ -= logdet(obs_cov(observed(model))) + nobserved_vars(model) + Fᵢ *= nsamples(model) - 1 + F += Fᵢ end - return (nsamples(models) - models.n) * (fit.minimum - G) + return F end function χ²(::SemFIML, fit::SemFit, models::SemEnsemble) diff --git a/src/frontend/fit/fitmeasures/minus2ll.jl b/src/frontend/fit/fitmeasures/minus2ll.jl index 4547738b8..961822ef5 100644 --- a/src/frontend/fit/fitmeasures/minus2ll.jl +++ b/src/frontend/fit/fitmeasures/minus2ll.jl @@ -11,23 +11,23 @@ minus2ll(fit::SemFit) = minus2ll(fit, fit.model) function minus2ll(fit::SemFit, model::AbstractSemSingle) check_single_lossfun(model; throw_error = true) - return minus2ll(model.loss.functions[1], fit, model) + F = objective(model, fit.solution) + return minus2ll(model.loss.functions[1], F, model) end # SemML ------------------------------------------------------------------------------------ -function minus2ll(::SemML, fit::SemFit, model::AbstractSemSingle) - obs = observed(model) - return nsamples(obs) * (fit.minimum + log(2π) * nobserved_vars(obs)) +function minus2ll(::SemML, F, model::AbstractSemSingle) + return nsamples(model) * (F + log(2π) * nobserved_vars(model)) end # WLS -------------------------------------------------------------------------------------- -minus2ll(::SemWLS, ::SemFit, ::AbstractSemSingle) = missing +minus2ll(::SemWLS, F, ::AbstractSemSingle) = missing # compute likelihood for missing data - H0 ------------------------------------------------- # -2ll = (∑ log(2π)*(nᵢ*mᵢ)) + F*n -function minus2ll(::SemFIML, fit::SemFit, model::AbstractSemSingle) +function minus2ll(::SemFIML, F, model::AbstractSemSingle) obs = observed(model)::SemObservedMissing - F = fit.minimum * nsamples(obs) + F *= nsamples(obs) F += log(2π) * sum(pat -> nsamples(pat) * nmeasured_vars(pat), obs.patterns) return F end diff --git a/src/types.jl b/src/types.jl index 5dc7c524c..3f695bfa3 100644 --- a/src/types.jl +++ b/src/types.jl @@ -235,13 +235,13 @@ function multigroup_weights(models, n) return [(nsamples(model)) / (nsamples_total) for model in models] end lossfun = models[1].loss.functions[1] - if !applicable(dof_correction, lossfun) + if !applicable(mg_correction, lossfun) @info "We don't know how to choose group weights for the specified loss function. Default weights of (#samples per group/#total samples) will be used". return [(nsamples(model)) / (nsamples_total) for model in models] end - dc = dof_correction(lossfun) - return [(nsamples(model)-dc) / (nsamples_total-n*dc) for model in models] + c = mg_correction(lossfun) + return [(nsamples(model)+c) / (nsamples_total+n*c) for model in models] end param_labels(ensemble::SemEnsemble) = ensemble.param_labels From 685a7f76c4ba93da56ea93d9848fb8d42a611cea Mon Sep 17 00:00:00 2001 From: Maximilian Ernst <34346372+Maximilian-Stefan-Ernst@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:32:19 +0100 Subject: [PATCH 08/10] Apply suggestion from @github-actions[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/additional_functions/helper.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/additional_functions/helper.jl b/src/additional_functions/helper.jl index 0bd5b53f3..d8ace12d6 100644 --- a/src/additional_functions/helper.jl +++ b/src/additional_functions/helper.jl @@ -122,4 +122,4 @@ check_single_lossfun(model::SemEnsemble; throw_error) = # scaling corrections for multigroup models mg_correction(::SemFIML) = 0 mg_correction(::SemML) = 0 -mg_correction(::SemWLS) = -1 \ No newline at end of file +mg_correction(::SemWLS) = -1 From 441f037e538d60166a3a22e079735100e84658b9 Mon Sep 17 00:00:00 2001 From: Maximilian Ernst <34346372+Maximilian-Stefan-Ernst@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:32:32 +0100 Subject: [PATCH 09/10] Apply suggestion from @github-actions[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/additional_functions/helper.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/additional_functions/helper.jl b/src/additional_functions/helper.jl index d8ace12d6..60365def0 100644 --- a/src/additional_functions/helper.jl +++ b/src/additional_functions/helper.jl @@ -116,7 +116,7 @@ function check_single_lossfun(models::AbstractSemSingle...; throw_error) return uniform end -check_single_lossfun(model::SemEnsemble; throw_error) = +check_single_lossfun(model::SemEnsemble; throw_error) = check_single_lossfun(model.sems...; throw_error) # scaling corrections for multigroup models From 9c372cc4dfb4092bde398f34616d8653d17e683a Mon Sep 17 00:00:00 2001 From: Maximilian Ernst <34346372+Maximilian-Stefan-Ernst@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:32:49 +0100 Subject: [PATCH 10/10] Apply suggestion from @github-actions[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/frontend/fit/fitmeasures/RMSEA.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/fit/fitmeasures/RMSEA.jl b/src/frontend/fit/fitmeasures/RMSEA.jl index 9059aa1db..764b5e116 100644 --- a/src/frontend/fit/fitmeasures/RMSEA.jl +++ b/src/frontend/fit/fitmeasures/RMSEA.jl @@ -27,4 +27,4 @@ end # scaling corrections rmsea_correction(::SemFIML) = 0 rmsea_correction(::SemML) = -1 -rmsea_correction(::SemWLS) = -1 \ No newline at end of file +rmsea_correction(::SemWLS) = -1