Skip to main content

gam_solve/estimate/
evaluation.rs

1use super::*;
2
3pub(crate) fn sas_log_deltaridgeweight() -> f64 {
4    // Weak fixed stabilization for the SAS tail parameter to avoid
5    // boundary/flat-region pathologies in outer optimization.
6    1e-4
7}
8
9#[inline]
10pub(crate) fn sas_log_delta_edge_barrierweight() -> f64 {
11    // Keep SAS raw log-delta away from tanh-saturation edges where
12    // link sensitivities collapse and outer gradients become uninformative.
13    1e-2
14}
15
16#[inline]
17pub(crate) fn sas_log_delta_bound() -> f64 {
18    crate::mixture_link::SAS_LOG_DELTA_BOUND
19}
20
21#[inline]
22pub(crate) fn sas_log_delta_edge_barriercostgrad(raw_log_delta: f64) -> (f64, f64) {
23    let w = sas_log_delta_edge_barrierweight();
24    if w <= 0.0 || !raw_log_delta.is_finite() {
25        return (0.0, 0.0);
26    }
27    let b = sas_log_delta_bound().max(f64::EPSILON);
28    let t = (raw_log_delta / b).tanh();
29    let one_minus_t2 = (1.0 - t * t).max(1e-12);
30    let cost = -w * one_minus_t2.ln();
31    // d/draw[-w log(1-t^2)] = (2w/B) * t.
32    let grad = (2.0 * w / b) * t;
33    (cost, grad)
34}
35
36#[inline]
37pub(crate) fn sas_epsilon_bound() -> f64 {
38    // Fixed smooth bound on raw SAS epsilon during outer optimization.
39    8.0
40}
41
42#[inline]
43pub(crate) fn sas_effective_epsilon(raw_epsilon: f64) -> (f64, f64) {
44    let bound = sas_epsilon_bound().max(f64::EPSILON);
45    let t = (raw_epsilon / bound).tanh();
46    let epsilon = bound * t;
47    let d_epsilon_d_raw = 1.0 - t * t;
48    (epsilon, d_epsilon_d_raw)
49}
50
51#[inline]
52pub(crate) fn sas_effective_epsilon_second(raw_epsilon: f64) -> (f64, f64, f64) {
53    let bound = sas_epsilon_bound().max(f64::EPSILON);
54    let t = (raw_epsilon / bound).tanh();
55    let first = 1.0 - t * t;
56    let second = -2.0 * t * first / bound;
57    (bound * t, first, second)
58}
59
60#[inline]
61pub(crate) fn sas_log_delta_edge_barriercostgradhess(raw_log_delta: f64) -> (f64, f64, f64) {
62    let w = sas_log_delta_edge_barrierweight();
63    if w <= 0.0 || !raw_log_delta.is_finite() {
64        return (0.0, 0.0, 0.0);
65    }
66    let b = sas_log_delta_bound().max(f64::EPSILON);
67    let t = (raw_log_delta / b).tanh();
68    let one_minus_t2 = (1.0 - t * t).max(1e-12);
69    let cost = -w * one_minus_t2.ln();
70    let grad = (2.0 * w / b) * t;
71    let hess = (2.0 * w / (b * b)) * one_minus_t2;
72    (cost, grad, hess)
73}
74
75pub(crate) fn materialize_link_outer_hessian(
76    hessian: gam_problem::HessianValue,
77    theta_dim: usize,
78) -> Result<Array2<f64>, EstimationError> {
79    match hessian.materialize_dense() {
80        Ok(Some(h)) => {
81            if h.nrows() != theta_dim || h.ncols() != theta_dim {
82                crate::bail_invalid_estim!(
83                    "unified evaluator Hessian shape {}x{} != theta_dim {}",
84                    h.nrows(),
85                    h.ncols(),
86                    theta_dim
87                );
88            }
89            Ok(h)
90        }
91        Ok(None) => Err(EstimationError::InvalidInput(
92            "unified evaluator returned no analytic Hessian in ValueGradientHessian mode"
93                .to_string(),
94        )),
95        Err(err) => Err(EstimationError::InvalidInput(format!(
96            "failed to materialize analytic link Hessian: {err}"
97        ))),
98    }
99}
100
101/// Evaluate the analytic gradient of the external REML objective.
102pub fn evaluate_externalgradient<X>(
103    y: ArrayView1<'_, f64>,
104    w: ArrayView1<'_, f64>,
105    x: X,
106    offset: ArrayView1<'_, f64>,
107    s_list: &[BlockwisePenalty],
108    opts: &ExternalOptimOptions,
109    rho: &Array1<f64>,
110) -> Result<Array1<f64>, EstimationError>
111where
112    X: Into<DesignMatrix>,
113{
114    let specs: Vec<PenaltySpec> = s_list.iter().map(PenaltySpec::from_blockwise_ref).collect();
115    let x = x.into();
116    if let Some(message) = row_mismatch_message(y.len(), w.len(), x.nrows(), offset.len()) {
117        crate::bail_invalid_estim!("{}", message);
118    }
119
120    let p = x.ncols();
121    validate_penalty_specs(&specs, p, "evaluate_externalgradient")?;
122    let (canonical, active_nullspace_dims) = gam_terms::construction::canonicalize_penalty_specs(
123        &specs,
124        &opts.nullspace_dims,
125        p,
126        "evaluate_externalgradient",
127    )?;
128    if rho.len() != active_nullspace_dims.len() {
129        crate::bail_invalid_estim!(
130            "rho dimension mismatch: rho_dim={}, active_penalties={}",
131            rho.len(),
132            active_nullspace_dims.len()
133        );
134    }
135
136    let (cfg, _) = resolved_external_config(opts)?;
137
138    let y_o = y.to_owned();
139    let w_o = w.to_owned();
140    let offset_o = offset.to_owned();
141    let conditioning = ParametricColumnConditioning::infer_from_penalty_specs(&x, &specs);
142    let x_fit = conditioning.apply_to_design(&x);
143    let fit_linear_constraints =
144        conditioning.transform_linear_constraints_to_internal(opts.linear_constraints.clone());
145
146    let mut reml_state = RemlState::newwith_offset(
147        y_o.view(),
148        x_fit,
149        w_o.view(),
150        offset_o.view(),
151        canonical,
152        p,
153        &cfg,
154        Some(active_nullspace_dims),
155        None,
156        fit_linear_constraints,
157    )?;
158    reml_state.set_penalty_shrinkage_floor(opts.penalty_shrinkage_floor);
159    reml_state.set_rho_prior(opts.rho_prior.clone());
160    reml_state.set_link_states(
161        cfg.link_kind.mixture_state().cloned(),
162        cfg.link_kind.sas_state().copied(),
163    );
164
165    reml_state.compute_gradient(rho)
166}
167
168fn gaussian_identity_inner_residual_norm(
169    y: ArrayView1<'_, f64>,
170    w: ArrayView1<'_, f64>,
171    x: &DesignMatrix,
172    offset: ArrayView1<'_, f64>,
173    canonical_penalties: &[gam_terms::construction::CanonicalPenalty],
174    rho: &Array1<f64>,
175    beta: &Array1<f64>,
176) -> Result<f64, EstimationError> {
177    if beta.len() != x.ncols() {
178        crate::bail_invalid_estim!(
179            "beta dimension mismatch: beta_dim={}, x_cols={}",
180            beta.len(),
181            x.ncols()
182        );
183    }
184    if rho.len() != canonical_penalties.len() {
185        crate::bail_invalid_estim!(
186            "rho dimension mismatch: rho_dim={}, active_penalties={}",
187            rho.len(),
188            canonical_penalties.len()
189        );
190    }
191    let lambdas = gam_problem::checked_exp_log_strengths(rho.iter().copied())?;
192
193    let mut residual = x.apply(beta);
194    residual += &offset;
195    residual -= &y;
196    residual *= &w;
197    let mut gradient = x.apply_transpose(&residual);
198
199    for (k, cp) in canonical_penalties.iter().enumerate() {
200        let lambda = lambdas[k];
201        if lambda == 0.0 || cp.rank() == 0 {
202            continue;
203        }
204        let r = cp.col_range.clone();
205        let centered = &beta.slice(s![r.start..r.end]) - &cp.prior_mean;
206        let penalty_grad = cp.local.dot(&centered) * lambda;
207        gradient
208            .slice_mut(s![r.start..r.end])
209            .scaled_add(1.0, &penalty_grad);
210    }
211
212    Ok(gradient.iter().map(|v| v * v).sum::<f64>().sqrt())
213}
214
215/// Evaluate IFT and flat warm-start inner residuals at `rho + delta_rho`.
216///
217/// Computes the inner-KKT residual norm at the IFT-predicted coefficient
218/// `β_pred(ρ+Δρ)` obtained by linearizing the inner solution around the
219/// converged `β̂(ρ)`, alongside the residual norm for the "flat" warm start
220/// `β̂(ρ)` (the same coefficient without any IFT correction). The pair lets
221/// callers verify that the IFT predictor reduces the inner residual to the
222/// expected second-order remainder in `‖Δρ‖`.
223///
224/// # Math
225///
226/// Let `β̂(ρ)` minimize the penalized inner objective and `v_j = ∂β̂/∂ρ_j`
227/// be the IFT sensitivity vectors at `ρ`. The first-order predictor is
228///
229/// ```text
230///   β_pred(ρ + Δρ) = β̂(ρ) − Σ_j Δρ_j · v_j.
231/// ```
232///
233/// Writing `r(β, ρ) = ∇_β L(β, ρ)` for the inner-KKT residual, the test
234/// invariant exercised by callers is
235///
236/// ```text
237///   ‖ r( β_pred(ρ+Δρ),  ρ + Δρ ) ‖ = O( ‖Δρ‖² ).
238/// ```
239///
240/// The flat baseline `‖ r( β̂(ρ), ρ + Δρ ) ‖` is `O(‖Δρ‖)` for comparison.
241///
242/// # Arguments
243///
244/// * `y`, `w`, `x`, `offset` — full-data response, weights, design, offset.
245/// * `s_list` — blockwise penalty specifications matching `rho`.
246/// * `opts` — external optimization options; must be `GaussianIdentity`
247///   with no linear constraints.
248/// * `rho` — base log-smoothing parameter vector at which the IFT
249///   sensitivities are taken.
250/// * `delta_rho` — perturbation applied to `rho` for the residual probe.
251///
252/// # Returns
253///
254/// `(ift_residual_norm, flat_residual_norm)` — the L2 norm of the inner
255/// KKT residual at `β_pred(ρ+Δρ)` and at the flat warm start `β̂(ρ)`,
256/// both evaluated at `ρ + Δρ`.
257///
258/// # Used by
259///
260/// Tests that exercise the IFT predictor's residual-order property; not
261/// part of the production solver hot path.
262pub fn evaluate_external_ift_residual_at_perturbed_rho<X>(
263    y: ArrayView1<'_, f64>,
264    w: ArrayView1<'_, f64>,
265    x: X,
266    offset: ArrayView1<'_, f64>,
267    s_list: &[BlockwisePenalty],
268    opts: &ExternalOptimOptions,
269    rho: &Array1<f64>,
270    delta_rho: ArrayView1<'_, f64>,
271) -> Result<(f64, f64), EstimationError>
272where
273    X: Into<DesignMatrix>,
274{
275    if !opts.family.is_gaussian_identity() {
276        crate::bail_invalid_estim!(
277            "evaluate_external_ift_residual_at_perturbed_rho currently supports GaussianIdentity"
278                .to_string(),
279        );
280    }
281    if opts.linear_constraints.is_some() {
282        crate::bail_invalid_estim!(
283            "evaluate_external_ift_residual_at_perturbed_rho does not support constrained fits"
284                .to_string(),
285        );
286    }
287
288    let specs: Vec<PenaltySpec> = s_list.iter().map(PenaltySpec::from_blockwise_ref).collect();
289    let x = x.into();
290    if let Some(message) = row_mismatch_message(y.len(), w.len(), x.nrows(), offset.len()) {
291        crate::bail_invalid_estim!("{}", message);
292    }
293
294    let p = x.ncols();
295    validate_penalty_specs(&specs, p, "evaluate_external_ift_residual_at_perturbed_rho")?;
296    let (canonical, active_nullspace_dims) = gam_terms::construction::canonicalize_penalty_specs(
297        &specs,
298        &opts.nullspace_dims,
299        p,
300        "evaluate_external_ift_residual_at_perturbed_rho",
301    )?;
302    if rho.len() != active_nullspace_dims.len() {
303        crate::bail_invalid_estim!(
304            "rho dimension mismatch: rho_dim={}, active_penalties={}",
305            rho.len(),
306            active_nullspace_dims.len()
307        );
308    }
309    if delta_rho.len() != rho.len() {
310        crate::bail_invalid_estim!(
311            "delta_rho dimension mismatch: delta_dim={}, rho_dim={}",
312            delta_rho.len(),
313            rho.len()
314        );
315    }
316
317    let mut tight_opts = opts.clone();
318    tight_opts.tol = 1e-12;
319    let (cfg, _) = resolved_external_config(&tight_opts)?;
320
321    let y_o = y.to_owned();
322    let w_o = w.to_owned();
323    let offset_o = offset.to_owned();
324    let conditioning = ParametricColumnConditioning::infer_from_penalty_specs(&x, &specs);
325    let x_fit = conditioning.apply_to_design(&x);
326    let fit_linear_constraints =
327        conditioning.transform_linear_constraints_to_internal(tight_opts.linear_constraints);
328
329    let mut reml_state = RemlState::newwith_offset(
330        y_o.view(),
331        x_fit.clone(),
332        w_o.view(),
333        offset_o.view(),
334        canonical.clone(),
335        p,
336        &cfg,
337        Some(active_nullspace_dims),
338        None,
339        fit_linear_constraints,
340    )?;
341    reml_state.set_penalty_shrinkage_floor(tight_opts.penalty_shrinkage_floor);
342    reml_state.set_rho_prior(tight_opts.rho_prior.clone());
343    reml_state.set_link_states(
344        cfg.link_kind.mixture_state().cloned(),
345        cfg.link_kind.sas_state().copied(),
346    );
347
348    reml_state.compute_gradient(rho)?;
349    let beta_hat = reml_state
350        .warm_start_beta
351        .read()
352        .unwrap()
353        .as_ref()
354        .map(|beta| beta.0.clone())
355        .ok_or_else(|| {
356            EstimationError::InvalidInput(
357                "PIRLS solve did not populate the warm-start beta cache".to_string(),
358            )
359        })?;
360
361    let rho_perturbed = rho + &delta_rho.to_owned();
362    let beta_pred = reml_state
363        .predict_warm_start_beta_ift_with_outcome(&rho_perturbed)
364        .map(|(beta, _)| beta.as_ref().clone())
365        .ok_or_else(|| {
366            EstimationError::InvalidInput(
367                "IFT warm-start predictor rejected the perturbed rho".to_string(),
368            )
369        })?;
370
371    let ift_residual = gaussian_identity_inner_residual_norm(
372        y_o.view(),
373        w_o.view(),
374        &x_fit,
375        offset_o.view(),
376        &canonical,
377        &rho_perturbed,
378        &beta_pred,
379    )?;
380    let flat_residual = gaussian_identity_inner_residual_norm(
381        y_o.view(),
382        w_o.view(),
383        &x_fit,
384        offset_o.view(),
385        &canonical,
386        &rho_perturbed,
387        &beta_hat,
388    )?;
389
390    Ok((ift_residual, flat_residual))
391}
392
393/// Evaluate the external cost and report the stabilization ridge used.
394/// This is a diagnostic helper for tests that need to detect ridge jitter.
395pub fn evaluate_externalcost_andridge<X>(
396    y: ArrayView1<'_, f64>,
397    w: ArrayView1<'_, f64>,
398    x: X,
399    offset: ArrayView1<'_, f64>,
400    s_list: &[BlockwisePenalty],
401    opts: &ExternalOptimOptions,
402    rho: &Array1<f64>,
403) -> Result<(f64, f64), EstimationError>
404where
405    X: Into<DesignMatrix>,
406{
407    let specs: Vec<PenaltySpec> = s_list.iter().map(PenaltySpec::from_blockwise_ref).collect();
408    let x = x.into();
409    if let Some(message) = row_mismatch_message(y.len(), w.len(), x.nrows(), offset.len()) {
410        crate::bail_invalid_estim!("{}", message);
411    }
412
413    let p = x.ncols();
414    validate_penalty_specs(&specs, p, "evaluate_externalcost_andridge")?;
415    let (canonical, active_nullspace_dims) = gam_terms::construction::canonicalize_penalty_specs(
416        &specs,
417        &opts.nullspace_dims,
418        p,
419        "evaluate_externalcost_andridge",
420    )?;
421    if rho.len() != active_nullspace_dims.len() {
422        crate::bail_invalid_estim!(
423            "rho dimension mismatch: rho_dim={}, active_penalties={}",
424            rho.len(),
425            active_nullspace_dims.len()
426        );
427    }
428
429    let (cfg, _) = resolved_external_config(opts)?;
430
431    let y_o = y.to_owned();
432    let w_o = w.to_owned();
433    let offset_o = offset.to_owned();
434    let conditioning = ParametricColumnConditioning::infer_from_penalty_specs(&x, &specs);
435    let x_fit = conditioning.apply_to_design(&x);
436    let fit_linear_constraints =
437        conditioning.transform_linear_constraints_to_internal(opts.linear_constraints.clone());
438
439    let mut reml_state = RemlState::newwith_offset(
440        y_o.view(),
441        x_fit,
442        w_o.view(),
443        offset_o.view(),
444        canonical,
445        p,
446        &cfg,
447        Some(active_nullspace_dims),
448        None,
449        fit_linear_constraints,
450    )?;
451    reml_state.set_penalty_shrinkage_floor(opts.penalty_shrinkage_floor);
452    reml_state.set_rho_prior(opts.rho_prior.clone());
453    reml_state.set_link_states(
454        cfg.link_kind.mixture_state().cloned(),
455        cfg.link_kind.sas_state().copied(),
456    );
457
458    let cost = reml_state.compute_cost(rho)?;
459    let ridge = reml_state.last_ridge_used().unwrap_or(0.0);
460    Ok((cost, ridge))
461}