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 geometry: Option<FitGeometry>,
38    pub inference: Option<FitInference>,
39    /// Complete REML/LAML objective value used for smoothing selection.
40    pub reml_score: f64,
41    pub fitted_link: FittedLinkState,
42    /// Number of outer REML cost-only evaluations executed during the fit
43    /// (the count the outer optimizer's trust-region/line-search probes drive,
44    /// each paying an inner P-IRLS solve). Surfaced for regression guards on
45    /// outer work (#1575); not part of the statistical contract.
46    pub outer_cost_evals: usize,
47    /// Number of *actual* full-n inner P-IRLS solves performed (cache-missing
48    /// `prepare_eval_bundlewithkey` calls). This is the true cost driver the
49    /// #1575 slowdown is measured in ("~150 outer cost evals each running a
50    /// full n-sized P-IRLS"): unlike `outer_cost_evals`, it excludes single-slot
51    /// cache hits and prior short-circuits and includes every solve done by the
52    /// seed-grid prepass, screening, multistart, and finalize phases. Surfaced
53    /// for a regression guard that pins the warm-start / parsimony-waiver /
54    /// PSIS-optin economy (#1575); not part of the statistical contract.
55    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    /// Internal lifecycle knob for fits whose result will be immediately
68    /// superseded. Keeps ordinary inference work but skips the live-objective
69    /// rho posterior certificate/escalation until the returned model is known.
70    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    /// Optional explicit Firth override for external fitting families that
76    /// support Jeffreys/Firth bias reduction.
77    /// - `Some(true)`: force Firth on
78    /// - `Some(false)`: force Firth off
79    /// - `None`: use family default behavior
80    pub firth_bias_reduction: Option<bool>,
81    /// Relative shrinkage floor for penalized block eigenvalues.
82    /// See [`FitOptions::penalty_shrinkage_floor`] for details.
83    pub penalty_shrinkage_floor: Option<f64>,
84    /// Fixed prior on smoothing parameters for explicit joint HMC sampling
85    /// flows. Standard fitting stays on the REML/Laplace path.
86    pub rho_prior: gam_problem::RhoPrior,
87    /// Kronecker-factored penalty system for tensor-product smooth terms.
88    pub kronecker_penalty_system: Option<gam_terms::smooth::KroneckerPenaltySystem>,
89    /// Full Kronecker factored basis for P-IRLS factored reparameterization.
90    pub kronecker_factored: Option<gam_terms::basis::KroneckerFactoredBasis>,
91    /// Engage the cross-process ON-DISK persistent warm-start layer for this
92    /// fit. Default `false`: only the in-memory warm start runs, so throwaway /
93    /// replicate / CI-coverage loops pay no disk I/O (#1082). A caller that
94    /// wants cross-process resume threads `true` down from
95    /// `FitConfig::persist_warm_start_disk`; the standard `RemlState`
96    /// constructor then calls `enable_persistent_warm_start_disk()`.
97    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        // LogLog and Cauchit are ordinary state-less probability links: they
114        // narrow into `StandardLink` (gam-spec), carry a full 5-jet Fisher
115        // weight (`fisher_weight_jet5` → `component_fisher_weight_jet5` for
116        // LinkComponent::{LogLog,Cauchit}), and their inverse-link jets live
117        // in `mixture_link.rs` — the exact same external-design/P-IRLS
118        // machinery probit/cloglog ride. #2104 un-gated them at validation
119        // (`link_legal_for_family`); this is the fitting half of that wiring.
120        | (ResponseFamily::Binomial, LinkFunction::LogLog)
121        | (ResponseFamily::Binomial, LinkFunction::Cauchit)
122        | (ResponseFamily::Binomial, LinkFunction::Sas)
123        | (ResponseFamily::Binomial, LinkFunction::BetaLogistic) => true,
124        // Beta regression with a constant precision φ is a genuine-dispersion
125        // mean family on par with Gamma/Tweedie/Negative-Binomial: the inner
126        // P-IRLS carries its full fixed-φ Fisher information and the outer loop
127        // estimates φ by the Pearson moment estimator (`estimate_beta_phi_from_eta`,
128        // mirroring the Tweedie φ / Gamma shape / NegBin θ locks). A
129        // `noise_formula` upgrades it to a dispersion-location-scale model that
130        // smooths log φ; without one, the external GLM route fits the mean with
131        // a single estimated φ exactly as betareg does by default.
132        (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
247/// Shape/bounds validation for a single [`PenaltySpec`] against the total
248/// coefficient width `p`. Canonical home for the block/dense shape checks that
249/// were duplicated inline in `terms::construction`'s fused validate-and-
250/// destructure path; both call this so the diagnostics stay identical.
251pub(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}