1use super::*;
2
3pub struct ExternalOptimResult {
4 pub beta: Array1<f64>,
5 pub log_lambdas: Array1<f64>,
8 pub lambdas: Array1<f64>,
9 pub likelihood_family: LikelihoodSpec,
10 pub likelihood_scale: LikelihoodScaleMetadata,
11 pub log_likelihood_normalization: LogLikelihoodNormalization,
12 pub log_likelihood: f64,
13 pub standard_deviation: f64,
19 pub iterations: usize,
20 pub finalgrad_norm: f64,
21 pub outer_converged: bool,
29 pub pirls_status: crate::pirls::PirlsStatus,
30 pub deviance: f64,
31 pub stable_penalty_term: f64,
33 pub used_device: bool,
34 pub max_abs_eta: f64,
35 pub constraint_kkt: Option<crate::pirls::ConstraintKktDiagnostics>,
36 pub artifacts: FitArtifacts,
37 pub geometry: Option<FitGeometry>,
38 pub inference: Option<FitInference>,
39 pub reml_score: f64,
41 pub fitted_link: FittedLinkState,
42 pub outer_cost_evals: usize,
47 pub inner_pirls_solves: usize,
56}
57
58#[derive(Clone)]
59pub struct ExternalOptimOptions {
60 pub family: gam_problem::LikelihoodSpec,
61 pub latent_cloglog: Option<LatentCLogLogState>,
62 pub mixture_link: Option<MixtureLinkSpec>,
63 pub optimize_mixture: bool,
64 pub sas_link: Option<SasLinkSpec>,
65 pub optimize_sas: bool,
66 pub compute_inference: bool,
67 pub skip_rho_posterior_inference: bool,
71 pub max_iter: usize,
72 pub tol: f64,
73 pub nullspace_dims: Vec<usize>,
74 pub linear_constraints: Option<crate::pirls::LinearInequalityConstraints>,
75 pub firth_bias_reduction: Option<bool>,
81 pub penalty_shrinkage_floor: Option<f64>,
84 pub rho_prior: gam_problem::RhoPrior,
87 pub kronecker_penalty_system: Option<gam_terms::smooth::KroneckerPenaltySystem>,
89 pub kronecker_factored: Option<gam_terms::basis::KroneckerFactoredBasis>,
91 pub persist_warm_start_disk: bool,
98}
99
100pub(crate) fn resolve_external_family(
101 family: &gam_problem::LikelihoodSpec,
102 firth_override: Option<bool>,
103) -> Result<(GlmLikelihoodSpec, bool), EstimationError> {
104 let external_glm_supported = match (&family.response, family.link_function()) {
105 (ResponseFamily::Gaussian, LinkFunction::Identity)
106 | (ResponseFamily::Poisson, LinkFunction::Log)
107 | (ResponseFamily::Gamma, LinkFunction::Log)
108 | (ResponseFamily::Tweedie { .. }, LinkFunction::Log)
109 | (ResponseFamily::NegativeBinomial { .. }, LinkFunction::Log)
110 | (ResponseFamily::Binomial, LinkFunction::Logit)
111 | (ResponseFamily::Binomial, LinkFunction::Probit)
112 | (ResponseFamily::Binomial, LinkFunction::CLogLog)
113 | (ResponseFamily::Binomial, LinkFunction::LogLog)
121 | (ResponseFamily::Binomial, LinkFunction::Cauchit)
122 | (ResponseFamily::Binomial, LinkFunction::Sas)
123 | (ResponseFamily::Binomial, LinkFunction::BetaLogistic) => true,
124 (ResponseFamily::Beta { .. }, LinkFunction::Logit) => true,
133 _ => false,
134 };
135 if !external_glm_supported {
136 crate::bail_invalid_estim!(
137 "optimize_external_design requires a supported standard GLM family/link; got {}. \
138 The external-design route supports Gaussian(identity), Binomial(logit/probit/cloglog/loglog/cauchit/SAS/Beta-Logistic), \
139 Beta(logit), and Poisson/Gamma/Tweedie/Negative-Binomial(log). For Beta precision modeling \
140 add a noise_formula to upgrade to the dispersion-location-scale route",
141 family.pretty_name(),
142 );
143 }
144
145 let supports_firth = family.supports_firth();
146 if firth_override == Some(true) && !supports_firth {
147 crate::bail_invalid_estim!(
148 "firth_bias_reduction requires a Binomial inverse link with a Fisher-weight jet; {} does not support it",
149 family.pretty_name(),
150 );
151 }
152
153 if let ResponseFamily::Tweedie { p } = &family.response {
154 if !gam_problem::is_valid_tweedie_power(*p) {
155 crate::bail_invalid_estim!("optimize_external_design requires a GLM family; Tweedie variance power must be finite and strictly between 1 and 2; use PoissonLog or GammaLog for boundary cases"
156 .to_string(),);
157 }
158 }
159 Ok((
160 GlmLikelihoodSpec::canonical(family.clone()),
161 firth_override.unwrap_or(false) && supports_firth,
162 ))
163}
164
165#[inline]
166pub(crate) fn effective_sas_link_for_family(
167 family: &gam_problem::LikelihoodSpec,
168 sas_link: Option<SasLinkSpec>,
169) -> Option<SasLinkSpec> {
170 if (family.is_binomial_sas() || family.is_binomial_beta_logistic()) && sas_link.is_none() {
171 Some(SasLinkSpec {
172 initial_epsilon: 0.0,
173 initial_log_delta: 0.0,
174 })
175 } else {
176 sas_link
177 }
178}
179
180#[inline]
181pub(crate) fn resolved_external_inverse_link(
182 link: LinkFunction,
183 latent_cloglog: Option<LatentCLogLogState>,
184 mixture_link: Option<&MixtureLinkSpec>,
185 sas_link: Option<SasLinkSpec>,
186) -> Result<InverseLink, EstimationError> {
187 if let Some(state) = latent_cloglog {
188 return Ok(InverseLink::LatentCLogLog(state));
189 }
190 if let Some(spec) = mixture_link {
191 return Ok(InverseLink::Mixture(state_fromspec(spec).map_err(|e| {
192 EstimationError::InvalidInput(format!("invalid blended inverse link: {e}"))
193 })?));
194 }
195 if let Some(spec) = sas_link {
196 return Ok(match link {
197 LinkFunction::BetaLogistic => {
198 InverseLink::BetaLogistic(state_from_beta_logisticspec(spec).map_err(|e| {
199 EstimationError::InvalidInput(format!("invalid Beta-Logistic link: {e}"))
200 })?)
201 }
202 _ => InverseLink::Sas(
203 state_from_sasspec(spec)
204 .map_err(|e| EstimationError::InvalidInput(format!("invalid SAS link: {e}")))?,
205 ),
206 });
207 }
208 Ok(InverseLink::Standard(StandardLink::try_from(link).map_err(|e| {
209 EstimationError::InvalidInput(format!(
210 "inverse link resolution: {e}; supply `sas_link` or `latent_cloglog` configuration for state-bearing links"
211 ))
212 })?))
213}
214
215#[inline]
216pub(crate) fn resolved_external_config(
217 opts: &ExternalOptimOptions,
218) -> Result<(RemlConfig, Option<SasLinkSpec>), EstimationError> {
219 if opts.latent_cloglog.is_some() && (opts.mixture_link.is_some() || opts.sas_link.is_some()) {
220 crate::bail_invalid_estim!(
221 "latent_cloglog cannot be combined with mixture_link or sas_link"
222 );
223 }
224 if opts.mixture_link.is_some() && opts.sas_link.is_some() {
225 crate::bail_invalid_estim!("mixture_link and sas_link are mutually exclusive");
226 }
227 if opts.family.is_latent_cloglog() && opts.latent_cloglog.is_none() {
228 crate::bail_invalid_estim!("BinomialLatentCLogLog requires latent_cloglog state");
229 }
230 if opts.latent_cloglog.is_some() && !opts.family.is_latent_cloglog() {
231 crate::bail_invalid_estim!("latent_cloglog is only supported with BinomialLatentCLogLog");
232 }
233 let effective_sas_link = effective_sas_link_for_family(&opts.family, opts.sas_link);
234 let (likelihood, firth_active) =
235 resolve_external_family(&opts.family, opts.firth_bias_reduction)?;
236 let link = likelihood.link_function();
237 let mut cfg = RemlConfig::external(likelihood, opts.tol, firth_active);
238 cfg.link_kind = resolved_external_inverse_link(
239 link,
240 opts.latent_cloglog,
241 opts.mixture_link.as_ref(),
242 effective_sas_link,
243 )?;
244 Ok((cfg, effective_sas_link))
245}
246
247pub(crate) fn validate_penalty_spec_shape(
252 idx: usize,
253 spec: &PenaltySpec,
254 p: usize,
255 context: &str,
256) -> Result<(), EstimationError> {
257 match spec {
258 PenaltySpec::Block {
259 local, col_range, ..
260 } => {
261 let bd = col_range.len();
262 if local.nrows() != bd || local.ncols() != bd {
263 crate::bail_invalid_estim!(
264 "{context}: block penalty {idx} local matrix must be {bd}x{bd}, got {}x{}",
265 local.nrows(),
266 local.ncols()
267 );
268 }
269 if col_range.end > p {
270 crate::bail_invalid_estim!(
271 "{context}: block penalty {idx} col_range {}..{} exceeds p={p}",
272 col_range.start,
273 col_range.end
274 );
275 }
276 }
277 PenaltySpec::Dense(m) => {
278 if m.nrows() != p || m.ncols() != p {
279 crate::bail_invalid_estim!(
280 "{context}: dense penalty {idx} must be {p}x{p}, got {}x{}",
281 m.nrows(),
282 m.ncols()
283 );
284 }
285 }
286 PenaltySpec::DenseWithMean { matrix, .. } => {
287 if matrix.nrows() != p || matrix.ncols() != p {
288 crate::bail_invalid_estim!(
289 "{context}: dense penalty {idx} must be {p}x{p}, got {}x{}",
290 matrix.nrows(),
291 matrix.ncols()
292 );
293 }
294 }
295 }
296 Ok(())
297}