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