Skip to main content

gam_solve/estimate/
external_options.rs

1use super::*;
2
3pub struct ExternalOptimResult {
4    pub beta: Array1<f64>,
5    /// Canonical optimized log-strengths. Physical `lambdas` are derived from
6    /// this vector through the shared exact-domain conversion.
7    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    /// Residual scale on the response scale.
14    ///
15    /// Contract: Gaussian identity models store the residual standard
16    /// deviation sigma here. Non-Gaussian families keep the response-scale
17    /// summary used by their explicit likelihood-scale metadata.
18    pub standard_deviation: f64,
19    pub iterations: usize,
20    pub finalgrad_norm: f64,
21    /// True iff the outer optimizer reached a stationary point (gradient
22    /// norm below tolerance), as reported by the optimizer itself. False
23    /// when the run exhausted its iteration budget without reaching the
24    /// gradient tolerance. Downstream consumers should NOT assume that a
25    /// fit with `outer_converged == false` is unusable — it may still be
26    /// the best basin reached given the budget — but they must not treat
27    /// it as certified-converged either.
28    pub outer_converged: bool,
29    pub pirls_status: crate::pirls::PirlsStatus,
30    pub deviance: f64,
31    /// Stable quadratic penalty term βᵀSβ, including any solver ridge quadratic.
32    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    /// Complete REML/LAML objective value used for smoothing selection.
39    pub reml_score: f64,
40    pub fitted_link: FittedLinkState,
41    /// Number of outer REML cost-only evaluations executed during the fit
42    /// (the count the outer optimizer's trust-region/line-search probes drive,
43    /// each paying an inner P-IRLS solve). Surfaced for regression guards on
44    /// outer work (#1575); not part of the statistical contract.
45    pub outer_cost_evals: usize,
46    /// Number of *actual* full-n inner P-IRLS solves performed (cache-missing
47    /// `prepare_eval_bundlewithkey` calls). This is the true cost driver the
48    /// #1575 slowdown is measured in ("~150 outer cost evals each running a
49    /// full n-sized P-IRLS"): unlike `outer_cost_evals`, it excludes single-slot
50    /// cache hits and prior short-circuits and includes every solve done by the
51    /// seed-grid prepass, screening, multistart, and finalize phases. Surfaced
52    /// for a regression guard that pins the warm-start / parsimony-waiver /
53    /// PSIS-optin economy (#1575); not part of the statistical contract.
54    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    /// Internal lifecycle knob for fits whose result will be immediately
67    /// superseded. Keeps ordinary inference work but skips the live-objective
68    /// rho posterior certificate/escalation until the returned model is known.
69    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    /// Optional explicit Firth override for external fitting families that
75    /// support Jeffreys/Firth bias reduction.
76    /// - `Some(true)`: force Firth on
77    /// - `Some(false)`: force Firth off
78    /// - `None`: use family default behavior
79    pub firth_bias_reduction: Option<bool>,
80    /// Relative shrinkage floor for penalized block eigenvalues.
81    /// See [`FitOptions::penalty_shrinkage_floor`] for details.
82    pub penalty_shrinkage_floor: Option<f64>,
83    /// Fixed prior on smoothing parameters for explicit joint HMC sampling
84    /// flows. Standard fitting stays on the REML/Laplace path.
85    pub rho_prior: gam_problem::RhoPrior,
86    /// Kronecker-factored penalty system for tensor-product smooth terms.
87    pub kronecker_penalty_system: Option<gam_terms::smooth::KroneckerPenaltySystem>,
88    /// Full Kronecker factored basis for P-IRLS factored reparameterization.
89    pub kronecker_factored: Option<gam_terms::basis::KroneckerFactoredBasis>,
90    /// Engage the cross-process ON-DISK persistent warm-start layer for this
91    /// fit. Default `false`: only the in-memory warm start runs, so throwaway /
92    /// replicate / CI-coverage loops pay no disk I/O (#1082). A caller that
93    /// wants cross-process resume threads `true` down from
94    /// `FitConfig::persist_warm_start_disk`; the standard `RemlState`
95    /// constructor then calls `enable_persistent_warm_start_disk()`.
96    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        // LogLog and Cauchit are ordinary state-less probability links: they
113        // narrow into `StandardLink` (gam-spec), carry a full 5-jet Fisher
114        // weight (`fisher_weight_jet5` → `component_fisher_weight_jet5` for
115        // LinkComponent::{LogLog,Cauchit}), and their inverse-link jets live
116        // in `mixture_link.rs` — the exact same external-design/P-IRLS
117        // machinery probit/cloglog ride. #2104 un-gated them at validation
118        // (`link_legal_for_family`); this is the fitting half of that wiring.
119        | (ResponseFamily::Binomial, LinkFunction::LogLog)
120        | (ResponseFamily::Binomial, LinkFunction::Cauchit)
121        | (ResponseFamily::Binomial, LinkFunction::Sas)
122        | (ResponseFamily::Binomial, LinkFunction::BetaLogistic) => true,
123        // Beta regression with a constant precision φ is a genuine-dispersion
124        // mean family on par with Gamma/Tweedie/Negative-Binomial: the inner
125        // P-IRLS carries its full fixed-φ Fisher information and the outer loop
126        // estimates φ by the Pearson moment estimator (`estimate_beta_phi_from_eta`,
127        // mirroring the Tweedie φ / Gamma shape / NegBin θ locks). A
128        // `noise_formula` upgrades it to a dispersion-location-scale model that
129        // smooths log φ; without one, the external GLM route fits the mean with
130        // a single estimated φ exactly as betareg does by default.
131        (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
246/// Shape/bounds validation for a single [`PenaltySpec`] against the total
247/// coefficient width `p`. Canonical home for the block/dense shape checks that
248/// were duplicated inline in `terms::construction`'s fused validate-and-
249/// destructure path; both call this so the diagnostics stay identical.
250pub(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}