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};
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}
438
439#[derive(Debug)]
440struct PenaltySpectrum {
441 rank: usize,
442 log_pseudo_determinant: f64,
443 pseudo_inverse: Array2<f64>,
444}
445
446pub fn fit_shared_tangent_reml(
448 mut request: SharedTangentRemlRequest,
449) -> Result<SharedTangentRemlFit, EstimationError> {
450 let requested_penalty_count = request.penalties.len();
451 let initial_log_lambdas = request.initial_log_lambdas.take();
452 let prepared = PreparedSharedTangent::from_request(request)?;
453 let n_outer = prepared.penalties.len();
454 if let Some(initial) = initial_log_lambdas.as_ref() {
455 if initial.len() != requested_penalty_count {
456 return Err(invalid(format!(
457 "initial_log_lambdas has length {}, expected {}",
458 initial.len(),
459 requested_penalty_count
460 )));
461 }
462 if initial.iter().any(|value| !value.is_finite()) {
463 return Err(invalid("initial_log_lambdas must be finite"));
464 }
465 }
466 let (rho, outer_iterations, certificate) = if n_outer == 0 {
467 (
472 Array1::<f64>::zeros(0),
473 0,
474 OuterCriterionCertificate {
475 stationarity:
476 gam_solve::rho_optimizer::OuterStationarityCertificate::AnalyticGradient {
477 grad_norm: 0.0,
478 projected_grad_norm: 0.0,
479 bound: 0.0,
480 },
481 hessian_psd: Some(true),
482 lambdas_railed: Vec::new(),
483 curvature_floor: None,
484 },
485 )
486 } else {
487 let mut problem = OuterProblem::new(n_outer)
488 .with_gradient(Derivative::Analytic)
489 .with_hessian(DeclaredHessianForm::Dense)
490 .with_disable_fixed_point(true)
491 .with_objective_scale(Some(
492 prepared
493 .effective_observations
494 .checked_mul(prepared.n_outputs)
495 .ok_or_else(|| invalid("effective observation count overflow"))?
496 as f64,
497 ));
498 if let Some(initial) = initial_log_lambdas.as_ref() {
499 problem = problem.with_initial_rho(Array1::from_iter(
500 prepared
501 .penalties
502 .iter()
503 .map(|penalty| initial[penalty.output_slot]),
504 ));
505 }
506 let mut objective = SharedTangentObjective {
507 prepared: &prepared,
508 };
509 let outer = problem.run(&mut objective, FIT_CONTEXT)?;
510 let certificate = outer
511 .criterion_certificate
512 .clone()
513 .filter(OuterCriterionCertificate::certifies)
514 .ok_or_else(|| EstimationError::RemlDidNotConverge {
515 context: FIT_CONTEXT.to_string(),
516 reason: "outer runner returned without a valid analytic certificate".to_string(),
517 iterations: outer.iterations,
518 final_value: outer.final_value,
519 projected_grad_norm: outer
520 .criterion_certificate
521 .as_ref()
522 .map(|value| value.stationarity.projected_norm()),
523 stationarity_bound: outer
524 .criterion_certificate
525 .as_ref()
526 .map_or(0.0, |value| value.stationarity.bound()),
527 stationarity_bound_rung: None,
530 rho_checkpoint: outer.rho.to_vec(),
531 })?;
532 (outer.rho, outer.iterations, certificate)
533 };
534
535 let evaluation = prepared.evaluate(&rho)?;
536 let fitted = predict_from_coefficients(&prepared.design, &evaluation.coefficients)?;
537 let mut lambdas = Array1::<f64>::zeros(prepared.output_penalty_slots);
538 let mut edf_by_penalty = Array1::<f64>::zeros(prepared.output_penalty_slots);
539 for (active_index, penalty) in prepared.penalties.iter().enumerate() {
540 lambdas[penalty.output_slot] = evaluation.lambdas[active_index];
541 let upper = (penalty.rank * prepared.n_outputs) as f64;
542 let raw = upper - evaluation.penalty_traces[active_index];
543 edf_by_penalty[penalty.output_slot] =
544 bounded_roundoff_value(raw, 0.0, upper, "per-penalty effective degrees of freedom")?;
545 }
546 let total_coefficients = prepared
547 .n_coefficients
548 .checked_mul(prepared.n_outputs)
549 .ok_or_else(|| invalid("coefficient dimension overflow"))?
550 as f64;
551 let edf_total = bounded_roundoff_value(
552 total_coefficients - evaluation.penalty_traces.sum(),
553 0.0,
554 total_coefficients,
555 "total effective degrees of freedom",
556 )?;
557 let effective_joint_rows = prepared
558 .effective_observations
559 .checked_mul(prepared.n_outputs)
560 .ok_or_else(|| invalid("effective joint row count overflow"))?
561 as f64;
562 let residual_df = effective_joint_rows - edf_total;
563 if !(residual_df.is_finite() && residual_df > 0.0) {
564 return Err(invalid(format!(
565 "residual scale requires positive n*D-edf; got {effective_joint_rows} - {edf_total} = {residual_df}"
566 )));
567 }
568
569 Ok(SharedTangentRemlFit {
570 coefficients: evaluation.coefficients,
571 fitted,
572 sigma2: evaluation.profiled_deviance / residual_df,
573 lambdas,
574 edf_by_penalty,
575 edf_total,
576 reml_score: evaluation.cost,
577 n_observations: prepared.n_observations,
578 n_outputs: prepared.n_outputs,
579 outer_iterations,
580 outer_certificate: certificate,
581 })
582}
583
584struct SharedTangentObjective<'a> {
585 prepared: &'a PreparedSharedTangent,
586}
587
588impl OuterObjective for SharedTangentObjective<'_> {
589 fn capability(&self) -> OuterCapability {
590 OuterCapability {
591 gradient: Derivative::Analytic,
592 hessian: DeclaredHessianForm::Dense,
593 n_params: self.prepared.penalties.len(),
594 psi_dim: 0,
595 fixed_point_available: false,
596 barrier_config: None,
597 prefer_gradient_only: false,
598 disable_fixed_point: true,
599 }
600 }
601
602 fn eval_cost(&mut self, rho: &Array1<f64>) -> Result<f64, EstimationError> {
603 self.prepared
604 .evaluate(rho)
605 .map(|evaluation| evaluation.cost)
606 }
607
608 fn eval(&mut self, rho: &Array1<f64>) -> Result<OuterEval, EstimationError> {
609 let evaluation = self.prepared.evaluate(rho)?;
610 Ok(OuterEval {
611 cost: evaluation.cost,
612 gradient: evaluation.gradient,
613 hessian: HessianValue::Dense(evaluation.hessian),
614 inner_beta_hint: None,
615 })
616 }
617
618 fn reset(&mut self) {}
619
620 fn seed_inner_state(&mut self, beta: &Array1<f64>) -> Result<SeedOutcome, EstimationError> {
621 if beta.iter().any(|value| !value.is_finite()) {
624 return Err(invalid(
625 "seed_inner_state received a non-finite β warm-start vector",
626 ));
627 }
628 Ok(SeedOutcome::NoSlot)
629 }
630}
631
632impl PreparedSharedTangent {
633 fn from_request(request: SharedTangentRemlRequest) -> Result<Self, EstimationError> {
634 let SharedTangentRemlRequest {
635 design,
636 response,
637 weights,
638 fisher_metric,
639 penalties: requested_penalties,
640 initial_log_lambdas: _,
641 } = request;
642 let n = design.nrows();
643 let k = design.ncols();
644 let (response_rows, d) = response.dim();
645 if n == 0 || k == 0 || d == 0 {
646 return Err(invalid(format!(
647 "shared-tangent REML requires non-empty dimensions; got N={n}, K={k}, D={d}"
648 )));
649 }
650 if response_rows != n {
651 return Err(invalid(format!(
652 "response rows {response_rows} do not match design rows {n}"
653 )));
654 }
655 if weights.len() != n {
656 return Err(invalid(format!(
657 "weight length {} does not match design rows {n}",
658 weights.len()
659 )));
660 }
661 if response.iter().any(|value| !value.is_finite()) {
662 return Err(invalid("response must contain only finite values"));
663 }
664 if weights
665 .iter()
666 .any(|value| !value.is_finite() || *value < 0.0)
667 {
668 return Err(invalid("weights must be finite and non-negative"));
669 }
670 let effective_observations = weights.iter().filter(|value| **value > 0.0).count();
671 if effective_observations == 0 {
672 return Err(invalid(
673 "at least one observation must have positive weight",
674 ));
675 }
676 if let Some(metric) = fisher_metric.as_ref()
677 && metric.dim() != (n, d, d)
678 {
679 return Err(invalid(format!(
680 "fisher_metric shape {:?} does not match ({n}, {d}, {d})",
681 metric.dim()
682 )));
683 }
684 let fisher_metric = if let Some(metric) = fisher_metric {
685 let mut validated = Array3::<f64>::zeros(metric.dim());
686 for row in 0..n {
687 let row_metric = validated_metric(metric.slice(s![row, .., ..]).to_owned(), row)?;
688 validated.slice_mut(s![row, .., ..]).assign(&row_metric);
689 }
690 Some(validated)
691 } else {
692 None
693 };
694
695 let penalties = prepare_penalties(&requested_penalties, k)?;
696 let output_penalty_slots = requested_penalties.len();
697 let statistics = match fisher_metric.as_ref() {
698 None => assemble_isotropic_statistics(&design, &response, &weights)?,
699 Some(metric) => assemble_fisher_statistics(&design, &response, &weights, metric)?,
700 };
701
702 Ok(Self {
703 design,
704 response,
705 weights,
706 fisher_metric,
707 n_observations: n,
708 n_coefficients: k,
709 n_outputs: d,
710 effective_observations,
711 output_penalty_slots,
712 penalties,
713 statistics,
714 })
715 }
716
717 fn evaluate(&self, rho: &Array1<f64>) -> Result<Evaluation, EstimationError> {
718 if rho.len() != self.penalties.len() {
719 return Err(invalid(format!(
720 "log-lambda length {} does not match active penalty count {}",
721 rho.len(),
722 self.penalties.len()
723 )));
724 }
725 gam_problem::validate_log_strengths(rho.iter().copied())
726 .map_err(|error| invalid(format!("shared-tangent rho: {error}")))?;
727 match &self.statistics {
728 SufficientStatistics::Isotropic { gram, cross } => {
729 self.evaluate_isotropic(rho, gram, cross)
730 }
731 SufficientStatistics::Fisher { gram, cross } => self.evaluate_fisher(rho, gram, cross),
732 }
733 }
734
735 fn evaluate_isotropic(
736 &self,
737 rho: &Array1<f64>,
738 gram: &Array2<f64>,
739 cross: &Array2<f64>,
740 ) -> Result<Evaluation, EstimationError> {
741 let d = self.n_outputs;
742 let (penalty, lambdas) = self.combined_penalty(rho)?;
743 let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
744 let mut penalized = gram.clone();
745 penalized += &penalty;
746 let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
747 let coefficients = inverse.dot(cross);
748 let profiled_deviance = self.profiled_deviance(&coefficients)?;
749 let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
750 validate_profiled_deviance(profiled_deviance)?;
751
752 let m = self.penalties.len();
753 let mut penalty_traces = Array1::<f64>::zeros(m);
754 let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
755 let mut deviance_first = Array1::<f64>::zeros(m);
756 let mut penalty_beta = Vec::with_capacity(m);
757 for (index, penalty_block) in self.penalties.iter().enumerate() {
758 penalty_traces[index] =
759 d as f64 * trace_local_base(&inverse, penalty_block, lambdas[index]);
760 penalty_logdet_traces[index] = d as f64
761 * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
762 let z = apply_local_base_matrix(penalty_block, lambdas[index], &coefficients);
763 deviance_first[index] = sum_products(&coefficients, &z);
764 penalty_beta.push(z);
765 }
766
767 let mut gradient = Array1::<f64>::zeros(m);
768 for j in 0..m {
769 gradient[j] = 0.5
770 * (penalty_traces[j] - penalty_logdet_traces[j]
771 + residual_degrees_of_freedom * deviance_first[j] / profiled_deviance);
772 }
773 let mut hessian = Array2::<f64>::zeros((m, m));
774 for j in 0..m {
775 let h_sandwich = sandwich_local_base(&inverse, &self.penalties[j], lambdas[j]);
776 let p_sandwich =
777 sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
778 for kk in 0..=j {
779 let h_cross = d as f64
780 * trace_sandwich_local_base(&h_sandwich, &self.penalties[kk], lambdas[kk]);
781 let p_cross = d as f64
782 * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
783 let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
784 let deviance_cross = sum_products(&penalty_beta[j], &solved_penalty_beta);
785 let delta = usize::from(j == kk) as f64;
786 let logdet_second = delta * penalty_traces[j] - h_cross;
787 let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
788 let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
789 let value = 0.5
790 * (logdet_second - penalty_logdet_second
791 + residual_degrees_of_freedom
792 * (deviance_second / profiled_deviance
793 - deviance_first[j] * deviance_first[kk]
794 / (profiled_deviance * profiled_deviance)));
795 hessian[[j, kk]] = value;
796 hessian[[kk, j]] = value;
797 }
798 }
799 let cost = 0.5
800 * (d as f64 * log_determinant - d as f64 * spectrum.log_pseudo_determinant
801 + residual_degrees_of_freedom
802 * (1.0
803 + (2.0 * std::f64::consts::PI * profiled_deviance
804 / residual_degrees_of_freedom)
805 .ln()));
806 validate_evaluation(cost, &gradient, &hessian)?;
807 Ok(Evaluation {
808 cost,
809 gradient,
810 hessian,
811 coefficients,
812 profiled_deviance,
813 penalty_traces,
814 lambdas,
815 })
816 }
817
818 fn evaluate_fisher(
819 &self,
820 rho: &Array1<f64>,
821 gram: &Array2<f64>,
822 cross: &Array1<f64>,
823 ) -> Result<Evaluation, EstimationError> {
824 let k = self.n_coefficients;
825 let d = self.n_outputs;
826 let q = k
827 .checked_mul(d)
828 .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
829 let (penalty, lambdas) = self.combined_penalty(rho)?;
830 let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
831 let mut penalized = gram.clone();
832 add_base_penalty_to_joint(&mut penalized, &penalty, d);
833 let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
834 let beta = inverse.dot(cross);
835 let mut coefficients = Array2::<f64>::zeros((k, d));
836 for basis in 0..k {
837 for output in 0..d {
838 coefficients[[basis, output]] = beta[basis * d + output];
839 }
840 }
841 let profiled_deviance = self.profiled_deviance(&coefficients)?;
842 validate_profiled_deviance(profiled_deviance)?;
843 let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
844
845 let m = self.penalties.len();
846 let mut penalty_traces = Array1::<f64>::zeros(m);
847 let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
848 let mut deviance_first = Array1::<f64>::zeros(m);
849 let mut penalty_beta = Vec::with_capacity(m);
850 for (index, penalty_block) in self.penalties.iter().enumerate() {
851 penalty_traces[index] = trace_local_joint(&inverse, penalty_block, lambdas[index], d);
852 penalty_logdet_traces[index] = d as f64
853 * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
854 let z = apply_local_joint_vector(penalty_block, lambdas[index], d, &beta);
855 deviance_first[index] = beta.dot(&z);
856 penalty_beta.push(z);
857 }
858
859 let mut gradient = Array1::<f64>::zeros(m);
860 for j in 0..m {
861 gradient[j] = 0.5
862 * (penalty_traces[j] - penalty_logdet_traces[j]
863 + residual_degrees_of_freedom * deviance_first[j] / profiled_deviance);
864 }
865 let mut hessian = Array2::<f64>::zeros((m, m));
866 for j in 0..m {
867 let h_sandwich = sandwich_local_joint(&inverse, &self.penalties[j], lambdas[j], d);
868 let p_sandwich =
869 sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
870 for kk in 0..=j {
871 let h_cross =
872 trace_sandwich_local_joint(&h_sandwich, &self.penalties[kk], lambdas[kk], d);
873 let p_cross = d as f64
874 * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
875 let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
876 let deviance_cross = penalty_beta[j].dot(&solved_penalty_beta);
877 let delta = usize::from(j == kk) as f64;
878 let logdet_second = delta * penalty_traces[j] - h_cross;
879 let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
880 let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
881 let value = 0.5
882 * (logdet_second - penalty_logdet_second
883 + residual_degrees_of_freedom
884 * (deviance_second / profiled_deviance
885 - deviance_first[j] * deviance_first[kk]
886 / (profiled_deviance * profiled_deviance)));
887 hessian[[j, kk]] = value;
888 hessian[[kk, j]] = value;
889 }
890 }
891 let cost = 0.5
892 * (log_determinant - d as f64 * spectrum.log_pseudo_determinant
893 + residual_degrees_of_freedom
894 * (1.0
895 + (2.0 * std::f64::consts::PI * profiled_deviance
896 / residual_degrees_of_freedom)
897 .ln()));
898 if inverse.dim() != (q, q) {
899 return Err(invalid("internal Fisher inverse shape mismatch"));
900 }
901 validate_evaluation(cost, &gradient, &hessian)?;
902 Ok(Evaluation {
903 cost,
904 gradient,
905 hessian,
906 coefficients,
907 profiled_deviance,
908 penalty_traces,
909 lambdas,
910 })
911 }
912
913 fn profiled_deviance(&self, coefficients: &Array2<f64>) -> Result<f64, EstimationError> {
919 let n = self.design.nrows();
920 let k = self.design.ncols();
921 let d = self.response.ncols();
922 if coefficients.dim() != (k, d) {
923 return Err(invalid(format!(
924 "shared-tangent coefficient shape {:?} does not match ({k}, {d})",
925 coefficients.dim()
926 )));
927 }
928 let mut quadratic = KahanSum::default();
929 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
930 for start in (0..n).step_by(chunk_rows) {
931 let end = (start + chunk_rows).min(n);
932 let x_chunk = self
933 .design
934 .try_row_chunk(start..end)
935 .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
936 validate_design_chunk(&x_chunk)?;
937 let fitted = x_chunk.dot(coefficients);
938 for local_row in 0..x_chunk.nrows() {
939 let row = start + local_row;
940 let weight = self.weights[row];
941 if weight == 0.0 {
942 continue;
943 }
944 if let Some(metric) = self.fisher_metric.as_ref() {
945 for output_a in 0..d {
946 let residual_a =
947 self.response[[row, output_a]] - fitted[[local_row, output_a]];
948 for output_b in 0..d {
949 let residual_b =
950 self.response[[row, output_b]] - fitted[[local_row, output_b]];
951 quadratic.add(
952 weight
953 * residual_a
954 * metric[[row, output_a, output_b]]
955 * residual_b,
956 );
957 }
958 }
959 } else {
960 for output in 0..d {
961 let residual = self.response[[row, output]] - fitted[[local_row, output]];
962 quadratic.add(weight * residual * residual);
963 }
964 }
965 }
966 }
967 Ok(quadratic.sum())
968 }
969
970 fn combined_penalty(
971 &self,
972 rho: &Array1<f64>,
973 ) -> Result<(Array2<f64>, Array1<f64>), EstimationError> {
974 let mut combined = Array2::<f64>::zeros((self.n_coefficients, self.n_coefficients));
975 let lambdas = Array1::from_vec(
976 gam_problem::checked_exp_log_strengths(rho.iter().copied())
977 .map_err(|error| invalid(format!("shared-tangent rho: {error}")))?,
978 );
979 for (index, penalty) in self.penalties.iter().enumerate() {
980 let lambda = lambdas[index];
981 for local_row in 0..penalty.local.nrows() {
982 for local_col in 0..penalty.local.ncols() {
983 combined[[
984 penalty.column_start + local_row,
985 penalty.column_start + local_col,
986 ]] += lambda * penalty.local[[local_row, local_col]];
987 }
988 }
989 }
990 Ok((combined, lambdas))
991 }
992
993 fn residual_degrees_of_freedom(
994 &self,
995 combined_penalty_rank: usize,
996 ) -> Result<f64, EstimationError> {
997 let effective_rows = self
998 .effective_observations
999 .checked_mul(self.n_outputs)
1000 .ok_or_else(|| invalid("effective joint row count overflow"))?;
1001 let base_nullity = self
1002 .n_coefficients
1003 .checked_sub(combined_penalty_rank)
1004 .ok_or_else(|| invalid("combined penalty rank exceeds coefficient dimension"))?;
1005 let joint_nullity = base_nullity
1006 .checked_mul(self.n_outputs)
1007 .ok_or_else(|| invalid("joint penalty nullity overflow"))?;
1008 if effective_rows <= joint_nullity {
1009 return Err(invalid(format!(
1010 "REML requires more effective joint rows than unpenalized coefficients; got {effective_rows} rows and nullity {joint_nullity}"
1011 )));
1012 }
1013 Ok((effective_rows - joint_nullity) as f64)
1014 }
1015}
1016
1017fn prepare_penalties(
1018 penalties: &[SharedTangentPenalty],
1019 n_coefficients: usize,
1020) -> Result<Vec<PreparedPenalty>, EstimationError> {
1021 let mut prepared = Vec::with_capacity(penalties.len());
1022 for (slot, penalty) in penalties.iter().enumerate() {
1023 let q = penalty.matrix.nrows();
1024 if q != penalty.matrix.ncols() {
1025 return Err(invalid(format!(
1026 "penalty {slot} must be square; got {}x{}",
1027 penalty.matrix.nrows(),
1028 penalty.matrix.ncols()
1029 )));
1030 }
1031 let end = penalty
1032 .column_start
1033 .checked_add(q)
1034 .ok_or_else(|| invalid(format!("penalty {slot} column range overflow")))?;
1035 if end > n_coefficients {
1036 return Err(invalid(format!(
1037 "penalty {slot} column range {}..{end} exceeds design width {n_coefficients}",
1038 penalty.column_start
1039 )));
1040 }
1041 if penalty.matrix.iter().any(|value| !value.is_finite()) {
1042 return Err(invalid(format!(
1043 "penalty {slot} contains non-finite values"
1044 )));
1045 }
1046 let local = symmetric_average(&penalty.matrix);
1047 let spectrum = penalty_spectrum(&local, &format!("shared-tangent penalty {slot}"))?;
1048 if spectrum.rank == 0 {
1049 continue;
1050 }
1051 prepared.push(PreparedPenalty {
1052 output_slot: slot,
1053 column_start: penalty.column_start,
1054 local,
1055 rank: spectrum.rank,
1056 });
1057 }
1058 Ok(prepared)
1059}
1060
1061fn assemble_isotropic_statistics(
1062 design: &DesignMatrix,
1063 response: &Array2<f64>,
1064 weights: &Array1<f64>,
1065) -> Result<SufficientStatistics, EstimationError> {
1066 let n = design.nrows();
1067 let k = design.ncols();
1068 let d = response.ncols();
1069 let mut gram = Array2::<f64>::zeros((k, k));
1070 let mut cross = Array2::<f64>::zeros((k, d));
1071 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1072 for start in (0..n).step_by(chunk_rows) {
1073 let end = (start + chunk_rows).min(n);
1074 let x_chunk = design
1075 .try_row_chunk(start..end)
1076 .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1077 validate_design_chunk(&x_chunk)?;
1078 let weight_chunk = weights.slice(s![start..end]);
1079 let response_chunk = response.slice(s![start..end, ..]);
1080 gram += &fast_xt_diag_x(&x_chunk, &weight_chunk);
1081 cross += &fast_xt_diag_y(&x_chunk, &weight_chunk, &response_chunk);
1082 }
1083 Ok(SufficientStatistics::Isotropic { gram, cross })
1084}
1085
1086fn assemble_fisher_statistics(
1087 design: &DesignMatrix,
1088 response: &Array2<f64>,
1089 weights: &Array1<f64>,
1090 fisher_metric: &Array3<f64>,
1091) -> Result<SufficientStatistics, EstimationError> {
1092 let n = design.nrows();
1093 let k = design.ncols();
1094 let d = response.ncols();
1095 let q = k
1096 .checked_mul(d)
1097 .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
1098 let mut gram = Array2::<f64>::zeros((q, q));
1099 let mut cross = Array1::<f64>::zeros(q);
1100 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1101 for start in (0..n).step_by(chunk_rows) {
1102 let end = (start + chunk_rows).min(n);
1103 let x_chunk = design
1104 .try_row_chunk(start..end)
1105 .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1106 validate_design_chunk(&x_chunk)?;
1107 for local_row in 0..x_chunk.nrows() {
1108 let row = start + local_row;
1109 let metric = fisher_metric.slice(s![row, .., ..]);
1110 let y = response.row(row);
1111 let metric_y = metric.dot(&y);
1112 let weight = weights[row];
1113 for basis_a in 0..k {
1114 let x_a = x_chunk[[local_row, basis_a]];
1115 for output in 0..d {
1116 cross[basis_a * d + output] += weight * x_a * metric_y[output];
1117 }
1118 for basis_b in 0..k {
1119 let scale = weight * x_a * x_chunk[[local_row, basis_b]];
1120 if scale == 0.0 {
1121 continue;
1122 }
1123 for output_a in 0..d {
1124 for output_b in 0..d {
1125 gram[[basis_a * d + output_a, basis_b * d + output_b]] +=
1126 scale * metric[[output_a, output_b]];
1127 }
1128 }
1129 }
1130 }
1131 }
1132 }
1133 Ok(SufficientStatistics::Fisher { gram, cross })
1134}
1135
1136fn validated_metric(mut metric: Array2<f64>, row: usize) -> Result<Array2<f64>, EstimationError> {
1137 if metric.iter().any(|value| !value.is_finite()) {
1138 return Err(invalid(format!(
1139 "fisher_metric row {row} contains non-finite values"
1140 )));
1141 }
1142 let scale = metric
1143 .iter()
1144 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1145 let tolerance = f64::EPSILON.sqrt() * metric.nrows().max(1) as f64 * scale;
1146 for a in 0..metric.nrows() {
1147 for b in (a + 1)..metric.ncols() {
1148 if (metric[[a, b]] - metric[[b, a]]).abs() > tolerance {
1149 return Err(invalid(format!(
1150 "fisher_metric row {row} is not symmetric at ({a}, {b})"
1151 )));
1152 }
1153 let average = 0.5 * (metric[[a, b]] + metric[[b, a]]);
1154 metric[[a, b]] = average;
1155 metric[[b, a]] = average;
1156 }
1157 }
1158 metric.cholesky(Side::Lower).map_err(|error| {
1159 invalid(format!(
1160 "fisher_metric row {row} must be positive definite: {error}"
1161 ))
1162 })?;
1163 Ok(metric)
1164}
1165
1166fn penalty_spectrum(
1167 penalty: &Array2<f64>,
1168 context: &str,
1169) -> Result<PenaltySpectrum, EstimationError> {
1170 let (eigenvalues, eigenvectors) = penalty
1171 .eigh(Side::Lower)
1172 .map_err(EstimationError::EigendecompositionFailed)?;
1173 let scale = eigenvalues
1174 .iter()
1175 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1176 let tolerance = f64::EPSILON.sqrt() * eigenvalues.len().max(1) as f64 * scale;
1177 let mut rank = 0usize;
1178 let mut log_pseudo_determinant = 0.0;
1179 let mut pseudo_inverse = Array2::<f64>::zeros(penalty.dim());
1180 for (index, &value) in eigenvalues.iter().enumerate() {
1181 if !value.is_finite() {
1182 return Err(EstimationError::PenaltySpectrumNonFinite {
1183 context: context.to_string(),
1184 index,
1185 value,
1186 });
1187 }
1188 if value < -tolerance {
1189 return Err(EstimationError::PenaltySpectrumIndefinite {
1190 context: context.to_string(),
1191 index,
1192 value,
1193 tolerance,
1194 scale,
1195 });
1196 }
1197 if value <= tolerance {
1198 continue;
1199 }
1200 rank += 1;
1201 log_pseudo_determinant += value.ln();
1202 for row in 0..penalty.nrows() {
1203 for col in 0..penalty.ncols() {
1204 pseudo_inverse[[row, col]] +=
1205 eigenvectors[[row, index]] * eigenvectors[[col, index]] / value;
1206 }
1207 }
1208 }
1209 Ok(PenaltySpectrum {
1210 rank,
1211 log_pseudo_determinant,
1212 pseudo_inverse,
1213 })
1214}
1215
1216fn spd_inverse_and_logdet(matrix: &Array2<f64>) -> Result<(Array2<f64>, f64), EstimationError> {
1217 let factor =
1218 matrix
1219 .cholesky(Side::Lower)
1220 .map_err(|_| EstimationError::ModelIsIllConditioned {
1221 condition_number: f64::INFINITY,
1222 })?;
1223 let diagonal = factor.diag();
1224 if diagonal
1225 .iter()
1226 .any(|value| !value.is_finite() || *value <= 0.0)
1227 {
1228 return Err(EstimationError::ModelIsIllConditioned {
1229 condition_number: f64::INFINITY,
1230 });
1231 }
1232 let log_determinant = 2.0 * diagonal.iter().map(|value| value.ln()).sum::<f64>();
1233 let identity = Array2::<f64>::eye(matrix.nrows());
1234 let inverse = factor.solve_mat(&identity);
1235 if !log_determinant.is_finite() || inverse.iter().any(|value| !value.is_finite()) {
1236 return Err(EstimationError::ModelIsIllConditioned {
1237 condition_number: f64::INFINITY,
1238 });
1239 }
1240 Ok((inverse, log_determinant))
1241}
1242
1243fn trace_local_base(inverse: &Array2<f64>, penalty: &PreparedPenalty, lambda: f64) -> f64 {
1244 let mut trace = 0.0;
1245 for row in 0..penalty.local.nrows() {
1246 for col in 0..penalty.local.ncols() {
1247 trace += lambda
1248 * penalty.local[[row, col]]
1249 * inverse[[penalty.column_start + col, penalty.column_start + row]];
1250 }
1251 }
1252 trace
1253}
1254
1255fn trace_local_joint(
1256 inverse: &Array2<f64>,
1257 penalty: &PreparedPenalty,
1258 lambda: f64,
1259 n_outputs: usize,
1260) -> f64 {
1261 let mut trace = 0.0;
1262 for output in 0..n_outputs {
1263 for row in 0..penalty.local.nrows() {
1264 for col in 0..penalty.local.ncols() {
1265 trace += lambda
1266 * penalty.local[[row, col]]
1267 * inverse[[
1268 (penalty.column_start + col) * n_outputs + output,
1269 (penalty.column_start + row) * n_outputs + output,
1270 ]];
1271 }
1272 }
1273 }
1274 trace
1275}
1276
1277fn apply_local_base_matrix(
1278 penalty: &PreparedPenalty,
1279 lambda: f64,
1280 matrix: &Array2<f64>,
1281) -> Array2<f64> {
1282 let mut output = Array2::<f64>::zeros(matrix.dim());
1283 for row in 0..penalty.local.nrows() {
1284 for col in 0..penalty.local.ncols() {
1285 let value = lambda * penalty.local[[row, col]];
1286 for output_index in 0..matrix.ncols() {
1287 output[[penalty.column_start + row, output_index]] +=
1288 value * matrix[[penalty.column_start + col, output_index]];
1289 }
1290 }
1291 }
1292 output
1293}
1294
1295fn apply_local_joint_vector(
1296 penalty: &PreparedPenalty,
1297 lambda: f64,
1298 n_outputs: usize,
1299 vector: &Array1<f64>,
1300) -> Array1<f64> {
1301 let mut output = Array1::<f64>::zeros(vector.len());
1302 for output_index in 0..n_outputs {
1303 for row in 0..penalty.local.nrows() {
1304 for col in 0..penalty.local.ncols() {
1305 output[(penalty.column_start + row) * n_outputs + output_index] += lambda
1306 * penalty.local[[row, col]]
1307 * vector[(penalty.column_start + col) * n_outputs + output_index];
1308 }
1309 }
1310 }
1311 output
1312}
1313
1314fn sandwich_local_base(
1315 inverse: &Array2<f64>,
1316 penalty: &PreparedPenalty,
1317 lambda: f64,
1318) -> Array2<f64> {
1319 let dimension = inverse.nrows();
1320 let mut result = Array2::<f64>::zeros((dimension, dimension));
1321 for local_row in 0..penalty.local.nrows() {
1322 let global_row = penalty.column_start + local_row;
1323 for local_col in 0..penalty.local.ncols() {
1324 let value = lambda * penalty.local[[local_row, local_col]];
1325 if value == 0.0 {
1326 continue;
1327 }
1328 let global_col = penalty.column_start + local_col;
1329 for row in 0..dimension {
1330 let left = inverse[[row, global_row]] * value;
1331 for col in 0..dimension {
1332 result[[row, col]] += left * inverse[[global_col, col]];
1333 }
1334 }
1335 }
1336 }
1337 result
1338}
1339
1340fn sandwich_local_joint(
1341 inverse: &Array2<f64>,
1342 penalty: &PreparedPenalty,
1343 lambda: f64,
1344 n_outputs: usize,
1345) -> Array2<f64> {
1346 let dimension = inverse.nrows();
1347 let mut result = Array2::<f64>::zeros((dimension, dimension));
1348 for output in 0..n_outputs {
1349 for local_row in 0..penalty.local.nrows() {
1350 let global_row = (penalty.column_start + local_row) * n_outputs + output;
1351 for local_col in 0..penalty.local.ncols() {
1352 let value = lambda * penalty.local[[local_row, local_col]];
1353 if value == 0.0 {
1354 continue;
1355 }
1356 let global_col = (penalty.column_start + local_col) * n_outputs + output;
1357 for row in 0..dimension {
1358 let left = inverse[[row, global_row]] * value;
1359 for col in 0..dimension {
1360 result[[row, col]] += left * inverse[[global_col, col]];
1361 }
1362 }
1363 }
1364 }
1365 }
1366 result
1367}
1368
1369fn trace_sandwich_local_base(
1370 sandwich: &Array2<f64>,
1371 penalty: &PreparedPenalty,
1372 lambda: f64,
1373) -> f64 {
1374 let mut trace = 0.0;
1375 for row in 0..penalty.local.nrows() {
1376 for col in 0..penalty.local.ncols() {
1377 trace += lambda
1378 * penalty.local[[row, col]]
1379 * sandwich[[penalty.column_start + col, penalty.column_start + row]];
1380 }
1381 }
1382 trace
1383}
1384
1385fn trace_sandwich_local_joint(
1386 sandwich: &Array2<f64>,
1387 penalty: &PreparedPenalty,
1388 lambda: f64,
1389 n_outputs: usize,
1390) -> f64 {
1391 let mut trace = 0.0;
1392 for output in 0..n_outputs {
1393 for row in 0..penalty.local.nrows() {
1394 for col in 0..penalty.local.ncols() {
1395 trace += lambda
1396 * penalty.local[[row, col]]
1397 * sandwich[[
1398 (penalty.column_start + col) * n_outputs + output,
1399 (penalty.column_start + row) * n_outputs + output,
1400 ]];
1401 }
1402 }
1403 }
1404 trace
1405}
1406
1407fn add_base_penalty_to_joint(joint: &mut Array2<f64>, penalty: &Array2<f64>, n_outputs: usize) {
1408 for row in 0..penalty.nrows() {
1409 for col in 0..penalty.ncols() {
1410 let value = penalty[[row, col]];
1411 for output in 0..n_outputs {
1412 joint[[row * n_outputs + output, col * n_outputs + output]] += value;
1413 }
1414 }
1415 }
1416}
1417
1418fn symmetric_average(matrix: &Array2<f64>) -> Array2<f64> {
1419 let mut output = matrix.clone();
1420 for row in 0..matrix.nrows() {
1421 for col in (row + 1)..matrix.ncols() {
1422 let average = 0.5 * (matrix[[row, col]] + matrix[[col, row]]);
1423 output[[row, col]] = average;
1424 output[[col, row]] = average;
1425 }
1426 }
1427 output
1428}
1429
1430fn predict_from_coefficients(
1431 design: &DesignMatrix,
1432 coefficients: &Array2<f64>,
1433) -> Result<Array2<f64>, EstimationError> {
1434 if design.ncols() != coefficients.nrows() {
1435 return Err(invalid(format!(
1436 "prediction design width {} does not match coefficient rows {}",
1437 design.ncols(),
1438 coefficients.nrows()
1439 )));
1440 }
1441 let mut prediction = Array2::<f64>::zeros((design.nrows(), coefficients.ncols()));
1442 for output in 0..coefficients.ncols() {
1443 let values = design.apply(&coefficients.column(output).to_owned());
1444 prediction.column_mut(output).assign(&values);
1445 }
1446 if prediction.iter().any(|value| !value.is_finite()) {
1447 return Err(invalid("prediction produced non-finite values"));
1448 }
1449 Ok(prediction)
1450}
1451
1452fn sum_products(left: &Array2<f64>, right: &Array2<f64>) -> f64 {
1453 left.iter()
1454 .zip(right.iter())
1455 .map(|(left, right)| left * right)
1456 .sum()
1457}
1458
1459fn validate_design_chunk(chunk: &Array2<f64>) -> Result<(), EstimationError> {
1460 if chunk.iter().any(|value| !value.is_finite()) {
1461 return Err(invalid("design contains non-finite values"));
1462 }
1463 Ok(())
1464}
1465
1466fn validate_profiled_deviance(value: f64) -> Result<(), EstimationError> {
1467 if !value.is_finite() || value <= 0.0 {
1468 return Err(EstimationError::RemlOptimizationFailed(format!(
1469 "{FIT_CONTEXT}: profiled penalized deviance must be finite and positive, got {value}"
1470 )));
1471 }
1472 Ok(())
1473}
1474
1475fn validate_evaluation(
1476 cost: f64,
1477 gradient: &Array1<f64>,
1478 hessian: &Array2<f64>,
1479) -> Result<(), EstimationError> {
1480 if !cost.is_finite()
1481 || gradient.iter().any(|value| !value.is_finite())
1482 || hessian.iter().any(|value| !value.is_finite())
1483 {
1484 return Err(EstimationError::RemlOptimizationFailed(format!(
1485 "{FIT_CONTEXT}: objective evaluation produced non-finite value or derivatives"
1486 )));
1487 }
1488 Ok(())
1489}
1490
1491fn validate_archived_tangent_fit(
1492 fit: &SharedTangentRemlFit,
1493) -> Result<(), ResponseGeometryModelError> {
1494 if fit.n_observations == 0
1495 || fit.n_outputs == 0
1496 || fit.coefficients.nrows() == 0
1497 || fit.coefficients.ncols() != fit.n_outputs
1498 || fit.fitted.dim() != (fit.n_observations, fit.n_outputs)
1499 {
1500 return Err(ResponseGeometryModelError::InvalidMetadata(
1501 "shared tangent fit has inconsistent dimensions".to_string(),
1502 ));
1503 }
1504 if fit.lambdas.len() != fit.edf_by_penalty.len() {
1505 return Err(ResponseGeometryModelError::InvalidMetadata(
1506 "shared tangent lambda and EDF vectors are misaligned".to_string(),
1507 ));
1508 }
1509 if fit.coefficients.iter().any(|value| !value.is_finite())
1510 || fit.fitted.iter().any(|value| !value.is_finite())
1511 || fit
1512 .lambdas
1513 .iter()
1514 .any(|value| !value.is_finite() || *value < 0.0)
1515 || fit
1516 .edf_by_penalty
1517 .iter()
1518 .any(|value| !value.is_finite() || *value < 0.0)
1519 || !fit.sigma2.is_finite()
1520 || fit.sigma2 <= 0.0
1521 || !fit.edf_total.is_finite()
1522 || fit.edf_total < 0.0
1523 || !fit.reml_score.is_finite()
1524 {
1525 return Err(ResponseGeometryModelError::InvalidMetadata(
1526 "shared tangent fit contains invalid numerical values".to_string(),
1527 ));
1528 }
1529 if !fit.outer_certificate.certifies() {
1530 return Err(ResponseGeometryModelError::InvalidMetadata(
1531 "shared tangent fit lacks a valid convergence certificate".to_string(),
1532 ));
1533 }
1534 Ok(())
1535}
1536
1537fn bounded_roundoff_value(
1538 value: f64,
1539 lower: f64,
1540 upper: f64,
1541 context: &str,
1542) -> Result<f64, EstimationError> {
1543 let tolerance = f64::EPSILON.sqrt() * upper.abs().max(1.0);
1544 if !value.is_finite() || value < lower - tolerance || value > upper + tolerance {
1545 return Err(EstimationError::RemlOptimizationFailed(format!(
1546 "{FIT_CONTEXT}: {context} {value} lies outside [{lower}, {upper}] beyond roundoff"
1547 )));
1548 }
1549 Ok(value.clamp(lower, upper))
1550}
1551
1552fn invalid(message: impl Into<String>) -> EstimationError {
1553 EstimationError::InvalidInput(message.into())
1554}
1555
1556#[cfg(test)]
1557mod tests {
1558 use super::*;
1559 use gam_linalg::test_support::no_densify_design;
1560 use ndarray::{Array3, array};
1561
1562 fn fixture_request(fisher_metric: Option<Array3<f64>>) -> SharedTangentRemlRequest {
1563 let design = array![
1564 [1.0, -1.0, 0.5],
1565 [1.0, -0.5, -0.2],
1566 [1.0, 0.0, 0.3],
1567 [1.0, 0.5, 0.8],
1568 [1.0, 1.0, -0.4],
1569 [1.0, 1.5, 0.1]
1570 ];
1571 let response = array![
1572 [-0.7, 0.4],
1573 [-0.1, 0.1],
1574 [0.2, -0.3],
1575 [0.8, -0.2],
1576 [1.1, 0.5],
1577 [1.7, 0.2]
1578 ];
1579 let penalties = vec![
1580 SharedTangentPenalty::new(1, array![[1.0, 0.0], [0.0, 0.0]]),
1581 SharedTangentPenalty::new(1, array![[0.0, 0.0], [0.0, 1.0]]),
1582 ];
1583 SharedTangentRemlRequest::new(
1584 no_densify_design(design),
1585 response,
1586 array![1.0, 0.8, 1.2, 1.0, 0.9, 1.1],
1587 fisher_metric,
1588 penalties,
1589 )
1590 }
1591
1592 #[test]
1593 fn operator_backed_isotropic_path_matches_streamed_identity_fisher_path() {
1594 let isotropic_request = fixture_request(None);
1595 let n = isotropic_request.response.nrows();
1596 let d = isotropic_request.response.ncols();
1597 let mut identity_metric = Array3::<f64>::zeros((n, d, d));
1598 for row in 0..n {
1599 for output in 0..d {
1600 identity_metric[[row, output, output]] = 1.0;
1601 }
1602 }
1603 let fisher_request = fixture_request(Some(identity_metric));
1604 let isotropic = PreparedSharedTangent::from_request(isotropic_request)
1605 .expect("prepare isotropic without densifying");
1606 let fisher = PreparedSharedTangent::from_request(fisher_request)
1607 .expect("prepare Fisher without densifying");
1608 let rho = array![-0.4, 0.7];
1609 let left = isotropic.evaluate(&rho).expect("isotropic eval");
1610 let right = fisher.evaluate(&rho).expect("Fisher eval");
1611 assert_close(left.cost, right.cost, 2.0e-11);
1612 assert_array1_close(&left.gradient, &right.gradient, 2.0e-10);
1613 assert_array2_close(&left.hessian, &right.hessian, 2.0e-9);
1614 assert_array2_close(&left.coefficients, &right.coefficients, 2.0e-11);
1615 }
1616
1617 #[test]
1618 fn analytic_gradient_and_hessian_match_test_only_finite_differences() {
1619 let request = fixture_request(None);
1620 let prepared = PreparedSharedTangent::from_request(request).expect("prepare");
1621 let rho = array![-0.2, 0.35];
1622 let exact = prepared.evaluate(&rho).expect("exact eval");
1623 let step = f64::EPSILON.cbrt();
1624 for j in 0..rho.len() {
1625 let mut plus = rho.clone();
1626 let mut minus = rho.clone();
1627 plus[j] += step;
1628 minus[j] -= step;
1629 let plus_eval = prepared.evaluate(&plus).expect("plus eval");
1630 let minus_eval = prepared.evaluate(&minus).expect("minus eval");
1631 let gradient_fd = (plus_eval.cost - minus_eval.cost) / (2.0 * step);
1632 assert_close(exact.gradient[j], gradient_fd, 2.0e-6);
1633 for k in 0..rho.len() {
1634 let hessian_fd = (plus_eval.gradient[k] - minus_eval.gradient[k]) / (2.0 * step);
1635 assert_close(exact.hessian[[k, j]], hessian_fd, 3.0e-6);
1636 }
1637 }
1638 }
1639
1640 #[test]
1641 fn streamed_varying_fisher_statistics_match_explicit_joint_oracle() {
1642 let base = fixture_request(None);
1643 let n = base.response.nrows();
1644 let d = base.response.ncols();
1645 let mut metric = Array3::<f64>::zeros((n, d, d));
1646 for row in 0..n {
1647 let off = 0.04 * (row as f64 + 1.0);
1648 metric[[row, 0, 0]] = 1.2 + 0.1 * row as f64;
1649 metric[[row, 0, 1]] = off;
1650 metric[[row, 1, 0]] = off;
1651 metric[[row, 1, 1]] = 0.9 + 0.05 * row as f64;
1652 }
1653 let request = fixture_request(Some(metric.clone()));
1654 let prepared =
1655 PreparedSharedTangent::from_request(request.clone()).expect("prepare Fisher");
1656 let SufficientStatistics::Fisher { gram, cross } = &prepared.statistics else {
1657 panic!("expected Fisher statistics")
1658 };
1659 let x = base.design.try_row_chunk(0..n).expect("test design rows");
1660 let k = x.ncols();
1661 let q = k * d;
1662 let mut oracle_gram = Array2::<f64>::zeros((q, q));
1663 let mut oracle_cross = Array1::<f64>::zeros(q);
1664 let mut oracle_response = 0.0;
1665 for row in 0..n {
1666 for a in 0..k {
1667 for o in 0..d {
1668 let ao = a * d + o;
1669 for p in 0..d {
1670 oracle_cross[ao] += request.weights[row]
1671 * x[[row, a]]
1672 * metric[[row, o, p]]
1673 * request.response[[row, p]];
1674 }
1675 for b in 0..k {
1676 for p in 0..d {
1677 oracle_gram[[ao, b * d + p]] += request.weights[row]
1678 * x[[row, a]]
1679 * x[[row, b]]
1680 * metric[[row, o, p]];
1681 }
1682 }
1683 }
1684 }
1685 let y = request.response.row(row);
1686 oracle_response += request.weights[row] * y.dot(&metric.slice(s![row, .., ..]).dot(&y));
1687 }
1688 assert_array2_close(gram, &oracle_gram, 2.0e-12);
1689 assert_array1_close(cross, &oracle_cross, 2.0e-12);
1690 let zero_coefficients = Array2::<f64>::zeros((k, d));
1691 let direct_response_quadratic = prepared
1692 .profiled_deviance(&zero_coefficients)
1693 .expect("direct zero-fit quadratic");
1694 assert_close(direct_response_quadratic, oracle_response, 2.0e-12);
1695 }
1696
1697 #[test]
1698 fn parametric_fit_is_certified_serializable_and_predicts_in_core() {
1699 let design = array![[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
1700 let response = array![[0.2, -0.1], [0.9, 0.4], [2.1, 0.8], [2.8, 1.4]];
1701 let request = SharedTangentRemlRequest::from_dense(
1702 design.clone(),
1703 response,
1704 Array1::ones(4),
1705 None,
1706 Vec::new(),
1707 );
1708 let fit = fit_shared_tangent_reml(request).expect("certified parametric fit");
1709 assert!(fit.outer_certificate.certifies());
1710 let prediction = fit.predict_dense(design).expect("core prediction");
1711 assert_array2_close(&prediction, &fit.fitted, 1.0e-12);
1712 let encoded = serde_json::to_string(&fit).expect("serialize fit");
1713 let decoded: SharedTangentRemlFit =
1714 serde_json::from_str(&encoded).expect("deserialize fit");
1715 assert_array2_close(&decoded.coefficients, &fit.coefficients, 0.0);
1716 assert!(decoded.outer_certificate.certifies());
1717 }
1718
1719 fn assert_close(left: f64, right: f64, tolerance: f64) {
1720 let scale = left.abs().max(right.abs()).max(1.0);
1721 assert!(
1722 (left - right).abs() <= tolerance * scale,
1723 "{left} != {right} within relative tolerance {tolerance}"
1724 );
1725 }
1726
1727 fn assert_array1_close(left: &Array1<f64>, right: &Array1<f64>, tolerance: f64) {
1728 assert_eq!(left.len(), right.len());
1729 for (left, right) in left.iter().zip(right.iter()) {
1730 assert_close(*left, *right, tolerance);
1731 }
1732 }
1733
1734 fn assert_array2_close(left: &Array2<f64>, right: &Array2<f64>, tolerance: f64) {
1735 assert_eq!(left.dim(), right.dim());
1736 for (left, right) in left.iter().zip(right.iter()) {
1737 assert_close(*left, *right, tolerance);
1738 }
1739 }
1740}