1use super::*;
2
3pub(crate) fn sas_log_deltaridgeweight() -> f64 {
4 1e-4
7}
8
9#[inline]
10pub(crate) fn sas_log_delta_edge_barrierweight() -> f64 {
11 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 let grad = (2.0 * w / b) * t;
33 (cost, grad)
34}
35
36#[inline]
37pub(crate) fn sas_epsilon_bound() -> f64 {
38 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
101pub 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(¢ered) * 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
215pub 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
393pub 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}