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}
437
438#[derive(Debug)]
439struct PenaltySpectrum {
440 rank: usize,
441 log_pseudo_determinant: f64,
442 pseudo_inverse: Array2<f64>,
443}
444
445pub fn fit_shared_tangent_reml(
447 mut request: SharedTangentRemlRequest,
448) -> Result<SharedTangentRemlFit, EstimationError> {
449 let requested_penalty_count = request.penalties.len();
450 let initial_log_lambdas = request.initial_log_lambdas.take();
451 let prepared = PreparedSharedTangent::from_request(request)?;
452 let n_outer = prepared.penalties.len();
453 if let Some(initial) = initial_log_lambdas.as_ref() {
454 if initial.len() != requested_penalty_count {
455 return Err(invalid(format!(
456 "initial_log_lambdas has length {}, expected {}",
457 initial.len(),
458 requested_penalty_count
459 )));
460 }
461 if initial.iter().any(|value| !value.is_finite()) {
462 return Err(invalid("initial_log_lambdas must be finite"));
463 }
464 }
465 let (rho, outer_iterations, certificate) = if n_outer == 0 {
466 (
471 Array1::<f64>::zeros(0),
472 0,
473 OuterCriterionCertificate {
474 stationarity:
475 gam_solve::rho_optimizer::OuterStationarityCertificate::AnalyticGradient {
476 grad_norm: 0.0,
477 projected_grad_norm: 0.0,
478 bound: 0.0,
479 },
480 hessian_psd: Some(true),
481 lambdas_railed: Vec::new(),
482 },
483 )
484 } else {
485 let mut problem = OuterProblem::new(n_outer)
486 .with_gradient(Derivative::Analytic)
487 .with_hessian(DeclaredHessianForm::Dense)
488 .with_disable_fixed_point(true)
489 .with_continuation_prewarm(false)
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] = rho[active_index].exp();
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 if rho.iter().any(|value| !value.is_finite()) {
722 return Err(invalid("log-lambdas must be finite"));
723 }
724 match &self.statistics {
725 SufficientStatistics::Isotropic { gram, cross } => {
726 self.evaluate_isotropic(rho, gram, cross)
727 }
728 SufficientStatistics::Fisher { gram, cross } => self.evaluate_fisher(rho, gram, cross),
729 }
730 }
731
732 fn evaluate_isotropic(
733 &self,
734 rho: &Array1<f64>,
735 gram: &Array2<f64>,
736 cross: &Array2<f64>,
737 ) -> Result<Evaluation, EstimationError> {
738 let d = self.n_outputs;
739 let (penalty, lambdas) = self.combined_penalty(rho)?;
740 let spectrum = penalty_spectrum(&penalty, "combined shared-tangent penalty")?;
741 let mut penalized = gram.clone();
742 penalized += &penalty;
743 let (inverse, log_determinant) = spd_inverse_and_logdet(&penalized)?;
744 let coefficients = inverse.dot(cross);
745 let profiled_deviance = self.profiled_deviance(&coefficients)?;
746 let residual_degrees_of_freedom = self.residual_degrees_of_freedom(spectrum.rank)?;
747 validate_profiled_deviance(profiled_deviance)?;
748
749 let m = self.penalties.len();
750 let mut penalty_traces = Array1::<f64>::zeros(m);
751 let mut penalty_logdet_traces = Array1::<f64>::zeros(m);
752 let mut deviance_first = Array1::<f64>::zeros(m);
753 let mut penalty_beta = Vec::with_capacity(m);
754 for (index, penalty_block) in self.penalties.iter().enumerate() {
755 penalty_traces[index] =
756 d as f64 * trace_local_base(&inverse, penalty_block, lambdas[index]);
757 penalty_logdet_traces[index] = d as f64
758 * trace_local_base(&spectrum.pseudo_inverse, penalty_block, lambdas[index]);
759 let z = apply_local_base_matrix(penalty_block, lambdas[index], &coefficients);
760 deviance_first[index] = sum_products(&coefficients, &z);
761 penalty_beta.push(z);
762 }
763
764 let mut gradient = Array1::<f64>::zeros(m);
765 for j in 0..m {
766 gradient[j] = 0.5
767 * (penalty_traces[j] - penalty_logdet_traces[j]
768 + residual_degrees_of_freedom * deviance_first[j] / profiled_deviance);
769 }
770 let mut hessian = Array2::<f64>::zeros((m, m));
771 for j in 0..m {
772 let h_sandwich = sandwich_local_base(&inverse, &self.penalties[j], lambdas[j]);
773 let p_sandwich =
774 sandwich_local_base(&spectrum.pseudo_inverse, &self.penalties[j], lambdas[j]);
775 for kk in 0..=j {
776 let h_cross = d as f64
777 * trace_sandwich_local_base(&h_sandwich, &self.penalties[kk], lambdas[kk]);
778 let p_cross = d as f64
779 * trace_sandwich_local_base(&p_sandwich, &self.penalties[kk], lambdas[kk]);
780 let solved_penalty_beta = inverse.dot(&penalty_beta[kk]);
781 let deviance_cross = sum_products(&penalty_beta[j], &solved_penalty_beta);
782 let delta = usize::from(j == kk) as f64;
783 let logdet_second = delta * penalty_traces[j] - h_cross;
784 let penalty_logdet_second = delta * penalty_logdet_traces[j] - p_cross;
785 let deviance_second = delta * deviance_first[j] - 2.0 * deviance_cross;
786 let value = 0.5
787 * (logdet_second - penalty_logdet_second
788 + residual_degrees_of_freedom
789 * (deviance_second / profiled_deviance
790 - deviance_first[j] * deviance_first[kk]
791 / (profiled_deviance * profiled_deviance)));
792 hessian[[j, kk]] = value;
793 hessian[[kk, j]] = value;
794 }
795 }
796 let cost = 0.5
797 * (d as f64 * log_determinant - d as f64 * spectrum.log_pseudo_determinant
798 + residual_degrees_of_freedom
799 * (1.0
800 + (2.0 * std::f64::consts::PI * profiled_deviance
801 / residual_degrees_of_freedom)
802 .ln()));
803 validate_evaluation(cost, &gradient, &hessian)?;
804 Ok(Evaluation {
805 cost,
806 gradient,
807 hessian,
808 coefficients,
809 profiled_deviance,
810 penalty_traces,
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 })
906 }
907
908 fn profiled_deviance(&self, coefficients: &Array2<f64>) -> Result<f64, EstimationError> {
914 let n = self.design.nrows();
915 let k = self.design.ncols();
916 let d = self.response.ncols();
917 if coefficients.dim() != (k, d) {
918 return Err(invalid(format!(
919 "shared-tangent coefficient shape {:?} does not match ({k}, {d})",
920 coefficients.dim()
921 )));
922 }
923 let mut quadratic = KahanSum::default();
924 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
925 for start in (0..n).step_by(chunk_rows) {
926 let end = (start + chunk_rows).min(n);
927 let x_chunk = self
928 .design
929 .try_row_chunk(start..end)
930 .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
931 validate_design_chunk(&x_chunk)?;
932 let fitted = x_chunk.dot(coefficients);
933 for local_row in 0..x_chunk.nrows() {
934 let row = start + local_row;
935 let weight = self.weights[row];
936 if weight == 0.0 {
937 continue;
938 }
939 if let Some(metric) = self.fisher_metric.as_ref() {
940 for output_a in 0..d {
941 let residual_a =
942 self.response[[row, output_a]] - fitted[[local_row, output_a]];
943 for output_b in 0..d {
944 let residual_b =
945 self.response[[row, output_b]] - fitted[[local_row, output_b]];
946 quadratic.add(
947 weight
948 * residual_a
949 * metric[[row, output_a, output_b]]
950 * residual_b,
951 );
952 }
953 }
954 } else {
955 for output in 0..d {
956 let residual = self.response[[row, output]] - fitted[[local_row, output]];
957 quadratic.add(weight * residual * residual);
958 }
959 }
960 }
961 }
962 Ok(quadratic.sum())
963 }
964
965 fn combined_penalty(
966 &self,
967 rho: &Array1<f64>,
968 ) -> Result<(Array2<f64>, Array1<f64>), EstimationError> {
969 let mut combined = Array2::<f64>::zeros((self.n_coefficients, self.n_coefficients));
970 let mut lambdas = Array1::<f64>::zeros(rho.len());
971 for (index, penalty) in self.penalties.iter().enumerate() {
972 let lambda = rho[index].exp();
973 if !lambda.is_finite() || lambda <= 0.0 {
974 return Err(invalid(format!(
975 "log-lambda {} exponentiated to invalid value {lambda}",
976 rho[index]
977 )));
978 }
979 lambdas[index] = lambda;
980 for local_row in 0..penalty.local.nrows() {
981 for local_col in 0..penalty.local.ncols() {
982 combined[[
983 penalty.column_start + local_row,
984 penalty.column_start + local_col,
985 ]] += lambda * penalty.local[[local_row, local_col]];
986 }
987 }
988 }
989 Ok((combined, lambdas))
990 }
991
992 fn residual_degrees_of_freedom(
993 &self,
994 combined_penalty_rank: usize,
995 ) -> Result<f64, EstimationError> {
996 let effective_rows = self
997 .effective_observations
998 .checked_mul(self.n_outputs)
999 .ok_or_else(|| invalid("effective joint row count overflow"))?;
1000 let base_nullity = self
1001 .n_coefficients
1002 .checked_sub(combined_penalty_rank)
1003 .ok_or_else(|| invalid("combined penalty rank exceeds coefficient dimension"))?;
1004 let joint_nullity = base_nullity
1005 .checked_mul(self.n_outputs)
1006 .ok_or_else(|| invalid("joint penalty nullity overflow"))?;
1007 if effective_rows <= joint_nullity {
1008 return Err(invalid(format!(
1009 "REML requires more effective joint rows than unpenalized coefficients; got {effective_rows} rows and nullity {joint_nullity}"
1010 )));
1011 }
1012 Ok((effective_rows - joint_nullity) as f64)
1013 }
1014}
1015
1016fn prepare_penalties(
1017 penalties: &[SharedTangentPenalty],
1018 n_coefficients: usize,
1019) -> Result<Vec<PreparedPenalty>, EstimationError> {
1020 let mut prepared = Vec::with_capacity(penalties.len());
1021 for (slot, penalty) in penalties.iter().enumerate() {
1022 let q = penalty.matrix.nrows();
1023 if q != penalty.matrix.ncols() {
1024 return Err(invalid(format!(
1025 "penalty {slot} must be square; got {}x{}",
1026 penalty.matrix.nrows(),
1027 penalty.matrix.ncols()
1028 )));
1029 }
1030 let end = penalty
1031 .column_start
1032 .checked_add(q)
1033 .ok_or_else(|| invalid(format!("penalty {slot} column range overflow")))?;
1034 if end > n_coefficients {
1035 return Err(invalid(format!(
1036 "penalty {slot} column range {}..{end} exceeds design width {n_coefficients}",
1037 penalty.column_start
1038 )));
1039 }
1040 if penalty.matrix.iter().any(|value| !value.is_finite()) {
1041 return Err(invalid(format!(
1042 "penalty {slot} contains non-finite values"
1043 )));
1044 }
1045 let local = symmetric_average(&penalty.matrix);
1046 let spectrum = penalty_spectrum(&local, &format!("shared-tangent penalty {slot}"))?;
1047 if spectrum.rank == 0 {
1048 continue;
1049 }
1050 prepared.push(PreparedPenalty {
1051 output_slot: slot,
1052 column_start: penalty.column_start,
1053 local,
1054 rank: spectrum.rank,
1055 });
1056 }
1057 Ok(prepared)
1058}
1059
1060fn assemble_isotropic_statistics(
1061 design: &DesignMatrix,
1062 response: &Array2<f64>,
1063 weights: &Array1<f64>,
1064) -> Result<SufficientStatistics, EstimationError> {
1065 let n = design.nrows();
1066 let k = design.ncols();
1067 let d = response.ncols();
1068 let mut gram = Array2::<f64>::zeros((k, k));
1069 let mut cross = Array2::<f64>::zeros((k, d));
1070 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1071 for start in (0..n).step_by(chunk_rows) {
1072 let end = (start + chunk_rows).min(n);
1073 let x_chunk = design
1074 .try_row_chunk(start..end)
1075 .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1076 validate_design_chunk(&x_chunk)?;
1077 let weight_chunk = weights.slice(s![start..end]);
1078 let response_chunk = response.slice(s![start..end, ..]);
1079 gram += &fast_xt_diag_x(&x_chunk, &weight_chunk);
1080 cross += &fast_xt_diag_y(&x_chunk, &weight_chunk, &response_chunk);
1081 }
1082 Ok(SufficientStatistics::Isotropic { gram, cross })
1083}
1084
1085fn assemble_fisher_statistics(
1086 design: &DesignMatrix,
1087 response: &Array2<f64>,
1088 weights: &Array1<f64>,
1089 fisher_metric: &Array3<f64>,
1090) -> Result<SufficientStatistics, EstimationError> {
1091 let n = design.nrows();
1092 let k = design.ncols();
1093 let d = response.ncols();
1094 let q = k
1095 .checked_mul(d)
1096 .ok_or_else(|| invalid("joint coefficient dimension overflow"))?;
1097 let mut gram = Array2::<f64>::zeros((q, q));
1098 let mut cross = Array1::<f64>::zeros(q);
1099 let chunk_rows = gam_linalg::utils::row_chunk_for_byte_budget(n, k);
1100 for start in (0..n).step_by(chunk_rows) {
1101 let end = (start + chunk_rows).min(n);
1102 let x_chunk = design
1103 .try_row_chunk(start..end)
1104 .map_err(|error| invalid(format!("failed to read design row chunk: {error}")))?;
1105 validate_design_chunk(&x_chunk)?;
1106 for local_row in 0..x_chunk.nrows() {
1107 let row = start + local_row;
1108 let metric = fisher_metric.slice(s![row, .., ..]);
1109 let y = response.row(row);
1110 let metric_y = metric.dot(&y);
1111 let weight = weights[row];
1112 for basis_a in 0..k {
1113 let x_a = x_chunk[[local_row, basis_a]];
1114 for output in 0..d {
1115 cross[basis_a * d + output] += weight * x_a * metric_y[output];
1116 }
1117 for basis_b in 0..k {
1118 let scale = weight * x_a * x_chunk[[local_row, basis_b]];
1119 if scale == 0.0 {
1120 continue;
1121 }
1122 for output_a in 0..d {
1123 for output_b in 0..d {
1124 gram[[basis_a * d + output_a, basis_b * d + output_b]] +=
1125 scale * metric[[output_a, output_b]];
1126 }
1127 }
1128 }
1129 }
1130 }
1131 }
1132 Ok(SufficientStatistics::Fisher { gram, cross })
1133}
1134
1135fn validated_metric(mut metric: Array2<f64>, row: usize) -> Result<Array2<f64>, EstimationError> {
1136 if metric.iter().any(|value| !value.is_finite()) {
1137 return Err(invalid(format!(
1138 "fisher_metric row {row} contains non-finite values"
1139 )));
1140 }
1141 let scale = metric
1142 .iter()
1143 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1144 let tolerance = f64::EPSILON.sqrt() * metric.nrows().max(1) as f64 * scale;
1145 for a in 0..metric.nrows() {
1146 for b in (a + 1)..metric.ncols() {
1147 if (metric[[a, b]] - metric[[b, a]]).abs() > tolerance {
1148 return Err(invalid(format!(
1149 "fisher_metric row {row} is not symmetric at ({a}, {b})"
1150 )));
1151 }
1152 let average = 0.5 * (metric[[a, b]] + metric[[b, a]]);
1153 metric[[a, b]] = average;
1154 metric[[b, a]] = average;
1155 }
1156 }
1157 metric.cholesky(Side::Lower).map_err(|error| {
1158 invalid(format!(
1159 "fisher_metric row {row} must be positive definite: {error}"
1160 ))
1161 })?;
1162 Ok(metric)
1163}
1164
1165fn penalty_spectrum(
1166 penalty: &Array2<f64>,
1167 context: &str,
1168) -> Result<PenaltySpectrum, EstimationError> {
1169 let (eigenvalues, eigenvectors) = penalty
1170 .eigh(Side::Lower)
1171 .map_err(EstimationError::EigendecompositionFailed)?;
1172 let scale = eigenvalues
1173 .iter()
1174 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1175 let tolerance = f64::EPSILON.sqrt() * eigenvalues.len().max(1) as f64 * scale;
1176 let mut rank = 0usize;
1177 let mut log_pseudo_determinant = 0.0;
1178 let mut pseudo_inverse = Array2::<f64>::zeros(penalty.dim());
1179 for (index, &value) in eigenvalues.iter().enumerate() {
1180 if !value.is_finite() {
1181 return Err(EstimationError::PenaltySpectrumNonFinite {
1182 context: context.to_string(),
1183 index,
1184 value,
1185 });
1186 }
1187 if value < -tolerance {
1188 return Err(EstimationError::PenaltySpectrumIndefinite {
1189 context: context.to_string(),
1190 index,
1191 value,
1192 tolerance,
1193 scale,
1194 });
1195 }
1196 if value <= tolerance {
1197 continue;
1198 }
1199 rank += 1;
1200 log_pseudo_determinant += value.ln();
1201 for row in 0..penalty.nrows() {
1202 for col in 0..penalty.ncols() {
1203 pseudo_inverse[[row, col]] +=
1204 eigenvectors[[row, index]] * eigenvectors[[col, index]] / value;
1205 }
1206 }
1207 }
1208 Ok(PenaltySpectrum {
1209 rank,
1210 log_pseudo_determinant,
1211 pseudo_inverse,
1212 })
1213}
1214
1215fn spd_inverse_and_logdet(matrix: &Array2<f64>) -> Result<(Array2<f64>, f64), EstimationError> {
1216 let factor =
1217 matrix
1218 .cholesky(Side::Lower)
1219 .map_err(|_| EstimationError::ModelIsIllConditioned {
1220 condition_number: f64::INFINITY,
1221 })?;
1222 let diagonal = factor.diag();
1223 if diagonal
1224 .iter()
1225 .any(|value| !value.is_finite() || *value <= 0.0)
1226 {
1227 return Err(EstimationError::ModelIsIllConditioned {
1228 condition_number: f64::INFINITY,
1229 });
1230 }
1231 let log_determinant = 2.0 * diagonal.iter().map(|value| value.ln()).sum::<f64>();
1232 let identity = Array2::<f64>::eye(matrix.nrows());
1233 let inverse = factor.solve_mat(&identity);
1234 if !log_determinant.is_finite() || inverse.iter().any(|value| !value.is_finite()) {
1235 return Err(EstimationError::ModelIsIllConditioned {
1236 condition_number: f64::INFINITY,
1237 });
1238 }
1239 Ok((inverse, log_determinant))
1240}
1241
1242fn trace_local_base(inverse: &Array2<f64>, penalty: &PreparedPenalty, lambda: f64) -> f64 {
1243 let mut trace = 0.0;
1244 for row in 0..penalty.local.nrows() {
1245 for col in 0..penalty.local.ncols() {
1246 trace += lambda
1247 * penalty.local[[row, col]]
1248 * inverse[[penalty.column_start + col, penalty.column_start + row]];
1249 }
1250 }
1251 trace
1252}
1253
1254fn trace_local_joint(
1255 inverse: &Array2<f64>,
1256 penalty: &PreparedPenalty,
1257 lambda: f64,
1258 n_outputs: usize,
1259) -> f64 {
1260 let mut trace = 0.0;
1261 for output in 0..n_outputs {
1262 for row in 0..penalty.local.nrows() {
1263 for col in 0..penalty.local.ncols() {
1264 trace += lambda
1265 * penalty.local[[row, col]]
1266 * inverse[[
1267 (penalty.column_start + col) * n_outputs + output,
1268 (penalty.column_start + row) * n_outputs + output,
1269 ]];
1270 }
1271 }
1272 }
1273 trace
1274}
1275
1276fn apply_local_base_matrix(
1277 penalty: &PreparedPenalty,
1278 lambda: f64,
1279 matrix: &Array2<f64>,
1280) -> Array2<f64> {
1281 let mut output = Array2::<f64>::zeros(matrix.dim());
1282 for row in 0..penalty.local.nrows() {
1283 for col in 0..penalty.local.ncols() {
1284 let value = lambda * penalty.local[[row, col]];
1285 for output_index in 0..matrix.ncols() {
1286 output[[penalty.column_start + row, output_index]] +=
1287 value * matrix[[penalty.column_start + col, output_index]];
1288 }
1289 }
1290 }
1291 output
1292}
1293
1294fn apply_local_joint_vector(
1295 penalty: &PreparedPenalty,
1296 lambda: f64,
1297 n_outputs: usize,
1298 vector: &Array1<f64>,
1299) -> Array1<f64> {
1300 let mut output = Array1::<f64>::zeros(vector.len());
1301 for output_index in 0..n_outputs {
1302 for row in 0..penalty.local.nrows() {
1303 for col in 0..penalty.local.ncols() {
1304 output[(penalty.column_start + row) * n_outputs + output_index] += lambda
1305 * penalty.local[[row, col]]
1306 * vector[(penalty.column_start + col) * n_outputs + output_index];
1307 }
1308 }
1309 }
1310 output
1311}
1312
1313fn sandwich_local_base(
1314 inverse: &Array2<f64>,
1315 penalty: &PreparedPenalty,
1316 lambda: f64,
1317) -> Array2<f64> {
1318 let dimension = inverse.nrows();
1319 let mut result = Array2::<f64>::zeros((dimension, dimension));
1320 for local_row in 0..penalty.local.nrows() {
1321 let global_row = penalty.column_start + local_row;
1322 for local_col in 0..penalty.local.ncols() {
1323 let value = lambda * penalty.local[[local_row, local_col]];
1324 if value == 0.0 {
1325 continue;
1326 }
1327 let global_col = penalty.column_start + local_col;
1328 for row in 0..dimension {
1329 let left = inverse[[row, global_row]] * value;
1330 for col in 0..dimension {
1331 result[[row, col]] += left * inverse[[global_col, col]];
1332 }
1333 }
1334 }
1335 }
1336 result
1337}
1338
1339fn sandwich_local_joint(
1340 inverse: &Array2<f64>,
1341 penalty: &PreparedPenalty,
1342 lambda: f64,
1343 n_outputs: usize,
1344) -> Array2<f64> {
1345 let dimension = inverse.nrows();
1346 let mut result = Array2::<f64>::zeros((dimension, dimension));
1347 for output in 0..n_outputs {
1348 for local_row in 0..penalty.local.nrows() {
1349 let global_row = (penalty.column_start + local_row) * n_outputs + output;
1350 for local_col in 0..penalty.local.ncols() {
1351 let value = lambda * penalty.local[[local_row, local_col]];
1352 if value == 0.0 {
1353 continue;
1354 }
1355 let global_col = (penalty.column_start + local_col) * n_outputs + output;
1356 for row in 0..dimension {
1357 let left = inverse[[row, global_row]] * value;
1358 for col in 0..dimension {
1359 result[[row, col]] += left * inverse[[global_col, col]];
1360 }
1361 }
1362 }
1363 }
1364 }
1365 result
1366}
1367
1368fn trace_sandwich_local_base(
1369 sandwich: &Array2<f64>,
1370 penalty: &PreparedPenalty,
1371 lambda: f64,
1372) -> f64 {
1373 let mut trace = 0.0;
1374 for row in 0..penalty.local.nrows() {
1375 for col in 0..penalty.local.ncols() {
1376 trace += lambda
1377 * penalty.local[[row, col]]
1378 * sandwich[[penalty.column_start + col, penalty.column_start + row]];
1379 }
1380 }
1381 trace
1382}
1383
1384fn trace_sandwich_local_joint(
1385 sandwich: &Array2<f64>,
1386 penalty: &PreparedPenalty,
1387 lambda: f64,
1388 n_outputs: usize,
1389) -> f64 {
1390 let mut trace = 0.0;
1391 for output in 0..n_outputs {
1392 for row in 0..penalty.local.nrows() {
1393 for col in 0..penalty.local.ncols() {
1394 trace += lambda
1395 * penalty.local[[row, col]]
1396 * sandwich[[
1397 (penalty.column_start + col) * n_outputs + output,
1398 (penalty.column_start + row) * n_outputs + output,
1399 ]];
1400 }
1401 }
1402 }
1403 trace
1404}
1405
1406fn add_base_penalty_to_joint(joint: &mut Array2<f64>, penalty: &Array2<f64>, n_outputs: usize) {
1407 for row in 0..penalty.nrows() {
1408 for col in 0..penalty.ncols() {
1409 let value = penalty[[row, col]];
1410 for output in 0..n_outputs {
1411 joint[[row * n_outputs + output, col * n_outputs + output]] += value;
1412 }
1413 }
1414 }
1415}
1416
1417fn symmetric_average(matrix: &Array2<f64>) -> Array2<f64> {
1418 let mut output = matrix.clone();
1419 for row in 0..matrix.nrows() {
1420 for col in (row + 1)..matrix.ncols() {
1421 let average = 0.5 * (matrix[[row, col]] + matrix[[col, row]]);
1422 output[[row, col]] = average;
1423 output[[col, row]] = average;
1424 }
1425 }
1426 output
1427}
1428
1429fn predict_from_coefficients(
1430 design: &DesignMatrix,
1431 coefficients: &Array2<f64>,
1432) -> Result<Array2<f64>, EstimationError> {
1433 if design.ncols() != coefficients.nrows() {
1434 return Err(invalid(format!(
1435 "prediction design width {} does not match coefficient rows {}",
1436 design.ncols(),
1437 coefficients.nrows()
1438 )));
1439 }
1440 let mut prediction = Array2::<f64>::zeros((design.nrows(), coefficients.ncols()));
1441 for output in 0..coefficients.ncols() {
1442 let values = design.apply(&coefficients.column(output).to_owned());
1443 prediction.column_mut(output).assign(&values);
1444 }
1445 if prediction.iter().any(|value| !value.is_finite()) {
1446 return Err(invalid("prediction produced non-finite values"));
1447 }
1448 Ok(prediction)
1449}
1450
1451fn sum_products(left: &Array2<f64>, right: &Array2<f64>) -> f64 {
1452 left.iter()
1453 .zip(right.iter())
1454 .map(|(left, right)| left * right)
1455 .sum()
1456}
1457
1458fn validate_design_chunk(chunk: &Array2<f64>) -> Result<(), EstimationError> {
1459 if chunk.iter().any(|value| !value.is_finite()) {
1460 return Err(invalid("design contains non-finite values"));
1461 }
1462 Ok(())
1463}
1464
1465fn validate_profiled_deviance(value: f64) -> Result<(), EstimationError> {
1466 if !value.is_finite() || value <= 0.0 {
1467 return Err(EstimationError::RemlOptimizationFailed(format!(
1468 "{FIT_CONTEXT}: profiled penalized deviance must be finite and positive, got {value}"
1469 )));
1470 }
1471 Ok(())
1472}
1473
1474fn validate_evaluation(
1475 cost: f64,
1476 gradient: &Array1<f64>,
1477 hessian: &Array2<f64>,
1478) -> Result<(), EstimationError> {
1479 if !cost.is_finite()
1480 || gradient.iter().any(|value| !value.is_finite())
1481 || hessian.iter().any(|value| !value.is_finite())
1482 {
1483 return Err(EstimationError::RemlOptimizationFailed(format!(
1484 "{FIT_CONTEXT}: objective evaluation produced non-finite value or derivatives"
1485 )));
1486 }
1487 Ok(())
1488}
1489
1490fn validate_archived_tangent_fit(
1491 fit: &SharedTangentRemlFit,
1492) -> Result<(), ResponseGeometryModelError> {
1493 if fit.n_observations == 0
1494 || fit.n_outputs == 0
1495 || fit.coefficients.nrows() == 0
1496 || fit.coefficients.ncols() != fit.n_outputs
1497 || fit.fitted.dim() != (fit.n_observations, fit.n_outputs)
1498 {
1499 return Err(ResponseGeometryModelError::InvalidMetadata(
1500 "shared tangent fit has inconsistent dimensions".to_string(),
1501 ));
1502 }
1503 if fit.lambdas.len() != fit.edf_by_penalty.len() {
1504 return Err(ResponseGeometryModelError::InvalidMetadata(
1505 "shared tangent lambda and EDF vectors are misaligned".to_string(),
1506 ));
1507 }
1508 if fit.coefficients.iter().any(|value| !value.is_finite())
1509 || fit.fitted.iter().any(|value| !value.is_finite())
1510 || fit
1511 .lambdas
1512 .iter()
1513 .any(|value| !value.is_finite() || *value < 0.0)
1514 || fit
1515 .edf_by_penalty
1516 .iter()
1517 .any(|value| !value.is_finite() || *value < 0.0)
1518 || !fit.sigma2.is_finite()
1519 || fit.sigma2 <= 0.0
1520 || !fit.edf_total.is_finite()
1521 || fit.edf_total < 0.0
1522 || !fit.reml_score.is_finite()
1523 {
1524 return Err(ResponseGeometryModelError::InvalidMetadata(
1525 "shared tangent fit contains invalid numerical values".to_string(),
1526 ));
1527 }
1528 if !fit.outer_certificate.certifies() {
1529 return Err(ResponseGeometryModelError::InvalidMetadata(
1530 "shared tangent fit lacks a valid convergence certificate".to_string(),
1531 ));
1532 }
1533 Ok(())
1534}
1535
1536fn bounded_roundoff_value(
1537 value: f64,
1538 lower: f64,
1539 upper: f64,
1540 context: &str,
1541) -> Result<f64, EstimationError> {
1542 let tolerance = f64::EPSILON.sqrt() * upper.abs().max(1.0);
1543 if !value.is_finite() || value < lower - tolerance || value > upper + tolerance {
1544 return Err(EstimationError::RemlOptimizationFailed(format!(
1545 "{FIT_CONTEXT}: {context} {value} lies outside [{lower}, {upper}] beyond roundoff"
1546 )));
1547 }
1548 Ok(value.clamp(lower, upper))
1549}
1550
1551fn invalid(message: impl Into<String>) -> EstimationError {
1552 EstimationError::InvalidInput(message.into())
1553}
1554
1555#[cfg(test)]
1556mod tests {
1557 use super::*;
1558 use gam_linalg::test_support::no_densify_design;
1559 use ndarray::{Array3, array};
1560
1561 fn fixture_request(fisher_metric: Option<Array3<f64>>) -> SharedTangentRemlRequest {
1562 let design = array![
1563 [1.0, -1.0, 0.5],
1564 [1.0, -0.5, -0.2],
1565 [1.0, 0.0, 0.3],
1566 [1.0, 0.5, 0.8],
1567 [1.0, 1.0, -0.4],
1568 [1.0, 1.5, 0.1]
1569 ];
1570 let response = array![
1571 [-0.7, 0.4],
1572 [-0.1, 0.1],
1573 [0.2, -0.3],
1574 [0.8, -0.2],
1575 [1.1, 0.5],
1576 [1.7, 0.2]
1577 ];
1578 let penalties = vec![
1579 SharedTangentPenalty::new(1, array![[1.0, 0.0], [0.0, 0.0]]),
1580 SharedTangentPenalty::new(1, array![[0.0, 0.0], [0.0, 1.0]]),
1581 ];
1582 SharedTangentRemlRequest::new(
1583 no_densify_design(design),
1584 response,
1585 array![1.0, 0.8, 1.2, 1.0, 0.9, 1.1],
1586 fisher_metric,
1587 penalties,
1588 )
1589 }
1590
1591 #[test]
1592 fn operator_backed_isotropic_path_matches_streamed_identity_fisher_path() {
1593 let isotropic_request = fixture_request(None);
1594 let n = isotropic_request.response.nrows();
1595 let d = isotropic_request.response.ncols();
1596 let mut identity_metric = Array3::<f64>::zeros((n, d, d));
1597 for row in 0..n {
1598 for output in 0..d {
1599 identity_metric[[row, output, output]] = 1.0;
1600 }
1601 }
1602 let fisher_request = fixture_request(Some(identity_metric));
1603 let isotropic = PreparedSharedTangent::from_request(isotropic_request)
1604 .expect("prepare isotropic without densifying");
1605 let fisher = PreparedSharedTangent::from_request(fisher_request)
1606 .expect("prepare Fisher without densifying");
1607 let rho = array![-0.4, 0.7];
1608 let left = isotropic.evaluate(&rho).expect("isotropic eval");
1609 let right = fisher.evaluate(&rho).expect("Fisher eval");
1610 assert_close(left.cost, right.cost, 2.0e-11);
1611 assert_array1_close(&left.gradient, &right.gradient, 2.0e-10);
1612 assert_array2_close(&left.hessian, &right.hessian, 2.0e-9);
1613 assert_array2_close(&left.coefficients, &right.coefficients, 2.0e-11);
1614 }
1615
1616 #[test]
1617 fn analytic_gradient_and_hessian_match_test_only_finite_differences() {
1618 let request = fixture_request(None);
1619 let prepared = PreparedSharedTangent::from_request(request).expect("prepare");
1620 let rho = array![-0.2, 0.35];
1621 let exact = prepared.evaluate(&rho).expect("exact eval");
1622 let step = f64::EPSILON.cbrt();
1623 for j in 0..rho.len() {
1624 let mut plus = rho.clone();
1625 let mut minus = rho.clone();
1626 plus[j] += step;
1627 minus[j] -= step;
1628 let plus_eval = prepared.evaluate(&plus).expect("plus eval");
1629 let minus_eval = prepared.evaluate(&minus).expect("minus eval");
1630 let gradient_fd = (plus_eval.cost - minus_eval.cost) / (2.0 * step);
1631 assert_close(exact.gradient[j], gradient_fd, 2.0e-6);
1632 for k in 0..rho.len() {
1633 let hessian_fd = (plus_eval.gradient[k] - minus_eval.gradient[k]) / (2.0 * step);
1634 assert_close(exact.hessian[[k, j]], hessian_fd, 3.0e-6);
1635 }
1636 }
1637 }
1638
1639 #[test]
1640 fn streamed_varying_fisher_statistics_match_explicit_joint_oracle() {
1641 let base = fixture_request(None);
1642 let n = base.response.nrows();
1643 let d = base.response.ncols();
1644 let mut metric = Array3::<f64>::zeros((n, d, d));
1645 for row in 0..n {
1646 let off = 0.04 * (row as f64 + 1.0);
1647 metric[[row, 0, 0]] = 1.2 + 0.1 * row as f64;
1648 metric[[row, 0, 1]] = off;
1649 metric[[row, 1, 0]] = off;
1650 metric[[row, 1, 1]] = 0.9 + 0.05 * row as f64;
1651 }
1652 let request = fixture_request(Some(metric.clone()));
1653 let prepared =
1654 PreparedSharedTangent::from_request(request.clone()).expect("prepare Fisher");
1655 let SufficientStatistics::Fisher { gram, cross } = &prepared.statistics else {
1656 panic!("expected Fisher statistics")
1657 };
1658 let x = base.design.try_row_chunk(0..n).expect("test design rows");
1659 let k = x.ncols();
1660 let q = k * d;
1661 let mut oracle_gram = Array2::<f64>::zeros((q, q));
1662 let mut oracle_cross = Array1::<f64>::zeros(q);
1663 let mut oracle_response = 0.0;
1664 for row in 0..n {
1665 for a in 0..k {
1666 for o in 0..d {
1667 let ao = a * d + o;
1668 for p in 0..d {
1669 oracle_cross[ao] += request.weights[row]
1670 * x[[row, a]]
1671 * metric[[row, o, p]]
1672 * request.response[[row, p]];
1673 }
1674 for b in 0..k {
1675 for p in 0..d {
1676 oracle_gram[[ao, b * d + p]] += request.weights[row]
1677 * x[[row, a]]
1678 * x[[row, b]]
1679 * metric[[row, o, p]];
1680 }
1681 }
1682 }
1683 }
1684 let y = request.response.row(row);
1685 oracle_response += request.weights[row] * y.dot(&metric.slice(s![row, .., ..]).dot(&y));
1686 }
1687 assert_array2_close(gram, &oracle_gram, 2.0e-12);
1688 assert_array1_close(cross, &oracle_cross, 2.0e-12);
1689 let zero_coefficients = Array2::<f64>::zeros((k, d));
1690 let direct_response_quadratic = prepared
1691 .profiled_deviance(&zero_coefficients)
1692 .expect("direct zero-fit quadratic");
1693 assert_close(direct_response_quadratic, oracle_response, 2.0e-12);
1694 }
1695
1696 #[test]
1697 fn parametric_fit_is_certified_serializable_and_predicts_in_core() {
1698 let design = array![[1.0, -1.0], [1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
1699 let response = array![[0.2, -0.1], [0.9, 0.4], [2.1, 0.8], [2.8, 1.4]];
1700 let request = SharedTangentRemlRequest::from_dense(
1701 design.clone(),
1702 response,
1703 Array1::ones(4),
1704 None,
1705 Vec::new(),
1706 );
1707 let fit = fit_shared_tangent_reml(request).expect("certified parametric fit");
1708 assert!(fit.outer_certificate.certifies());
1709 let prediction = fit.predict_dense(design).expect("core prediction");
1710 assert_array2_close(&prediction, &fit.fitted, 1.0e-12);
1711 let encoded = serde_json::to_string(&fit).expect("serialize fit");
1712 let decoded: SharedTangentRemlFit =
1713 serde_json::from_str(&encoded).expect("deserialize fit");
1714 assert_array2_close(&decoded.coefficients, &fit.coefficients, 0.0);
1715 assert!(decoded.outer_certificate.certifies());
1716 }
1717
1718 fn assert_close(left: f64, right: f64, tolerance: f64) {
1719 let scale = left.abs().max(right.abs()).max(1.0);
1720 assert!(
1721 (left - right).abs() <= tolerance * scale,
1722 "{left} != {right} within relative tolerance {tolerance}"
1723 );
1724 }
1725
1726 fn assert_array1_close(left: &Array1<f64>, right: &Array1<f64>, tolerance: f64) {
1727 assert_eq!(left.len(), right.len());
1728 for (left, right) in left.iter().zip(right.iter()) {
1729 assert_close(*left, *right, tolerance);
1730 }
1731 }
1732
1733 fn assert_array2_close(left: &Array2<f64>, right: &Array2<f64>, tolerance: f64) {
1734 assert_eq!(left.dim(), right.dim());
1735 for (left, right) in left.iter().zip(right.iter()) {
1736 assert_close(*left, *right, tolerance);
1737 }
1738 }
1739}