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