1use faer::Side;
16use gam_linalg::faer_ndarray::{FaerCholesky, FaerEigh, fast_xt_diag_x, fast_xt_diag_y};
17use gam_linalg::matrix::{DesignMatrix, LinearOperator};
18use gam_linalg::utils::KahanSum;
19use gam_problem::{DeclaredHessianForm, Derivative, StationarityStandard};
23use gam_solve::estimate::EstimationError;
24use gam_solve::rho_optimizer::{
25 HessianValue, OuterCapability, OuterCriterionCertificate, OuterEval, OuterObjective,
26 OuterProblem, SeedOutcome,
27};
28use ndarray::{Array1, Array2, Array3, s};
29use serde::{Deserialize, Serialize};
30
31use crate::inference::model::FittedModel;
32
33const FIT_CONTEXT: &str = "shared-tangent Gaussian REML";
34pub const RESPONSE_GEOMETRY_MODEL_VERSION: u32 = 1;
35
36#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct SharedTangentPenalty {
42 pub column_start: usize,
43 pub matrix: Array2<f64>,
44}
45
46impl SharedTangentPenalty {
47 pub fn new(column_start: usize, matrix: Array2<f64>) -> Self {
48 Self {
49 column_start,
50 matrix,
51 }
52 }
53
54 pub fn column_end(&self) -> usize {
55 self.column_start + self.matrix.ncols()
56 }
57}
58
59#[derive(Clone, Debug)]
64pub struct SharedTangentRemlRequest {
65 pub design: DesignMatrix,
66 pub response: Array2<f64>,
67 pub weights: Array1<f64>,
68 pub fisher_metric: Option<Array3<f64>>,
69 pub penalties: Vec<SharedTangentPenalty>,
70 pub initial_log_lambdas: Option<Array1<f64>>,
72}
73
74impl SharedTangentRemlRequest {
75 pub fn new(
76 design: DesignMatrix,
77 response: Array2<f64>,
78 weights: Array1<f64>,
79 fisher_metric: Option<Array3<f64>>,
80 penalties: Vec<SharedTangentPenalty>,
81 ) -> Self {
82 Self {
83 design,
84 response,
85 weights,
86 fisher_metric,
87 penalties,
88 initial_log_lambdas: None,
89 }
90 }
91
92 pub fn from_dense(
94 design: Array2<f64>,
95 response: Array2<f64>,
96 weights: Array1<f64>,
97 fisher_metric: Option<Array3<f64>>,
98 penalties: Vec<SharedTangentPenalty>,
99 ) -> Self {
100 Self::new(
101 DesignMatrix::from(design),
102 response,
103 weights,
104 fisher_metric,
105 penalties,
106 )
107 }
108
109 pub fn with_initial_log_lambdas(mut self, initial: Array1<f64>) -> Self {
110 self.initial_log_lambdas = Some(initial);
111 self
112 }
113}
114
115#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct SharedTangentRemlFit {
121 pub coefficients: Array2<f64>,
123 pub fitted: Array2<f64>,
125 pub sigma2: f64,
127 pub lambdas: Array1<f64>,
130 pub edf_by_penalty: Array1<f64>,
132 pub edf_total: f64,
133 pub reml_score: f64,
135 pub n_observations: usize,
136 pub n_outputs: usize,
137 pub outer_iterations: usize,
138 pub outer_certificate: OuterCriterionCertificate,
139}
140
141impl SharedTangentRemlFit {
142 pub fn predict(&self, design: &DesignMatrix) -> Result<Array2<f64>, EstimationError> {
144 predict_from_coefficients(design, &self.coefficients)
145 }
146
147 pub fn predict_dense(&self, design: Array2<f64>) -> Result<Array2<f64>, EstimationError> {
149 self.predict(&DesignMatrix::from(design))
150 }
151}
152
153#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
155pub struct ResponseGeometryCurvature {
156 pub kappa_hat: f64,
157 pub confidence_level: f64,
158 pub confidence_lower: f64,
159 pub confidence_upper: f64,
160 pub lower_at_bound: bool,
161 pub upper_at_bound: bool,
162 pub verdict: String,
163 pub flatness_likelihood_ratio: f64,
164 pub flatness_p_value: f64,
165 pub railed_at_resolution_limit: bool,
166 pub scale_free_kappa_radius_squared: f64,
167 pub characteristic_radius: f64,
168}
169
170impl ResponseGeometryCurvature {
171 fn validate(&self) -> Result<(), ResponseGeometryModelError> {
172 let finite = [
173 self.kappa_hat,
174 self.confidence_level,
175 self.confidence_lower,
176 self.confidence_upper,
177 self.flatness_likelihood_ratio,
178 self.flatness_p_value,
179 self.scale_free_kappa_radius_squared,
180 self.characteristic_radius,
181 ];
182 if finite.iter().any(|value| !value.is_finite()) {
183 return Err(ResponseGeometryModelError::InvalidMetadata(
184 "curvature record contains non-finite values".to_string(),
185 ));
186 }
187 if !(self.confidence_level > 0.0 && self.confidence_level < 1.0) {
188 return Err(ResponseGeometryModelError::InvalidMetadata(format!(
189 "curvature confidence level must lie in (0, 1), got {}",
190 self.confidence_level
191 )));
192 }
193 if self.confidence_lower > self.confidence_upper {
194 return Err(ResponseGeometryModelError::InvalidMetadata(
195 "curvature confidence interval is reversed".to_string(),
196 ));
197 }
198 if !(self.flatness_likelihood_ratio >= 0.0
199 && (0.0..=1.0).contains(&self.flatness_p_value)
200 && self.characteristic_radius > 0.0)
201 {
202 return Err(ResponseGeometryModelError::InvalidMetadata(
203 "curvature likelihood-ratio, p-value, or characteristic radius is invalid"
204 .to_string(),
205 ));
206 }
207 if self.verdict.trim().is_empty() {
208 return Err(ResponseGeometryModelError::InvalidMetadata(
209 "curvature verdict must not be empty".to_string(),
210 ));
211 }
212 Ok(())
213 }
214}
215
216#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
218pub struct ResponseGeometryMetadata {
219 pub response_geometry: String,
220 pub response_columns: Vec<String>,
221 pub base_point: Array1<f64>,
222 pub coordinates: String,
223 pub reference: isize,
224 pub training_table_kind: String,
225 pub curvature: Option<ResponseGeometryCurvature>,
226}
227
228#[derive(Clone, Debug, Serialize, Deserialize)]
230pub struct ResponseGeometrySummary {
231 pub model_class: String,
232 pub metadata: ResponseGeometryMetadata,
233 pub tangent_dimension: usize,
234 pub shared_smoothing: bool,
235 pub reml_score: f64,
236 pub lambdas: Array1<f64>,
237 pub edf_by_penalty: Array1<f64>,
238 pub edf_total: f64,
239 pub sigma2: f64,
240 pub template_formula: String,
241 pub template_family: String,
242}
243
244#[derive(Clone, Serialize, Deserialize)]
251pub struct ResponseGeometryModel {
252 pub version: u32,
253 pub template_model: FittedModel,
254 pub metadata: ResponseGeometryMetadata,
255 pub shared_tangent_fit: SharedTangentRemlFit,
256}
257
258#[derive(Debug, thiserror::Error)]
259pub enum ResponseGeometryModelError {
260 #[error("invalid response-geometry metadata: {0}")]
261 InvalidMetadata(String),
262 #[error("invalid response-geometry template model: {0}")]
263 InvalidTemplate(String),
264 #[error("response-geometry archive serialization failed: {0}")]
265 Serialization(String),
266}
267
268impl ResponseGeometryModel {
269 pub fn new(
270 template_model: FittedModel,
271 metadata: ResponseGeometryMetadata,
272 shared_tangent_fit: SharedTangentRemlFit,
273 ) -> Result<Self, ResponseGeometryModelError> {
274 let model = Self {
275 version: RESPONSE_GEOMETRY_MODEL_VERSION,
276 template_model,
277 metadata,
278 shared_tangent_fit,
279 };
280 model.validate()?;
281 Ok(model)
282 }
283
284 pub fn validate(&self) -> Result<(), ResponseGeometryModelError> {
285 if self.version != RESPONSE_GEOMETRY_MODEL_VERSION {
286 return Err(ResponseGeometryModelError::InvalidMetadata(format!(
287 "archive version {} does not match required version {}",
288 self.version, RESPONSE_GEOMETRY_MODEL_VERSION
289 )));
290 }
291 if self.metadata.response_geometry.trim().is_empty()
292 || self.metadata.coordinates.trim().is_empty()
293 {
294 return Err(ResponseGeometryModelError::InvalidMetadata(
295 "geometry and coordinate-chart labels must not be empty".to_string(),
296 ));
297 }
298 if self.metadata.response_columns.is_empty()
299 || self
300 .metadata
301 .response_columns
302 .iter()
303 .any(|column| column.trim().is_empty())
304 {
305 return Err(ResponseGeometryModelError::InvalidMetadata(
306 "response columns must be non-empty names".to_string(),
307 ));
308 }
309 let unique: std::collections::HashSet<&str> = self
310 .metadata
311 .response_columns
312 .iter()
313 .map(String::as_str)
314 .collect();
315 if unique.len() != self.metadata.response_columns.len() {
316 return Err(ResponseGeometryModelError::InvalidMetadata(
317 "response columns must be unique".to_string(),
318 ));
319 }
320 if self.metadata.base_point.is_empty()
321 || self
322 .metadata
323 .base_point
324 .iter()
325 .any(|value| !value.is_finite())
326 {
327 return Err(ResponseGeometryModelError::InvalidMetadata(
328 "base point must be non-empty and finite".to_string(),
329 ));
330 }
331 if self.metadata.training_table_kind.trim().is_empty() {
332 return Err(ResponseGeometryModelError::InvalidMetadata(
333 "training table kind must be non-empty".to_string(),
334 ));
335 }
336 if let Some(curvature) = self.metadata.curvature.as_ref() {
337 curvature.validate()?;
338 }
339 validate_archived_tangent_fit(&self.shared_tangent_fit)?;
340 self.template_model
341 .validate_for_persistence()
342 .map_err(|error| ResponseGeometryModelError::InvalidTemplate(error.to_string()))?;
343 self.template_model
344 .validate_numeric_finiteness()
345 .map_err(|error| ResponseGeometryModelError::InvalidTemplate(error.to_string()))?;
346 Ok(())
347 }
348
349 pub fn to_bytes(&self) -> Result<Vec<u8>, ResponseGeometryModelError> {
351 self.validate()?;
352 serde_json::to_vec(self)
353 .map_err(|error| ResponseGeometryModelError::Serialization(error.to_string()))
354 }
355
356 pub fn from_bytes(bytes: &[u8]) -> Result<Self, ResponseGeometryModelError> {
358 let model: Self = serde_json::from_slice(bytes)
359 .map_err(|error| ResponseGeometryModelError::Serialization(error.to_string()))?;
360 model.validate()?;
361 Ok(model)
362 }
363
364 pub fn metadata(&self) -> ResponseGeometryMetadata {
365 self.metadata.clone()
366 }
367
368 pub fn summary(&self) -> ResponseGeometrySummary {
369 let payload = self.template_model.payload();
370 ResponseGeometrySummary {
371 model_class: "response-geometry".to_string(),
372 metadata: self.metadata.clone(),
373 tangent_dimension: self.shared_tangent_fit.coefficients.ncols(),
374 shared_smoothing: true,
375 reml_score: self.shared_tangent_fit.reml_score,
376 lambdas: self.shared_tangent_fit.lambdas.clone(),
377 edf_by_penalty: self.shared_tangent_fit.edf_by_penalty.clone(),
378 edf_total: self.shared_tangent_fit.edf_total,
379 sigma2: self.shared_tangent_fit.sigma2,
380 template_formula: payload.formula.clone(),
381 template_family: payload.family.clone(),
382 }
383 }
384
385 pub fn predict_tangent(&self, design: &DesignMatrix) -> Result<Array2<f64>, EstimationError> {
389 self.shared_tangent_fit.predict(design)
390 }
391}
392
393#[derive(Clone, Debug)]
394struct PreparedPenalty {
395 output_slot: usize,
396 column_start: usize,
397 local: Array2<f64>,
398 rank: usize,
399}
400
401#[derive(Clone, Debug)]
402enum SufficientStatistics {
403 Isotropic {
404 gram: Array2<f64>,
405 cross: Array2<f64>,
406 },
407 Fisher {
408 gram: Array2<f64>,
409 cross: Array1<f64>,
410 },
411}
412
413#[derive(Clone, Debug)]
414struct PreparedSharedTangent {
415 design: DesignMatrix,
416 response: Array2<f64>,
417 weights: Array1<f64>,
418 fisher_metric: Option<Array3<f64>>,
419 n_observations: usize,
420 n_coefficients: usize,
421 n_outputs: usize,
422 effective_observations: usize,
423 output_penalty_slots: usize,
424 penalties: Vec<PreparedPenalty>,
425 statistics: SufficientStatistics,
426}
427
428#[derive(Debug)]
429struct Evaluation {
430 cost: f64,
431 gradient: Array1<f64>,
432 hessian: Array2<f64>,
433 coefficients: Array2<f64>,
434 profiled_deviance: f64,
435 penalty_traces: Array1<f64>,
436 lambdas: Array1<f64>,
437 combined_penalty_rank: usize,
443}
444
445#[derive(Debug)]
446struct PenaltySpectrum {
447 rank: usize,
448 log_pseudo_determinant: f64,
449 pseudo_inverse: Array2<f64>,
450}
451
452pub fn fit_shared_tangent_reml(
454 mut request: SharedTangentRemlRequest,
455) -> Result<SharedTangentRemlFit, EstimationError> {
456 let requested_penalty_count = request.penalties.len();
457 let initial_log_lambdas = request.initial_log_lambdas.take();
458 let prepared = PreparedSharedTangent::from_request(request)?;
459 let n_outer = prepared.penalties.len();
460 if let Some(initial) = initial_log_lambdas.as_ref() {
461 if initial.len() != requested_penalty_count {
462 return Err(invalid(format!(
463 "initial_log_lambdas has length {}, expected {}",
464 initial.len(),
465 requested_penalty_count
466 )));
467 }
468 if initial.iter().any(|value| !value.is_finite()) {
469 return Err(invalid("initial_log_lambdas must be finite"));
470 }
471 }
472 let (rho, outer_iterations, certificate) = if n_outer == 0 {
473 (
478 Array1::<f64>::zeros(0),
479 0,
480 OuterCriterionCertificate {
481 stationarity:
482 gam_solve::rho_optimizer::OuterStationarityCertificate::AnalyticGradient {
483 grad_norm: 0.0,
484 projected_grad_norm: 0.0,
485 bound: 0.0,
486 rung: gam_problem::StationarityRung::EMPTY_ESTIMAND.into(),
492 },
493 curvature: gam_solve::rho_optimizer::CurvatureEvidence::NoEstimand,
498 lambdas_railed: Vec::new(),
499 railed_facts: Vec::new(),
500 curvature_floor: None,
501 },
502 )
503 } else {
504 let mut problem = OuterProblem::new(n_outer)
505 .with_gradient(Derivative::Analytic)
506 .with_hessian(DeclaredHessianForm::Dense)
507 .with_disable_fixed_point(true)
508 .with_objective_scale(Some(
509 prepared
510 .effective_observations
511 .checked_mul(prepared.n_outputs)
512 .ok_or_else(|| invalid("effective observation count overflow"))?
513 as f64,
514 ));
515 if let Some(initial) = initial_log_lambdas.as_ref() {
516 problem = problem.with_initial_rho(Array1::from_iter(
517 prepared
518 .penalties
519 .iter()
520 .map(|penalty| initial[penalty.output_slot]),
521 ));
522 }
523 let mut objective = SharedTangentObjective {
524 prepared: &prepared,
525 };
526 let outer = problem.run(&mut objective, FIT_CONTEXT)?;
527 let certificate = outer
528 .criterion_certificate
529 .clone()
530 .filter(OuterCriterionCertificate::certifies)
531 .ok_or_else(|| EstimationError::RemlDidNotConverge {
532 context: FIT_CONTEXT.to_string(),
533 reason: "outer runner returned without a valid analytic certificate".to_string(),
534 iterations: outer.iterations,
535 final_value: outer.final_value,
536 projected_grad_norm: outer
537 .criterion_certificate
538 .as_ref()
539 .map(|value| value.stationarity.projected_norm()),
540 stationarity_standard: StationarityStandard::NoComparison,
547 rho_checkpoint: outer.rho.to_vec(),
548 })?;
549 (outer.rho, outer.iterations, certificate)
550 };
551
552 let evaluation = prepared.evaluate(&rho)?;
553 let fitted = predict_from_coefficients(&prepared.design, &evaluation.coefficients)?;
554 let block_ranks: Vec<usize> = prepared
586 .penalties
587 .iter()
588 .map(|penalty| penalty.rank * prepared.n_outputs)
589 .collect();
590 for (active_index, &rank) in block_ranks.iter().enumerate() {
597 let raw = evaluation.penalty_traces[active_index];
598 if raw.is_finite() {
599 bounded_roundoff_value(raw, 0.0, rank as f64, "per-penalty penalty trace")?;
600 }
601 }
602 let total_coefficients = prepared
603 .n_coefficients
604 .checked_mul(prepared.n_outputs)
605 .ok_or_else(|| invalid("coefficient dimension overflow"))?;
606 let joint_penalty_nullity = prepared
607 .n_coefficients
608 .checked_sub(evaluation.combined_penalty_rank)
609 .ok_or_else(|| invalid("combined penalty rank exceeds coefficient dimension"))?
610 .checked_mul(prepared.n_outputs)
611 .ok_or_else(|| invalid("joint penalty nullity overflow"))?;
612 let bundle = gam_solve::estimate::penalized_edf_bundle(
613 evaluation
614 .penalty_traces
615 .as_slice()
616 .ok_or_else(|| invalid("penalty traces are not contiguous"))?,
617 &block_ranks,
618 total_coefficients,
619 joint_penalty_nullity as f64,
620 );
621 let mut lambdas = Array1::<f64>::zeros(prepared.output_penalty_slots);
622 let mut edf_by_penalty = Array1::<f64>::zeros(prepared.output_penalty_slots);
623 for (active_index, penalty) in prepared.penalties.iter().enumerate() {
624 lambdas[penalty.output_slot] = evaluation.lambdas[active_index];
625 edf_by_penalty[penalty.output_slot] = bundle.edf_by_block[active_index];
626 }
627 let edf_total = bundle.edf_total;
628 let effective_joint_rows = prepared
629 .effective_observations
630 .checked_mul(prepared.n_outputs)
631 .ok_or_else(|| invalid("effective joint row count overflow"))?
632 as f64;
633 let residual_df = effective_joint_rows - edf_total;
634 if !(residual_df.is_finite() && residual_df > 0.0) {
635 return Err(invalid(format!(
636 "residual scale requires positive n*D-edf; got {effective_joint_rows} - {edf_total} = {residual_df}"
637 )));
638 }
639
640 Ok(SharedTangentRemlFit {
641 coefficients: evaluation.coefficients,
642 fitted,
643 sigma2: evaluation.profiled_deviance / residual_df,
644 lambdas,
645 edf_by_penalty,
646 edf_total,
647 reml_score: evaluation.cost,
648 n_observations: prepared.n_observations,
649 n_outputs: prepared.n_outputs,
650 outer_iterations,
651 outer_certificate: certificate,
652 })
653}
654
655struct SharedTangentObjective<'a> {
656 prepared: &'a PreparedSharedTangent,
657}
658
659impl OuterObjective for SharedTangentObjective<'_> {
660 fn capability(&self) -> OuterCapability {
661 OuterCapability {
662 gradient: Derivative::Analytic,
663 hessian: DeclaredHessianForm::Dense,
664 n_params: self.prepared.penalties.len(),
665 psi_dim: 0,
666 fixed_point_available: false,
667 barrier_config: None,
668 prefer_gradient_only: false,
669 disable_fixed_point: true,
670 }
671 }
672
673 fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
674 self.prepared
675 .evaluate(rho)
676 .map(|evaluation| evaluation.cost)
677 }
678
679 fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
680 let evaluation = self.prepared.evaluate(rho)?;
681 Ok(OuterEval {
682 cost: evaluation.cost,
683 gradient: evaluation.gradient,
684 hessian: HessianValue::Dense(evaluation.hessian),
685 inner_beta_hint: None,
686 })
687 }
688
689 fn reset(&mut self) {}
690
691 fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
692 if beta.iter().any(|value| !value.is_finite()) {
695 return Err(invalid(
696 "seed_inner_state received a non-finite β warm-start vector",
697 ));
698 }
699 Ok(SeedOutcome::NoSlot)
700 }
701}
702
703impl PreparedSharedTangent {
704 fn from_request(request: SharedTangentRemlRequest) -> Result<Self, EstimationError> {
705 let SharedTangentRemlRequest {
706 design,
707 response,
708 weights,
709 fisher_metric,
710 penalties: requested_penalties,
711 initial_log_lambdas: _,
712 } = request;
713 let n = design.nrows();
714 let k = design.ncols();
715 let (response_rows, d) = response.dim();
716 if n == 0 || k == 0 || d == 0 {
717 return Err(invalid(format!(
718 "shared-tangent REML requires non-empty dimensions; got N={n}, K={k}, D={d}"
719 )));
720 }
721 if response_rows != n {
722 return Err(invalid(format!(
723 "response rows {response_rows} do not match design rows {n}"
724 )));
725 }
726 if weights.len() != n {
727 return Err(invalid(format!(
728 "weight length {} does not match design rows {n}",
729 weights.len()
730 )));
731 }
732 if response.iter().any(|value| !value.is_finite()) {
733 return Err(invalid("response must contain only finite values"));
734 }
735 if weights
736 .iter()
737 .any(|value| !value.is_finite() || *value < 0.0)
738 {
739 return Err(invalid("weights must be finite and non-negative"));
740 }
741 let effective_observations = weights.iter().filter(|value| **value > 0.0).count();
742 if effective_observations == 0 {
743 return Err(invalid(
744 "at least one observation must have positive weight",
745 ));
746 }
747 if let Some(metric) = fisher_metric.as_ref()
748 && metric.dim() != (n, d, d)
749 {
750 return Err(invalid(format!(
751 "fisher_metric shape {:?} does not match ({n}, {d}, {d})",
752 metric.dim()
753 )));
754 }
755 let fisher_metric = if let Some(metric) = fisher_metric {
756 let mut validated = Array3::<f64>::zeros(metric.dim());
757 for row in 0..n {
758 let row_metric = validated_metric(metric.slice(s![row, .., ..]).to_owned(), row)?;
759 validated.slice_mut(s![row, .., ..]).assign(&row_metric);
760 }
761 Some(validated)
762 } else {
763 None
764 };
765
766 let penalties = prepare_penalties(&requested_penalties, k)?;
767 let output_penalty_slots = requested_penalties.len();
768 let statistics = match fisher_metric.as_ref() {
769 None => assemble_isotropic_statistics(&design, &response, &weights)?,
770 Some(metric) => assemble_fisher_statistics(&design, &response, &weights, metric)?,
771 };
772
773 Ok(Self {
774 design,
775 response,
776 weights,
777 fisher_metric,
778 n_observations: n,
779 n_coefficients: k,
780 n_outputs: d,
781 effective_observations,
782 output_penalty_slots,
783 penalties,
784 statistics,
785 })
786 }
787
788 fn evaluate(&self, rho: &Array1<f64>) -> Result<Evaluation, EstimationError> {
789 if rho.len() != self.penalties.len() {
790 return Err(invalid(format!(
791 "log-lambda length {} does not match active penalty count {}",
792 rho.len(),
793 self.penalties.len()
794 )));
795 }
796 gam_problem::validate_log_strengths(rho.iter().copied())
797 .map_err(|error| invalid(format!("shared-tangent rho: {error}")))?;
798 match &self.statistics {
799 SufficientStatistics::Isotropic { gram, cross } => {
800 self.evaluate_isotropic(rho, gram, cross)
801 }
802 SufficientStatistics::Fisher { gram, cross } => self.evaluate_fisher(rho, gram, cross),
803 }
804 }
805
806 fn evaluate_isotropic(
807 &self,
808 rho: &Array1<f64>,
809 gram: &Array2<f64>,
810 cross: &Array2<f64>,
811 ) -> Result<Evaluation, EstimationError> {
812 let d = self.n_outputs;
813 let (penalty, lambdas) = self.combined_penalty(rho)?;
814 let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
815 let mut penalized = gram.clone();
816 penalized += &penalty;
817 let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
818 let coefficients = inverse.dot(cross);
819 let profiled_deviance = self.profiled_deviance(&coefficients)?;
820 let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
821 validate_profiled_deviance(profiled_deviance)?;
822
823 let m = self.penalties.len();
824 let mut penalty_traces = Array1::<f64>::zeros(m);
825 let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
826 let mut deviance_first = Array1::<f64>::zeros(m);
827 let mut penalty_beta = Vec::with_capacity(m);
828 for (index, penalty_block) in self.penalties.iter().enumerate() {
829 penalty_traces[index] =
830 d as f64 * trace_local_base(&inverse, penalty_block, lambdas[index]);
831 penalty_logdet_traces[index] = d as f64
832 * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
833 let z = apply_local_base_matrix(penalty_block, lambdas[index], &coefficients);
834 deviance_first[index] = sum_products(&coefficients, &z);
835 penalty_beta.push(z);
836 }
837
838
839 let penalized_deviance = profiled_deviance + deviance_first.sum();
862 validate_profiled_deviance(penalized_deviance)?;
863
864 let mut gradient = Array1::<f64>::zeros(m);
865 for j in 0..m {
866 gradient[j] = 0.5
867 * (penalty_traces[j] - penalty_logdet_traces[j]
868 + residual_degrees_of_freedom * deviance_first[j] / penalized_deviance);
869 }
870 let mut hessian = Array2::<f64>::zeros((m, m));
871 for j in 0..m {
872 let h_sandwich = sandwich_local_base(&inverse, &self.penalties[j], lambdas[j]);
873 let p_sandwich =
874 sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
875 for kk in 0..=j {
876 let h_cross = d as f64
877 * trace_sandwich_local_base(&h_sandwich, &self.penalties[kk], lambdas[kk]);
878 let p_cross = d as f64
879 * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
880 let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
881 let deviance_cross = sum_products(&penalty_beta[j], &solved_penalty_beta);
882 let delta = usize::from(j == kk) as f64;
883 let logdet_second = delta * penalty_traces[j] - h_cross;
884 let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
885 let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
886 let value = 0.5
887 * (logdet_second - penalty_logdet_second
888 + residual_degrees_of_freedom
889 * (deviance_second / penalized_deviance
890 - deviance_first[j] * deviance_first[kk]
891 / (penalized_deviance * penalized_deviance)));
892 hessian[[j, kk]] = value;
893 hessian[[kk, j]] = value;
894 }
895 }
896 let cost = 0.5
897 * (d as f64 * log_determinant - d as f64 * spectrum.log_pseudo_determinant
898 + residual_degrees_of_freedom
899 * (1.0
900 + (2.0 * std::f64::consts::PI * penalized_deviance
901 / residual_degrees_of_freedom)
902 .ln()));
903 validate_evaluation(cost, &gradient, &hessian)?;
904 Ok(Evaluation {
905 cost,
906 gradient,
907 hessian,
908 coefficients,
909 profiled_deviance,
910 penalty_traces,
911 lambdas,
912 combined_penalty_rank: spectrum.rank,
913 })
914 }
915
916 fn evaluate_fisher(
917 &self,
918 rho: &Array1<f64>,
919 gram: &Array2<f64>,
920 cross: &Array1<f64>,
921 ) -> Result<Evaluation, EstimationError> {
922 let k = self.n_coefficients;
923 let d = self.n_outputs;
924 let q = k
925 .checked_mul(d)
926 .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
927 let (penalty, lambdas) = self.combined_penalty(rho)?;
928 let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
929 let mut penalized = gram.clone();
930 add_base_penalty_to_joint(&mut penalized, &penalty, d);
931 let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
932 let beta = inverse.dot(cross);
933 let mut coefficients = Array2::<f64>::zeros((k, d));
934 for basis in 0..k {
935 for output in 0..d {
936 coefficients[[basis, output]] = beta[basis * d + output];
937 }
938 }
939 let profiled_deviance = self.profiled_deviance(&coefficients)?;
940 validate_profiled_deviance(profiled_deviance)?;
941 let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
942
943 let m = self.penalties.len();
944 let mut penalty_traces = Array1::<f64>::zeros(m);
945 let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
946 let mut deviance_first = Array1::<f64>::zeros(m);
947 let mut penalty_beta = Vec::with_capacity(m);
948 for (index, penalty_block) in self.penalties.iter().enumerate() {
949 penalty_traces[index] = trace_local_joint(&inverse, penalty_block, lambdas[index], d);
950 penalty_logdet_traces[index] = d as f64
951 * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
952 let z = apply_local_joint_vector(penalty_block, lambdas[index], d, &beta);
953 deviance_first[index] = beta.dot(&z);
954 penalty_beta.push(z);
955 }
956
957
958 let penalized_deviance = profiled_deviance + deviance_first.sum();
981 validate_profiled_deviance(penalized_deviance)?;
982
983 let mut gradient = Array1::<f64>::zeros(m);
984 for j in 0..m {
985 gradient[j] = 0.5
986 * (penalty_traces[j] - penalty_logdet_traces[j]
987 + residual_degrees_of_freedom * deviance_first[j] / penalized_deviance);
988 }
989 let mut hessian = Array2::<f64>::zeros((m, m));
990 for j in 0..m {
991 let h_sandwich = sandwich_local_joint(&inverse, &self.penalties[j], lambdas[j], d);
992 let p_sandwich =
993 sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
994 for kk in 0..=j {
995 let h_cross =
996 trace_sandwich_local_joint(&h_sandwich, &self.penalties[kk], lambdas[kk], d);
997 let p_cross = d as f64
998 * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
999 let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
1000 let deviance_cross = penalty_beta[j].dot(&solved_penalty_beta);
1001 let delta = usize::from(j == kk) as f64;
1002 let logdet_second = delta * penalty_traces[j] - h_cross;
1003 let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
1004 let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
1005 let value = 0.5
1006 * (logdet_second - penalty_logdet_second
1007 + residual_degrees_of_freedom
1008 * (deviance_second / penalized_deviance
1009 - deviance_first[j] * deviance_first[kk]
1010 / (penalized_deviance * penalized_deviance)));
1011 hessian[[j, kk]] = value;
1012 hessian[[kk, j]] = value;
1013 }
1014 }
1015 let cost = 0.5
1016 * (log_determinant - d as f64 * spectrum.log_pseudo_determinant
1017 + residual_degrees_of_freedom
1018 * (1.0
1019 + (2.0 * std::f64::consts::PI * penalized_deviance
1020 / residual_degrees_of_freedom)
1021 .ln()));
1022 if inverse.dim() != (q, q) {
1023 return Err(invalid("internal Fisher inverse shape mismatch"));
1024 }
1025 validate_evaluation(cost, &gradient, &hessian)?;
1026 Ok(Evaluation {
1027 cost,
1028 gradient,
1029 hessian,
1030 coefficients,
1031 profiled_deviance,
1032 penalty_traces,
1033 lambdas,
1034 combined_penalty_rank: spectrum.rank,
1035 })
1036 }
1037
1038 fn profiled_deviance(&self, coefficients: &Array2<f64>) -> Result<f64, EstimationError> {
1044 let n = self.design.nrows();
1045 let k = self.design.ncols();
1046 let d = self.response.ncols();
1047 if coefficients.dim() != (k, d) {
1048 return Err(invalid(format!(
1049 "shared-tangent coefficient shape {:?} does not match ({k}, {d})",
1050 coefficients.dim()
1051 )));
1052 }
1053 let mut quadratic = KahanSum::default();
1054 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1055 for start in (0..n).step_by(chunk_rows) {
1056 let end = (start + chunk_rows).min(n);
1057 let x_chunk = self
1058 .design
1059 .try_row_chunk(start..end)
1060 .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1061 validate_design_chunk(&x_chunk)?;
1062 let fitted = x_chunk.dot(coefficients);
1063 for local_row in 0..x_chunk.nrows() {
1064 let row = start + local_row;
1065 let weight = self.weights[row];
1066 if weight == 0.0 {
1067 continue;
1068 }
1069 if let Some(metric) = self.fisher_metric.as_ref() {
1070 for output_a in 0..d {
1071 let residual_a =
1072 self.response[[row, output_a]] - fitted[[local_row, output_a]];
1073 for output_b in 0..d {
1074 let residual_b =
1075 self.response[[row, output_b]] - fitted[[local_row, output_b]];
1076 quadratic.add(
1077 weight
1078 * residual_a
1079 * metric[[row, output_a, output_b]]
1080 * residual_b,
1081 );
1082 }
1083 }
1084 } else {
1085 for output in 0..d {
1086 let residual = self.response[[row, output]] - fitted[[local_row, output]];
1087 quadratic.add(weight * residual * residual);
1088 }
1089 }
1090 }
1091 }
1092 Ok(quadratic.sum())
1093 }
1094
1095 fn combined_penalty(
1096 &self,
1097 rho: &Array1<f64>,
1098 ) -> Result<(Array2<f64>, Array1<f64>), EstimationError> {
1099 let mut combined = Array2::<f64>::zeros((self.n_coefficients, self.n_coefficients));
1100 let lambdas = Array1::from_vec(
1101 gam_problem::checked_exp_log_strengths(rho.iter().copied())
1102 .map_err(|error| invalid(format!("shared-tangent rho: {error}")))?,
1103 );
1104 for (index, penalty) in self.penalties.iter().enumerate() {
1105 let lambda = lambdas[index];
1106 for local_row in 0..penalty.local.nrows() {
1107 for local_col in 0..penalty.local.ncols() {
1108 combined[[
1109 penalty.column_start + local_row,
1110 penalty.column_start + local_col,
1111 ]] += lambda * penalty.local[[local_row, local_col]];
1112 }
1113 }
1114 }
1115 Ok((combined, lambdas))
1116 }
1117
1118 fn residual_degrees_of_freedom(
1119 &self,
1120 combined_penalty_rank: usize,
1121 ) -> Result<f64, EstimationError> {
1122 let effective_rows = self
1123 .effective_observations
1124 .checked_mul(self.n_outputs)
1125 .ok_or_else(|| invalid("effective joint row count overflow"))?;
1126 let base_nullity = self
1127 .n_coefficients
1128 .checked_sub(combined_penalty_rank)
1129 .ok_or_else(|| invalid("combined penalty rank exceeds coefficient dimension"))?;
1130 let joint_nullity = base_nullity
1131 .checked_mul(self.n_outputs)
1132 .ok_or_else(|| invalid("joint penalty nullity overflow"))?;
1133 if effective_rows <= joint_nullity {
1134 return Err(invalid(format!(
1135 "REML requires more effective joint rows than unpenalized coefficients; got {effective_rows} rows and nullity {joint_nullity}"
1136 )));
1137 }
1138 Ok((effective_rows - joint_nullity) as f64)
1139 }
1140}
1141
1142fn prepare_penalties(
1143 penalties: &[SharedTangentPenalty],
1144 n_coefficients: usize,
1145) -> Result<Vec<PreparedPenalty>, EstimationError> {
1146 let mut prepared = Vec::with_capacity(penalties.len());
1147 for (slot, penalty) in penalties.iter().enumerate() {
1148 let q = penalty.matrix.nrows();
1149 if q != penalty.matrix.ncols() {
1150 return Err(invalid(format!(
1151 "penalty {slot} must be square; got {}x{}",
1152 penalty.matrix.nrows(),
1153 penalty.matrix.ncols()
1154 )));
1155 }
1156 let end = penalty
1157 .column_start
1158 .checked_add(q)
1159 .ok_or_else(|| invalid(format!("penalty {slot} column range overflow")))?;
1160 if end > n_coefficients {
1161 return Err(invalid(format!(
1162 "penalty {slot} column range {}..{end} exceeds design width {n_coefficients}",
1163 penalty.column_start
1164 )));
1165 }
1166 if penalty.matrix.iter().any(|value| !value.is_finite()) {
1167 return Err(invalid(format!(
1168 "penalty {slot} contains non-finite values"
1169 )));
1170 }
1171 let local = symmetric_average(&penalty.matrix);
1172 let spectrum = penalty_spectrum(&local, &format!("shared-tangent penalty {slot}"))?;
1173 if spectrum.rank == 0 {
1174 continue;
1175 }
1176 prepared.push(PreparedPenalty {
1177 output_slot: slot,
1178 column_start: penalty.column_start,
1179 local,
1180 rank: spectrum.rank,
1181 });
1182 }
1183 Ok(prepared)
1184}
1185
1186fn assemble_isotropic_statistics(
1187 design: &DesignMatrix,
1188 response: &Array2<f64>,
1189 weights: &Array1<f64>,
1190) -> Result<SufficientStatistics, EstimationError> {
1191 let n = design.nrows();
1192 let k = design.ncols();
1193 let d = response.ncols();
1194 let mut gram = Array2::<f64>::zeros((k, k));
1195 let mut cross = Array2::<f64>::zeros((k, d));
1196 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1197 for start in (0..n).step_by(chunk_rows) {
1198 let end = (start + chunk_rows).min(n);
1199 let x_chunk = design
1200 .try_row_chunk(start..end)
1201 .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1202 validate_design_chunk(&x_chunk)?;
1203 let weight_chunk = weights.slice(s![start..end]);
1204 let response_chunk = response.slice(s![start..end, ..]);
1205 gram += &fast_xt_diag_x(&x_chunk, &weight_chunk);
1206 cross += &fast_xt_diag_y(&x_chunk, &weight_chunk, &response_chunk);
1207 }
1208 Ok(SufficientStatistics::Isotropic { gram, cross })
1209}
1210
1211fn assemble_fisher_statistics(
1212 design: &DesignMatrix,
1213 response: &Array2<f64>,
1214 weights: &Array1<f64>,
1215 fisher_metric: &Array3<f64>,
1216) -> Result<SufficientStatistics, EstimationError> {
1217 let n = design.nrows();
1218 let k = design.ncols();
1219 let d = response.ncols();
1220 let q = k
1221 .checked_mul(d)
1222 .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
1223 let mut gram = Array2::<f64>::zeros((q, q));
1224 let mut cross = Array1::<f64>::zeros(q);
1225 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1226 for start in (0..n).step_by(chunk_rows) {
1227 let end = (start + chunk_rows).min(n);
1228 let x_chunk = design
1229 .try_row_chunk(start..end)
1230 .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1231 validate_design_chunk(&x_chunk)?;
1232 for local_row in 0..x_chunk.nrows() {
1233 let row = start + local_row;
1234 let metric = fisher_metric.slice(s![row, .., ..]);
1235 let y = response.row(row);
1236 let metric_y = metric.dot(&y);
1237 let weight = weights[row];
1238 for basis_a in 0..k {
1239 let x_a = x_chunk[[local_row, basis_a]];
1240 for output in 0..d {
1241 cross[basis_a * d + output] += weight * x_a * metric_y[output];
1242 }
1243 for basis_b in 0..k {
1244 let scale = weight * x_a * x_chunk[[local_row, basis_b]];
1245 if scale == 0.0 {
1246 continue;
1247 }
1248 for output_a in 0..d {
1249 for output_b in 0..d {
1250 gram[[basis_a * d + output_a, basis_b * d + output_b]] +=
1251 scale * metric[[output_a, output_b]];
1252 }
1253 }
1254 }
1255 }
1256 }
1257 }
1258 Ok(SufficientStatistics::Fisher { gram, cross })
1259}
1260
1261fn validated_metric(mut metric: Array2<f64>, row: usize) -> Result<Array2<f64>, EstimationError> {
1262 if metric.iter().any(|value| !value.is_finite()) {
1263 return Err(invalid(format!(
1264 "fisher_metric row {row} contains non-finite values"
1265 )));
1266 }
1267 let scale = metric
1268 .iter()
1269 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1270 let tolerance = f64::EPSILON.sqrt() * metric.nrows().max(1) as f64 * scale;
1271 for a in 0..metric.nrows() {
1272 for b in (a + 1)..metric.ncols() {
1273 if (metric[[a, b]] - metric[[b, a]]).abs() > tolerance {
1274 return Err(invalid(format!(
1275 "fisher_metric row {row} is not symmetric at ({a}, {b})"
1276 )));
1277 }
1278 let average = 0.5 * (metric[[a, b]] + metric[[b, a]]);
1279 metric[[a, b]] = average;
1280 metric[[b, a]] = average;
1281 }
1282 }
1283 metric.cholesky(Side::Lower).map_err(|error| {
1284 invalid(format!(
1285 "fisher_metric row {row} must be positive definite: {error}"
1286 ))
1287 })?;
1288 Ok(metric)
1289}
1290
1291fn penalty_spectrum(
1292 penalty: &Array2<f64>,
1293 context: &str,
1294) -> Result<PenaltySpectrum, EstimationError> {
1295 let (eigenvalues, eigenvectors) = penalty
1296 .eigh(Side::Lower)
1297 .map_err(EstimationError::EigendecompositionFailed)?;
1298 let scale = eigenvalues
1299 .iter()
1300 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1301 let tolerance = f64::EPSILON.sqrt() * eigenvalues.len().max(1) as f64 * scale;
1302 let mut rank = 0usize;
1303 let mut log_pseudo_determinant = 0.0;
1304 let mut pseudo_inverse = Array2::<f64>::zeros(penalty.dim());
1305 for (index, &value) in eigenvalues.iter().enumerate() {
1306 if !value.is_finite() {
1307 return Err(EstimationError::PenaltySpectrumNonFinite {
1308 context: context.to_string(),
1309 index,
1310 value,
1311 });
1312 }
1313 if value < -tolerance {
1314 return Err(EstimationError::PenaltySpectrumIndefinite {
1315 context: context.to_string(),
1316 index,
1317 value,
1318 tolerance,
1319 scale,
1320 });
1321 }
1322 if value <= tolerance {
1323 continue;
1324 }
1325 rank += 1;
1326 log_pseudo_determinant += value.ln();
1327 for row in 0..penalty.nrows() {
1328 for col in 0..penalty.ncols() {
1329 pseudo_inverse[[row, col]] +=
1330 eigenvectors[[row, index]] * eigenvectors[[col, index]] / value;
1331 }
1332 }
1333 }
1334 Ok(PenaltySpectrum {
1335 rank,
1336 log_pseudo_determinant,
1337 pseudo_inverse,
1338 })
1339}
1340
1341fn spd_inverse_and_logdet(matrix: &Array2<f64>) -> Result<(Array2<f64>, f64), EstimationError> {
1342 let factor =
1343 matrix
1344 .cholesky(Side::Lower)
1345 .map_err(|_| EstimationError::ModelIsIllConditioned {
1346 condition_number: f64::INFINITY,
1347 })?;
1348 let diagonal = factor.diag();
1349 if diagonal
1350 .iter()
1351 .any(|value| !value.is_finite() || *value <= 0.0)
1352 {
1353 return Err(EstimationError::ModelIsIllConditioned {
1354 condition_number: f64::INFINITY,
1355 });
1356 }
1357 let log_determinant = 2.0 * diagonal.iter().map(|value| value.ln()).sum::<f64>();
1358 let identity = Array2::<f64>::eye(matrix.nrows());
1359 let inverse = factor.solve_mat(&identity);
1360 if !log_determinant.is_finite() || inverse.iter().any(|value| !value.is_finite()) {
1361 return Err(EstimationError::ModelIsIllConditioned {
1362 condition_number: f64::INFINITY,
1363 });
1364 }
1365 Ok((inverse, log_determinant))
1366}
1367
1368fn trace_local_base(inverse: &Array2<f64>, penalty: &PreparedPenalty, lambda: f64) -> f64 {
1369 let mut trace = 0.0;
1370 for row in 0..penalty.local.nrows() {
1371 for col in 0..penalty.local.ncols() {
1372 trace += lambda
1373 * penalty.local[[row, col]]
1374 * inverse[[penalty.column_start + col, penalty.column_start + row]];
1375 }
1376 }
1377 trace
1378}
1379
1380fn trace_local_joint(
1381 inverse: &Array2<f64>,
1382 penalty: &PreparedPenalty,
1383 lambda: f64,
1384 n_outputs: usize,
1385) -> f64 {
1386 let mut trace = 0.0;
1387 for output in 0..n_outputs {
1388 for row in 0..penalty.local.nrows() {
1389 for col in 0..penalty.local.ncols() {
1390 trace += lambda
1391 * penalty.local[[row, col]]
1392 * inverse[[
1393 (penalty.column_start + col) * n_outputs + output,
1394 (penalty.column_start + row) * n_outputs + output,
1395 ]];
1396 }
1397 }
1398 }
1399 trace
1400}
1401
1402fn apply_local_base_matrix(
1403 penalty: &PreparedPenalty,
1404 lambda: f64,
1405 matrix: &Array2<f64>,
1406) -> Array2<f64> {
1407 let mut output = Array2::<f64>::zeros(matrix.dim());
1408 for row in 0..penalty.local.nrows() {
1409 for col in 0..penalty.local.ncols() {
1410 let value = lambda * penalty.local[[row, col]];
1411 for output_index in 0..matrix.ncols() {
1412 output[[penalty.column_start + row, output_index]] +=
1413 value * matrix[[penalty.column_start + col, output_index]];
1414 }
1415 }
1416 }
1417 output
1418}
1419
1420fn apply_local_joint_vector(
1421 penalty: &PreparedPenalty,
1422 lambda: f64,
1423 n_outputs: usize,
1424 vector: &Array1<f64>,
1425) -> Array1<f64> {
1426 let mut output = Array1::<f64>::zeros(vector.len());
1427 for output_index in 0..n_outputs {
1428 for row in 0..penalty.local.nrows() {
1429 for col in 0..penalty.local.ncols() {
1430 output[(penalty.column_start + row) * n_outputs + output_index] += lambda
1431 * penalty.local[[row, col]]
1432 * vector[(penalty.column_start + col) * n_outputs + output_index];
1433 }
1434 }
1435 }
1436 output
1437}
1438
1439fn sandwich_local_base(
1440 inverse: &Array2<f64>,
1441 penalty: &PreparedPenalty,
1442 lambda: f64,
1443) -> Array2<f64> {
1444 let dimension = inverse.nrows();
1445 let mut result = Array2::<f64>::zeros((dimension, dimension));
1446 for local_row in 0..penalty.local.nrows() {
1447 let global_row = penalty.column_start + local_row;
1448 for local_col in 0..penalty.local.ncols() {
1449 let value = lambda * penalty.local[[local_row, local_col]];
1450 if value == 0.0 {
1451 continue;
1452 }
1453 let global_col = penalty.column_start + local_col;
1454 for row in 0..dimension {
1455 let left = inverse[[row, global_row]] * value;
1456 for col in 0..dimension {
1457 result[[row, col]] += left * inverse[[global_col, col]];
1458 }
1459 }
1460 }
1461 }
1462 result
1463}
1464
1465fn sandwich_local_joint(
1466 inverse: &Array2<f64>,
1467 penalty: &PreparedPenalty,
1468 lambda: f64,
1469 n_outputs: usize,
1470) -> Array2<f64> {
1471 let dimension = inverse.nrows();
1472 let mut result = Array2::<f64>::zeros((dimension, dimension));
1473 for output in 0..n_outputs {
1474 for local_row in 0..penalty.local.nrows() {
1475 let global_row = (penalty.column_start + local_row) * n_outputs + output;
1476 for local_col in 0..penalty.local.ncols() {
1477 let value = lambda * penalty.local[[local_row, local_col]];
1478 if value == 0.0 {
1479 continue;
1480 }
1481 let global_col = (penalty.column_start + local_col) * n_outputs + output;
1482 for row in 0..dimension {
1483 let left = inverse[[row, global_row]] * value;
1484 for col in 0..dimension {
1485 result[[row, col]] += left * inverse[[global_col, col]];
1486 }
1487 }
1488 }
1489 }
1490 }
1491 result
1492}
1493
1494fn trace_sandwich_local_base(
1495 sandwich: &Array2<f64>,
1496 penalty: &PreparedPenalty,
1497 lambda: f64,
1498) -> f64 {
1499 let mut trace = 0.0;
1500 for row in 0..penalty.local.nrows() {
1501 for col in 0..penalty.local.ncols() {
1502 trace += lambda
1503 * penalty.local[[row, col]]
1504 * sandwich[[penalty.column_start + col, penalty.column_start + row]];
1505 }
1506 }
1507 trace
1508}
1509
1510fn trace_sandwich_local_joint(
1511 sandwich: &Array2<f64>,
1512 penalty: &PreparedPenalty,
1513 lambda: f64,
1514 n_outputs: usize,
1515) -> f64 {
1516 let mut trace = 0.0;
1517 for output in 0..n_outputs {
1518 for row in 0..penalty.local.nrows() {
1519 for col in 0..penalty.local.ncols() {
1520 trace += lambda
1521 * penalty.local[[row, col]]
1522 * sandwich[[
1523 (penalty.column_start + col) * n_outputs + output,
1524 (penalty.column_start + row) * n_outputs + output,
1525 ]];
1526 }
1527 }
1528 }
1529 trace
1530}
1531
1532fn add_base_penalty_to_joint(joint: &mut Array2<f64>, penalty: &Array2<f64>, n_outputs: usize) {
1533 for row in 0..penalty.nrows() {
1534 for col in 0..penalty.ncols() {
1535 let value = penalty[[row, col]];
1536 for output in 0..n_outputs {
1537 joint[[row * n_outputs + output, col * n_outputs + output]] += value;
1538 }
1539 }
1540 }
1541}
1542
1543fn symmetric_average(matrix: &Array2<f64>) -> Array2<f64> {
1544 let mut output = matrix.clone();
1545 for row in 0..matrix.nrows() {
1546 for col in (row + 1)..matrix.ncols() {
1547 let average = 0.5 * (matrix[[row, col]] + matrix[[col, row]]);
1548 output[[row, col]] = average;
1549 output[[col, row]] = average;
1550 }
1551 }
1552 output
1553}
1554
1555fn predict_from_coefficients(
1556 design: &DesignMatrix,
1557 coefficients: &Array2<f64>,
1558) -> Result<Array2<f64>, EstimationError> {
1559 if design.ncols() != coefficients.nrows() {
1560 return Err(invalid(format!(
1561 "prediction design width {} does not match coefficient rows {}",
1562 design.ncols(),
1563 coefficients.nrows()
1564 )));
1565 }
1566 let mut prediction = Array2::<f64>::zeros((design.nrows(), coefficients.ncols()));
1567 for output in 0..coefficients.ncols() {
1568 let values = design.apply(&coefficients.column(output).to_owned());
1569 prediction.column_mut(output).assign(&values);
1570 }
1571 if prediction.iter().any(|value| !value.is_finite()) {
1572 return Err(invalid("prediction produced non-finite values"));
1573 }
1574 Ok(prediction)
1575}
1576
1577fn sum_products(left: &Array2<f64>, right: &Array2<f64>) -> f64 {
1578 left.iter()
1579 .zip(right.iter())
1580 .map(|(left, right)| left * right)
1581 .sum()
1582}
1583
1584fn validate_design_chunk(chunk: &Array2<f64>) -> Result<(), EstimationError> {
1585 if chunk.iter().any(|value| !value.is_finite()) {
1586 return Err(invalid("design contains non-finite values"));
1587 }
1588 Ok(())
1589}
1590
1591fn validate_profiled_deviance(value: f64) -> Result<(), EstimationError> {
1592 if !value.is_finite() || value <= 0.0 {
1593 return Err(EstimationError::RemlOptimizationFailed(format!(
1594 "{FIT_CONTEXT}: profiled penalized deviance must be finite and positive, got {value}"
1595 )));
1596 }
1597 Ok(())
1598}
1599
1600fn validate_evaluation(
1601 cost: f64,
1602 gradient: &Array1<f64>,
1603 hessian: &Array2<f64>,
1604) -> Result<(), EstimationError> {
1605 if !cost.is_finite()
1606 || gradient.iter().any(|value| !value.is_finite())
1607 || hessian.iter().any(|value| !value.is_finite())
1608 {
1609 return Err(EstimationError::RemlOptimizationFailed(format!(
1610 "{FIT_CONTEXT}: objective evaluation produced non-finite value or derivatives"
1611 )));
1612 }
1613 Ok(())
1614}
1615
1616fn validate_archived_tangent_fit(
1617 fit: &SharedTangentRemlFit,
1618) -> Result<(), ResponseGeometryModelError> {
1619 if fit.n_observations == 0
1620 || fit.n_outputs == 0
1621 || fit.coefficients.nrows() == 0
1622 || fit.coefficients.ncols() != fit.n_outputs
1623 || fit.fitted.dim() != (fit.n_observations, fit.n_outputs)
1624 {
1625 return Err(ResponseGeometryModelError::InvalidMetadata(
1626 "shared tangent fit has inconsistent dimensions".to_string(),
1627 ));
1628 }
1629 if fit.lambdas.len() != fit.edf_by_penalty.len() {
1630 return Err(ResponseGeometryModelError::InvalidMetadata(
1631 "shared tangent lambda and EDF vectors are misaligned".to_string(),
1632 ));
1633 }
1634 if fit.coefficients.iter().any(|value| !value.is_finite())
1635 || fit.fitted.iter().any(|value| !value.is_finite())
1636 || fit
1637 .lambdas
1638 .iter()
1639 .any(|value| !value.is_finite() || *value < 0.0)
1640 || fit
1641 .edf_by_penalty
1642 .iter()
1643 .any(|value| !value.is_finite() || *value < 0.0)
1644 || !fit.sigma2.is_finite()
1645 || fit.sigma2 <= 0.0
1646 || !fit.edf_total.is_finite()
1647 || fit.edf_total < 0.0
1648 || !fit.reml_score.is_finite()
1649 {
1650 return Err(ResponseGeometryModelError::InvalidMetadata(
1651 "shared tangent fit contains invalid numerical values".to_string(),
1652 ));
1653 }
1654 if !fit.outer_certificate.certifies() {
1655 return Err(ResponseGeometryModelError::InvalidMetadata(
1656 "shared tangent fit lacks a valid convergence certificate".to_string(),
1657 ));
1658 }
1659 Ok(())
1660}
1661
1662fn bounded_roundoff_value(
1663 value: f64,
1664 lower: f64,
1665 upper: f64,
1666 context: &str,
1667) -> Result<f64, EstimationError> {
1668 let tolerance = f64::EPSILON.sqrt() * upper.abs().max(1.0);
1669 if !value.is_finite() || value < lower - tolerance || value > upper + tolerance {
1670 return Err(EstimationError::RemlOptimizationFailed(format!(
1671 "{FIT_CONTEXT}: {context} {value} lies outside [{lower}, {upper}] beyond roundoff"
1672 )));
1673 }
1674 Ok(value.clamp(lower, upper))
1675}
1676
1677fn invalid(message: impl Into<String>) -> EstimationError {
1678 EstimationError::InvalidInput(message.into())
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683 use super::*;
1684 use gam_linalg::test_support::no_densify_design;
1685 use ndarray::{Array3, array};
1686
1687 fn fixture_request(fisher_metric: Option<Array3<f64>>) -> SharedTangentRemlRequest {
1688 let design = array![
1689 [1.0, -1.0, 0.5],
1690 [1.0, -0.5, -0.2],
1691 [1.0, 0.0, 0.3],
1692 [1.0, 0.5, 0.8],
1693 [1.0, 1.0, -0.4],
1694 [1.0, 1.5, 0.1]
1695 ];
1696 let response = array![
1697 [-0.7, 0.4],
1698 [-0.1, 0.1],
1699 [0.2, -0.3],
1700 [0.8, -0.2],
1701 [1.1, 0.5],
1702 [1.7, 0.2]
1703 ];
1704 let penalties = vec![
1705 SharedTangentPenalty::new(1, array![[1.0, 0.0], [0.0, 0.0]]),
1706 SharedTangentPenalty::new(1, array![[0.0, 0.0], [0.0, 1.0]]),
1707 ];
1708 SharedTangentRemlRequest::new(
1709 no_densify_design(design),
1710 response,
1711 array![1.0, 0.8, 1.2, 1.0, 0.9, 1.1],
1712 fisher_metric,
1713 penalties,
1714 )
1715 }
1716
1717 #[test]
1718 fn operator_backed_isotropic_path_matches_streamed_identity_fisher_path() {
1719 let isotropic_request = fixture_request(None);
1720 let n = isotropic_request.response.nrows();
1721 let d = isotropic_request.response.ncols();
1722 let mut identity_metric = Array3::<f64>::zeros((n, d, d));
1723 for row in 0..n {
1724 for output in 0..d {
1725 identity_metric[[row, output, output]] = 1.0;
1726 }
1727 }
1728 let fisher_request = fixture_request(Some(identity_metric));
1729 let isotropic = PreparedSharedTangent::from_request(isotropic_request)
1730 .expect("prepare isotropic without densifying");
1731 let fisher = PreparedSharedTangent::from_request(fisher_request)
1732 .expect("prepare Fisher without densifying");
1733 let rho = array![-0.4, 0.7];
1734 let left = isotropic.evaluate(&rho).expect("isotropic eval");
1735 let right = fisher.evaluate(&rho).expect("Fisher eval");
1736 assert_close(left.cost, right.cost, 2.0e-11);
1737 assert_array1_close(&left.gradient, &right.gradient, 2.0e-10);
1738 assert_array2_close(&left.hessian, &right.hessian, 2.0e-9);
1739 assert_array2_close(&left.coefficients, &right.coefficients, 2.0e-11);
1740 }
1741
1742 #[test]
1743 fn analytic_gradient_and_hessian_match_test_only_finite_differences() {
1744 let request = fixture_request(None);
1745 let prepared = PreparedSharedTangent::from_request(request).expect("prepare");
1746 let rho = array![-0.2, 0.35];
1747 let exact = prepared.evaluate(&rho).expect("exact eval");
1748 let step = f64::EPSILON.cbrt();
1749 for j in 0..rho.len() {
1750 let mut plus = rho.clone();
1751 let mut minus = rho.clone();
1752 plus[j] += step;
1753 minus[j] -= step;
1754 let plus_eval = prepared.evaluate(&plus).expect("plus eval");
1755 let minus_eval = prepared.evaluate(&minus).expect("minus eval");
1756 let gradient_fd = (plus_eval.cost - minus_eval.cost) / (2.0 * step);
1757 assert_close(exact.gradient[j], gradient_fd, 2.0e-6);
1758 for k in 0..rho.len() {
1759 let hessian_fd = (plus_eval.gradient[k] - minus_eval.gradient[k]) / (2.0 * step);
1760 assert_close(exact.hessian[[k, j]], hessian_fd, 3.0e-6);
1761 }
1762 }
1763 }
1764
1765 #[test]
1785 fn the_shared_tangent_criterion_carries_no_soft_rho_guard_floor_2629() {
1786 use gam_solve::rho_optimizer::soft_rho_guard_floor::{
1787 GuardLadderRung, SATURATED_RHO_LADDER, classify_soft_rho_guard_floor,
1788 soft_rho_guard_emission_at,
1789 };
1790
1791 let prepared =
1792 PreparedSharedTangent::from_request(fixture_request(None)).expect("prepare");
1793 let ladders: Vec<Vec<GuardLadderRung>> = (0..2)
1797 .map(|coord| {
1798 SATURATED_RHO_LADDER
1799 .iter()
1800 .map(|&probe| {
1801 let rho = Array1::from_elem(2, probe);
1802 let evaluation = prepared
1803 .evaluate(&rho)
1804 .unwrap_or_else(|e| panic!("shared-tangent eval at rho={probe}: {e}"));
1805 GuardLadderRung {
1806 rho: probe,
1807 rho_gradient: evaluation.gradient[coord],
1808 }
1809 })
1810 .collect()
1811 })
1812 .collect();
1813
1814 for (coord, ladder) in ladders.iter().enumerate() {
1815 let verdict = classify_soft_rho_guard_floor(ladder, 0.0);
1818 let rendered = ladder
1819 .iter()
1820 .map(|rung| {
1821 format!(
1822 "(rho={:.0}, g={:+.6e}, guard={:.6e})",
1823 rung.rho,
1824 rung.rho_gradient,
1825 soft_rho_guard_emission_at(rung.rho, 0.0)
1826 )
1827 })
1828 .collect::<Vec<_>>()
1829 .join(" ");
1830 eprintln!(
1831 "[#2629-table] shared-tangent k={coord}: {} | {rendered}",
1832 verdict.summary()
1833 );
1834 assert!(
1835 verdict.is_absent(),
1836 "#2629's table lists `shared-tangent` as carrying no soft rho-guard \
1837 barrier. If that is wrong, every railed coordinate of every \
1838 shared-tangent fit carries a standing |Pg| >= w*a = 1.3333e-7 that \
1839 no convergence clears, and this objective owes the seam a \
1840 publication. k={coord}: {} | ladder {rendered}",
1841 verdict.summary()
1842 );
1843
1844 let injected: Vec<GuardLadderRung> = ladder
1849 .iter()
1850 .map(|rung| GuardLadderRung {
1851 rho: rung.rho,
1852 rho_gradient: rung.rho_gradient
1853 + soft_rho_guard_emission_at(rung.rho, 0.0),
1854 })
1855 .collect();
1856 let injected_verdict = classify_soft_rho_guard_floor(&injected, 0.0);
1857 assert!(
1858 !injected_verdict.is_absent(),
1859 "k={coord}: with the barrier explicitly added this ladder must NOT \
1860 read as absent — if it does, the fixture is blind to the very \
1861 thing the assertion above claims to have looked for. Got: {}",
1862 injected_verdict.summary()
1863 );
1864 eprintln!(
1865 "[#2629-control] shared-tangent k={coord} + injected barrier: {}",
1866 injected_verdict.summary()
1867 );
1868 }
1869 }
1870
1871 #[test]
1872 fn streamed_varying_fisher_statistics_match_explicit_joint_oracle() {
1873 let base = fixture_request(None);
1874 let n = base.response.nrows();
1875 let d = base.response.ncols();
1876 let mut metric = Array3::<f64>::zeros((n, d, d));
1877 for row in 0..n {
1878 let off = 0.04 * (row as f64 + 1.0);
1879 metric[[row, 0, 0]] = 1.2 + 0.1 * row as f64;
1880 metric[[row, 0, 1]] = off;
1881 metric[[row, 1, 0]] = off;
1882 metric[[row, 1, 1]] = 0.9 + 0.05 * row as f64;
1883 }
1884 let request = fixture_request(Some(metric.clone()));
1885 let prepared =
1886 PreparedSharedTangent::from_request(request.clone()).expect("prepare Fisher");
1887 let SufficientStatistics::Fisher { gram, cross } = &prepared.statistics else {
1888 panic!("expected Fisher statistics")
1889 };
1890 let x = base.design.try_row_chunk(0..n).expect("test design rows");
1891 let k = x.ncols();
1892 let q = k * d;
1893 let mut oracle_gram = Array2::<f64>::zeros((q, q));
1894 let mut oracle_cross = Array1::<f64>::zeros(q);
1895 let mut oracle_response = 0.0;
1896 for row in 0..n {
1897 for a in 0..k {
1898 for o in 0..d {
1899 let ao = a * d + o;
1900 for p in 0..d {
1901 oracle_cross[ao] += request.weights[row]
1902 * x[[row, a]]
1903 * metric[[row, o, p]]
1904 * request.response[[row, p]];
1905 }
1906 for b in 0..k {
1907 for p in 0..d {
1908 oracle_gram[[ao, b * d + p]] += request.weights[row]
1909 * x[[row, a]]
1910 * x[[row, b]]
1911 * metric[[row, o, p]];
1912 }
1913 }
1914 }
1915 }
1916 let y = request.response.row(row);
1917 oracle_response += request.weights[row] * y.dot(&metric.slice(s![row, .., ..]).dot(&y));
1918 }
1919 assert_array2_close(gram, &oracle_gram, 2.0e-12);
1920 assert_array1_close(cross, &oracle_cross, 2.0e-12);
1921 let zero_coefficients = Array2::<f64>::zeros((k, d));
1922 let direct_response_quadratic = prepared
1923 .profiled_deviance(&zero_coefficients)
1924 .expect("direct zero-fit quadratic");
1925 assert_close(direct_response_quadratic, oracle_response, 2.0e-12);
1926 }
1927
1928 #[test]
1929 fn parametric_fit_is_certified_serializable_and_predicts_in_core() {
1930 let design = array![[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
1931 let response = array![[0.2, -0.1], [0.9, 0.4], [2.1, 0.8], [2.8, 1.4]];
1932 let request = SharedTangentRemlRequest::from_dense(
1933 design.clone(),
1934 response,
1935 Array1::ones(4),
1936 None,
1937 Vec::new(),
1938 );
1939 let fit = fit_shared_tangent_reml(request).expect("certified parametric fit");
1940 assert!(fit.outer_certificate.certifies());
1941 let prediction = fit.predict_dense(design).expect("core prediction");
1942 assert_array2_close(&prediction, &fit.fitted, 1.0e-12);
1943 let encoded = serde_json::to_string(&fit).expect("serialize fit");
1944 let decoded: SharedTangentRemlFit =
1945 serde_json::from_str(&encoded).expect("deserialize fit");
1946 assert_array2_close(&decoded.coefficients, &fit.coefficients, 0.0);
1947 assert!(decoded.outer_certificate.certifies());
1948 }
1949
1950 fn assert_close(left: f64, right: f64, tolerance: f64) {
1951 let scale = left.abs().max(right.abs()).max(1.0);
1952 assert!(
1953 (left - right).abs() <= tolerance * scale,
1954 "{left} != {right} within relative tolerance {tolerance}"
1955 );
1956 }
1957
1958 fn assert_array1_close(left: &Array1<f64>, right: &Array1<f64>, tolerance: f64) {
1959 assert_eq!(left.len(), right.len());
1960 for (left, right) in left.iter().zip(right.iter()) {
1961 assert_close(*left, *right, tolerance);
1962 }
1963 }
1964
1965 fn assert_array2_close(left: &Array2<f64>, right: &Array2<f64>, tolerance: f64) {
1966 assert_eq!(left.dim(), right.dim());
1967 for (left, right) in left.iter().zip(right.iter()) {
1968 assert_close(*left, *right, tolerance);
1969 }
1970 }
1971}