1use crate::active_set::{
19 feasible_point_for_linear_constraints, solve_quadratic_with_linear_constraints,
20};
21use crate::estimate::EstimationError;
22use crate::gaussian_reml::{
23 GaussianRemlBackwardResult, gaussian_reml_multi_closed_form_with_cache,
24};
25use faer::Side;
26use gam_linalg::faer_ndarray::{
27 FaerCholesky, FaerEigh, default_rrqr_rank_alpha, rrqr_nullspace_basis, rrqr_with_permutation,
28};
29use gam_problem::LinearInequalityConstraints;
30use ndarray::{Array1, Array2, ArrayView1, ArrayView2, Axis};
31use opt::{Bfgs, Bounds, FirstOrderSample, FusedObjective, GradientTolerance, ObjectiveEvalError};
32
33pub struct ConstrainedGaussianRemlForwardProblem<'a> {
41 pub x: ArrayView2<'a, f64>,
42 pub y: ArrayView2<'a, f64>,
43 pub penalty: ArrayView2<'a, f64>,
44 pub weights: Option<ArrayView1<'a, f64>>,
45 pub constraints: Option<&'a LinearInequalityConstraints>,
46 pub init_lambda: Option<f64>,
47}
48
49pub struct ConstrainedGaussianRemlForwardResult {
51 pub lambda: f64,
52 pub coefficients: Array2<f64>,
53 pub fitted: Array2<f64>,
54 pub reml_score: f64,
55 pub edf: f64,
56 pub active_indices: Array1<u64>,
57}
58
59pub struct ConstrainedGaussianRemlBackwardProblem<'a> {
65 pub x: ArrayView2<'a, f64>,
66 pub y: ArrayView2<'a, f64>,
67 pub penalty: ArrayView2<'a, f64>,
68 pub weights: Option<ArrayView1<'a, f64>>,
69 pub a_inequality: ArrayView2<'a, f64>,
70 pub b_inequality: ArrayView1<'a, f64>,
71 pub active_indices: ArrayView1<'a, u64>,
72 pub lambda: f64,
73 pub coefficients: ArrayView2<'a, f64>,
74 pub grad_coefficients: Option<ArrayView2<'a, f64>>,
75 pub grad_fitted: Option<ArrayView2<'a, f64>>,
76 pub grad_lambda: f64,
77 pub grad_log_lambda: f64,
78 pub grad_reml_score: f64,
79 pub grad_edf: f64,
80}
81
82#[derive(Clone)]
83struct ActiveFace {
84 a: Array2<f64>,
85 b: Array1<f64>,
86 z: Array2<f64>,
87}
88
89#[derive(Clone)]
90struct AffineFaceProfile {
91 x: Array2<f64>,
92 y: Array2<f64>,
93 penalty: Array2<f64>,
94 weights: Array1<f64>,
95 face: ActiveFace,
96 beta_particular: Array2<f64>,
97 tangent_gram: Array2<f64>,
98 tangent_penalty: Array2<f64>,
99 tangent_rhs_data: Array2<f64>,
100 tangent_penalty_particular: Array2<f64>,
101 penalty_rank: usize,
102 penalty_logdet: f64,
103 residual_df: f64,
104}
105
106struct AffineFaceEvaluation {
107 rho: f64,
108 lambda: f64,
109 score: f64,
110 rho_gradient: f64,
111 rho_curvature: f64,
112 edf: f64,
113 beta: Array2<f64>,
114 fitted: Array2<f64>,
115}
116
117struct TangentPenaltyGeometry {
118 rank: usize,
119 logdet: f64,
120 pseudoinverse: Array2<f64>,
121}
122
123struct FaceState {
124 penalty: Array2<f64>,
125 weights: Array1<f64>,
126 beta: Array2<f64>,
127 residual: Array2<f64>,
128 gram: Array2<f64>,
129 p_response: Array2<f64>,
130 q_penalty: Array2<f64>,
131 penalty_rank: usize,
132 residual_df: f64,
133}
134
135struct VjpAccumulator {
136 x: Array2<f64>,
137 y: Array2<f64>,
138 penalty: Array2<f64>,
139 weights: Array1<f64>,
140 lambda: f64,
141}
142
143impl VjpAccumulator {
144 fn zeros(n: usize, p: usize, d: usize) -> Self {
145 Self {
146 x: Array2::zeros((n, p)),
147 y: Array2::zeros((n, d)),
148 penalty: Array2::zeros((p, p)),
149 weights: Array1::zeros(n),
150 lambda: 0.0,
151 }
152 }
153}
154
155pub fn constrained_gaussian_reml_forward(
161 problem: ConstrainedGaussianRemlForwardProblem<'_>,
162) -> Result<ConstrainedGaussianRemlForwardResult, EstimationError> {
163 validate_forward_problem(&problem)?;
164 let canonical_constraints = problem
165 .constraints
166 .map(LinearInequalityConstraints::canonicalized)
167 .transpose()
168 .map_err(|message| EstimationError::InvalidInput(message))?;
169
170 let unconstrained = gaussian_reml_multi_closed_form_with_cache(
171 problem.x,
172 problem.y,
173 problem.penalty,
174 problem.weights,
175 problem.init_lambda,
176 None,
177 )?;
178 let Some(constraints) = canonical_constraints.as_ref() else {
179 return Ok(unconstrained_result(unconstrained));
180 };
181 if constraints.a.nrows() == 0 {
182 return Ok(unconstrained_result(unconstrained));
183 }
184 let unconstrained_active = binding_rows(constraints, unconstrained.coefficients.column(0));
185 if unconstrained_active.is_empty() {
186 return Ok(unconstrained_result(unconstrained));
187 }
188
189 let n = problem.x.nrows();
190 let p = problem.x.ncols();
191 let penalty = symmetric_average(problem.penalty);
192 let weights = problem
193 .weights
194 .map_or_else(|| Array1::ones(n), |values| values.to_owned());
195 let weighted_x = &problem.x.to_owned() * &weights.view().insert_axis(Axis(1));
196 let gram = problem.x.t().dot(&weighted_x);
197 let weighted_y = &problem.y.to_owned() * &weights.view().insert_axis(Axis(1));
198 let rhs = problem.x.t().dot(&weighted_y).column(0).to_owned();
199 let mut beta_start =
200 feasible_point_for_linear_constraints(constraints, p).ok_or_else(|| {
201 EstimationError::ParameterConstraintViolation(
202 "constrained Gaussian REML could not construct a feasible coefficient seed"
203 .to_string(),
204 )
205 })?;
206 let mut rho = unconstrained.rho;
207 let mut active_hint: Vec<usize> = unconstrained_active
208 .iter()
209 .map(|&index| index as usize)
210 .collect();
211 let mut visited_faces: Vec<Vec<u64>> = Vec::new();
212
213 loop {
214 let lambda = gam_problem::checked_exp_log_strength(rho)
215 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
216 let hessian = &gram + &(penalty.clone() * lambda);
217 let (qp_beta, qp_active) = solve_quadratic_with_linear_constraints(
218 &hessian,
219 &rhs,
220 &beta_start,
221 constraints,
222 Some(&active_hint),
223 )?;
224 let active = binding_rows(constraints, qp_beta.view());
225 if active.is_empty() {
226 return Err(EstimationError::GradientUnavailable {
227 context: "constrained Gaussian REML forward",
228 mode: "active-face selection reached an unresolved constraint transition",
229 });
230 }
231 let active_key = active.to_vec();
232 if visited_faces.iter().any(|seen| seen == &active_key) {
233 return Err(EstimationError::GradientUnavailable {
234 context: "constrained Gaussian REML forward",
235 mode: "active-face smoothing iteration cycles at a constraint transition",
236 });
237 }
238 visited_faces.push(active_key);
239
240 let profile = AffineFaceProfile::new(
241 problem.x,
242 problem.y,
243 penalty.view(),
244 weights.view(),
245 constraints,
246 active.view(),
247 )?;
248 let accepted = optimize_affine_face(&profile, rho)?;
249
250 let accepted_hessian = &gram + &(penalty.clone() * accepted.lambda);
251 let accepted_beta = accepted.beta.column(0).to_owned();
252 let (qp_check, next_hint) = solve_quadratic_with_linear_constraints(
253 &accepted_hessian,
254 &rhs,
255 &accepted_beta,
256 constraints,
257 Some(&qp_active),
258 )?;
259 let next_active = binding_rows(constraints, qp_check.view());
260 if next_active == active {
261 let beta_scale = accepted_beta
262 .iter()
263 .chain(qp_check.iter())
264 .fold(1.0_f64, |scale, &value| scale.max(value.abs()));
265 let agreement = accepted_beta
266 .iter()
267 .zip(qp_check.iter())
268 .fold(0.0_f64, |maximum, (&left, &right)| {
269 maximum.max((left - right).abs())
270 });
271 let resolution = crate::pirls::ACTIVE_SET_PRIMAL_FEASIBILITY_TOL * beta_scale;
272 if agreement > resolution {
273 return Err(EstimationError::GradientUnavailable {
274 context: "constrained Gaussian REML forward",
275 mode: "affine-face optimum does not agree with its constrained KKT solve",
276 });
277 }
278 return Ok(ConstrainedGaussianRemlForwardResult {
279 lambda: accepted.lambda,
280 coefficients: accepted.beta,
281 fitted: accepted.fitted,
282 reml_score: accepted.score,
283 edf: accepted.edf,
284 active_indices: active,
285 });
286 }
287
288 beta_start = qp_check;
289 rho = accepted.rho;
290 active_hint = next_hint;
291 }
292}
293
294fn validate_forward_problem(
295 problem: &ConstrainedGaussianRemlForwardProblem<'_>,
296) -> Result<(), EstimationError> {
297 let n = problem.x.nrows();
298 let p = problem.x.ncols();
299 if problem.y.dim() != (n, 1) || problem.penalty.dim() != (p, p) {
300 crate::bail_invalid_estim!(
301 "constrained Gaussian REML forward input shapes are inconsistent"
302 );
303 }
304 if let Some(weights) = problem.weights
305 && weights.len() != n
306 {
307 crate::bail_invalid_estim!(
308 "constrained Gaussian REML weights length {} does not match row count {n}",
309 weights.len()
310 );
311 }
312 if let Some(constraints) = problem.constraints
313 && (constraints.a.ncols() != p || constraints.b.len() != constraints.a.nrows())
314 {
315 crate::bail_invalid_estim!(
316 "constrained Gaussian REML constraint dimensions are inconsistent"
317 );
318 }
319 if problem
320 .x
321 .iter()
322 .chain(problem.y.iter())
323 .chain(problem.penalty.iter())
324 .any(|value| !value.is_finite())
325 {
326 crate::bail_invalid_estim!("constrained Gaussian REML inputs must be finite");
327 }
328 if let Some(weights) = problem.weights
329 && weights
330 .iter()
331 .any(|value| !value.is_finite() || *value < 0.0)
332 {
333 crate::bail_invalid_estim!(
334 "constrained Gaussian REML weights must be finite and non-negative"
335 );
336 }
337 Ok(())
338}
339
340fn unconstrained_result(
341 result: crate::gaussian_reml::GaussianRemlMultiResult,
342) -> ConstrainedGaussianRemlForwardResult {
343 ConstrainedGaussianRemlForwardResult {
344 lambda: result.lambda,
345 coefficients: result.coefficients,
346 fitted: result.fitted,
347 reml_score: result.reml_score,
348 edf: result.edf,
349 active_indices: Array1::zeros(0),
350 }
351}
352
353fn binding_rows(
354 constraints: &LinearInequalityConstraints,
355 beta: ArrayView1<'_, f64>,
356) -> Array1<u64> {
357 let beta_scale = beta
358 .iter()
359 .fold(1.0_f64, |scale, &value| scale.max(value.abs()));
360 let mut active = Vec::new();
361 for row in 0..constraints.a.nrows() {
362 let normal = constraints.a.row(row);
363 if normal.iter().all(|&value| value == 0.0) {
364 continue;
365 }
366 let slack = normal.dot(&beta) - constraints.b[row];
367 let resolution = crate::pirls::ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
368 * beta_scale.max(constraints.b[row].abs().max(1.0));
369 if slack <= resolution {
370 active.push(row as u64);
371 }
372 }
373 Array1::from_vec(active)
374}
375
376impl AffineFaceProfile {
377 fn new(
378 x: ArrayView2<'_, f64>,
379 y: ArrayView2<'_, f64>,
380 penalty: ArrayView2<'_, f64>,
381 weights: ArrayView1<'_, f64>,
382 constraints: &LinearInequalityConstraints,
383 active_indices: ArrayView1<'_, u64>,
384 ) -> Result<Self, EstimationError> {
385 let face = active_face_from_parts(
386 constraints.a.view(),
387 constraints.b.view(),
388 active_indices,
389 x.ncols(),
390 )?;
391 let normal_gram = face.a.dot(&face.a.t());
392 let normal_factor = normal_gram
393 .cholesky(Side::Lower)
394 .map_err(EstimationError::LinearSystemSolveFailed)?;
395 let normal_coordinates = normal_factor.solvevec(&face.b);
396 let beta_particular_vec = face.a.t().dot(&normal_coordinates);
397 let beta_particular = beta_particular_vec.insert_axis(Axis(1));
398 let tangent_design = x.dot(&face.z);
399 let weighted_tangent_design = &tangent_design * &weights.view().insert_axis(Axis(1));
400 let tangent_gram = tangent_design.t().dot(&weighted_tangent_design);
401 let tangent_penalty = face.z.t().dot(&penalty).dot(&face.z);
402 let base_response = y.to_owned() - &x.dot(&beta_particular);
403 let weighted_base_response = &base_response * &weights.view().insert_axis(Axis(1));
404 let tangent_rhs_data = tangent_design.t().dot(&weighted_base_response);
405 let tangent_penalty_particular = face.z.t().dot(&penalty).dot(&beta_particular);
406 let penalty_geometry = tangent_penalty_geometry(&tangent_penalty)?;
407 let penalty_rank = penalty_geometry.rank;
408 let penalty_logdet = penalty_geometry.logdet;
409 let n_effective = weights.iter().filter(|&&value| value > 0.0).count();
410 let penalty_nullity = face.z.ncols().saturating_sub(penalty_rank);
411 if n_effective <= penalty_nullity {
412 crate::bail_invalid_estim!(
413 "constrained Gaussian REML requires more positive-weight rows than tangent penalty nullity"
414 );
415 }
416 Ok(Self {
417 x: x.to_owned(),
418 y: y.to_owned(),
419 penalty: penalty.to_owned(),
420 weights: weights.to_owned(),
421 face,
422 beta_particular,
423 tangent_gram,
424 tangent_penalty,
425 tangent_rhs_data,
426 tangent_penalty_particular,
427 penalty_rank,
428 penalty_logdet,
429 residual_df: (n_effective - penalty_nullity) as f64,
430 })
431 }
432
433 fn evaluate(&self, rho: f64) -> Result<AffineFaceEvaluation, EstimationError> {
434 let lambda = gam_problem::checked_exp_log_strength(rho)
435 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
436 let tangent_dim = self.face.z.ncols();
437 let (gamma, inverse, logdet_h) = if tangent_dim == 0 {
438 (Array2::zeros((0, 1)), Array2::zeros((0, 0)), 0.0)
439 } else {
440 let hessian = &self.tangent_gram + &(self.tangent_penalty.clone() * lambda);
441 let factor = hessian
442 .cholesky(Side::Lower)
443 .map_err(EstimationError::LinearSystemSolveFailed)?;
444 let rhs = &self.tangent_rhs_data - &(self.tangent_penalty_particular.clone() * lambda);
445 let gamma = factor.solve_mat(&rhs);
446 let inverse = factor.solve_mat(&Array2::<f64>::eye(tangent_dim));
447 let logdet_h = 2.0 * factor.diag().iter().map(|value| value.ln()).sum::<f64>();
448 (gamma, inverse, logdet_h)
449 };
450 let beta = &self.beta_particular + &self.face.z.dot(&gamma);
451 let fitted = self.x.dot(&beta);
452 let residual = &self.y - &fitted;
453 let weighted_rss =
454 (&residual * &residual * &self.weights.view().insert_axis(Axis(1))).sum();
455 let penalty_beta = self.penalty.dot(&beta);
456 let energy = (&beta * &penalty_beta).sum();
457 let penalized_deviance = weighted_rss + lambda * energy;
458 if !penalized_deviance.is_finite() || penalized_deviance <= 0.0 {
459 crate::bail_invalid_estim!(
460 "constrained Gaussian REML profiled deviance must be positive"
461 );
462 }
463
464 let inverse_penalty = inverse.dot(&self.tangent_penalty);
465 let trace_inverse_penalty = trace(&inverse_penalty);
466 let trace_inverse_penalty_squared = trace(&inverse_penalty.dot(&inverse_penalty));
467 let tangent_penalty_beta = self.face.z.t().dot(&penalty_beta);
468 let curvature_energy = if tangent_dim == 0 {
469 0.0
470 } else {
471 (&tangent_penalty_beta * &inverse.dot(&tangent_penalty_beta)).sum()
472 };
473 let logdet_penalty = self.penalty_logdet + self.penalty_rank as f64 * rho;
474 let score = 0.5 * (logdet_h - logdet_penalty)
475 + 0.5
476 * self.residual_df
477 * (1.0 + (2.0 * std::f64::consts::PI * penalized_deviance / self.residual_df).ln());
478 let lambda_energy = lambda * energy;
479 let rho_gradient = 0.5 * (lambda * trace_inverse_penalty - self.penalty_rank as f64)
480 + 0.5 * self.residual_df * lambda_energy / penalized_deviance;
481 let rho_curvature = 0.5
482 * (lambda * trace_inverse_penalty - lambda * lambda * trace_inverse_penalty_squared)
483 + 0.5
484 * self.residual_df
485 * ((lambda_energy - 2.0 * lambda * lambda * curvature_energy) / penalized_deviance
486 - (lambda_energy / penalized_deviance).powi(2));
487 let edf = tangent_dim as f64 - lambda * trace_inverse_penalty;
488 Ok(AffineFaceEvaluation {
489 rho,
490 lambda,
491 score,
492 rho_gradient,
493 rho_curvature,
494 edf,
495 beta,
496 fitted,
497 })
498 }
499}
500
501fn tangent_penalty_geometry(
502 penalty: &Array2<f64>,
503) -> Result<TangentPenaltyGeometry, EstimationError> {
504 if penalty.is_empty() {
505 return Ok(TangentPenaltyGeometry {
506 rank: 0,
507 logdet: 0.0,
508 pseudoinverse: Array2::zeros(penalty.dim()),
509 });
510 }
511 let (eigenvalues, eigenvectors) = penalty
512 .eigh(Side::Lower)
513 .map_err(EstimationError::EigendecompositionFailed)?;
514 let scale = eigenvalues
515 .iter()
516 .fold(0.0_f64, |maximum, &value| maximum.max(value.abs()));
517 let tolerance =
518 default_rrqr_rank_alpha() * f64::EPSILON * penalty.nrows().max(1) as f64 * scale;
519 let mut rank = 0usize;
520 let mut logdet = 0.0;
521 let mut scaled_eigenvectors = Array2::<f64>::zeros(eigenvectors.dim());
522 for (index, &value) in eigenvalues.iter().enumerate() {
523 if !value.is_finite() {
524 return Err(EstimationError::PenaltySpectrumNonFinite {
525 context: "constrained Gaussian REML tangent penalty".to_string(),
526 index,
527 value,
528 });
529 }
530 if value < -tolerance {
531 return Err(EstimationError::PenaltySpectrumIndefinite {
532 context: "constrained Gaussian REML tangent penalty".to_string(),
533 index,
534 value,
535 tolerance,
536 scale,
537 });
538 }
539 if value > tolerance {
540 rank += 1;
541 logdet += value.ln();
542 for row in 0..eigenvectors.nrows() {
543 scaled_eigenvectors[[row, index]] = eigenvectors[[row, index]] / value;
544 }
545 }
546 }
547 Ok(TangentPenaltyGeometry {
548 rank,
549 logdet,
550 pseudoinverse: scaled_eigenvectors.dot(&eigenvectors.t()),
551 })
552}
553
554fn optimize_affine_face(
555 profile: &AffineFaceProfile,
556 initial_rho: f64,
557) -> Result<AffineFaceEvaluation, EstimationError> {
558 let bound = crate::estimate::RHO_BOUND;
559 let seed_rho = initial_rho.clamp(-bound, bound);
560 let seed = profile.evaluate(seed_rho)?;
561 let seed_point = Array1::from_vec(vec![seed_rho]);
562 let initial_sample = FirstOrderSample {
563 value: seed.score,
564 gradient: Array1::from_vec(vec![seed.rho_gradient]),
565 };
566 let objective_profile = profile.clone();
567 let objective = FusedObjective::new(move |point: &Array1<f64>| {
568 objective_profile
569 .evaluate(point[0])
570 .map(|evaluation| FirstOrderSample {
571 value: evaluation.score,
572 gradient: Array1::from_vec(vec![evaluation.rho_gradient]),
573 })
574 .map_err(|error| ObjectiveEvalError::fatal(error.to_string()))
575 });
576 let bound_resolution = f64::EPSILON.sqrt();
577 let stationarity_resolution = rho_stationarity_resolution();
578 let bounds = Bounds::new(
579 Array1::from_vec(vec![-bound]),
580 Array1::from_vec(vec![bound]),
581 bound_resolution,
582 )
583 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
584 let mut optimizer = Bfgs::new(seed_point.clone(), objective)
585 .with_initial_sample(seed_point, initial_sample)
586 .with_bounds(bounds)
587 .with_gradient_tolerance(GradientTolerance::relative_to_cost(stationarity_resolution));
588 let solution = optimizer.run().map_err(|error| {
589 EstimationError::RemlOptimizationFailed(format!(
590 "affine-face Gaussian REML optimization did not converge: {error}"
591 ))
592 })?;
593 let mut accepted = polish_affine_rho(profile, solution.final_point[0])?;
594 for endpoint in [-bound, bound] {
595 let candidate = profile.evaluate(endpoint)?;
596 let projected_stationary = (endpoint < 0.0 && candidate.rho_gradient >= 0.0)
597 || (endpoint > 0.0 && candidate.rho_gradient <= 0.0);
598 if projected_stationary && candidate.score < accepted.score {
599 accepted = candidate;
600 }
601 }
602 Ok(accepted)
603}
604
605fn polish_affine_rho(
606 profile: &AffineFaceProfile,
607 initial_rho: f64,
608) -> Result<AffineFaceEvaluation, EstimationError> {
609 let bound = crate::estimate::RHO_BOUND;
610 let resolution = rho_stationarity_resolution();
611 let bound_resolution = f64::EPSILON.sqrt();
612 let mut rho = initial_rho.clamp(-bound, bound);
613 loop {
614 let current = profile.evaluate(rho)?;
615 let at_lower = rho <= -bound + bound_resolution * bound.max(1.0);
616 let at_upper = rho >= bound - bound_resolution * bound.max(1.0);
617 if (at_lower && current.rho_gradient >= 0.0) || (at_upper && current.rho_gradient <= 0.0) {
618 return Ok(current);
619 }
620 let curvature_resolution = resolution * (1.0 + current.rho_gradient.abs());
621 if !current.rho_curvature.is_finite() || current.rho_curvature <= curvature_resolution {
622 return Err(EstimationError::GradientUnavailable {
623 context: "constrained Gaussian REML forward",
624 mode: "affine-face smoothing optimum has unresolved positive curvature",
625 });
626 }
627 if current.rho_gradient.abs() <= resolution * (1.0 + current.score.abs()) {
628 return Ok(current);
629 }
630 let mut candidate_rho =
631 (rho - current.rho_gradient / current.rho_curvature).clamp(-bound, bound);
632 if candidate_rho.to_bits() == rho.to_bits() {
633 return Err(EstimationError::GradientUnavailable {
634 context: "constrained Gaussian REML forward",
635 mode: "affine-face smoothing root is below floating-point resolution",
636 });
637 }
638 let mut candidate = profile.evaluate(candidate_rho)?;
639 while candidate.score >= current.score {
640 candidate_rho = 0.5 * (rho + candidate_rho);
641 if candidate_rho.to_bits() == rho.to_bits() {
642 return Err(EstimationError::GradientUnavailable {
643 context: "constrained Gaussian REML forward",
644 mode: "affine-face smoothing polish cannot resolve a descent step",
645 });
646 }
647 candidate = profile.evaluate(candidate_rho)?;
648 }
649 rho = candidate_rho;
650 }
651}
652
653fn rho_stationarity_resolution() -> f64 {
654 let central_difference_scale = f64::EPSILON.cbrt();
655 central_difference_scale * central_difference_scale
656}
657
658pub fn constrained_gaussian_reml_backward(
660 problem: ConstrainedGaussianRemlBackwardProblem<'_>,
661) -> Result<GaussianRemlBackwardResult, EstimationError> {
662 validate_problem(&problem)?;
663 let constraints = LinearInequalityConstraints::new(
664 problem.a_inequality.to_owned(),
665 problem.b_inequality.to_owned(),
666 )
667 .and_then(|constraints| constraints.canonicalized())
668 .map_err(EstimationError::InvalidInput)?;
669 let face = active_face_from_parts(
670 constraints.a.view(),
671 constraints.b.view(),
672 problem.active_indices,
673 problem.x.ncols(),
674 )?;
675 let state = face_state(&problem, &face)?;
676 certify_strict_complementarity(&problem, &state, &face, &constraints)?;
677
678 let n = problem.x.nrows();
679 let p = problem.x.ncols();
680 let d = problem.y.ncols();
681 let mut out = VjpAccumulator::zeros(n, p, d);
682
683 let mut mode_seed = Array2::<f64>::zeros((p, d));
685 if let Some(seed) = problem.grad_coefficients {
686 mode_seed += &seed;
687 }
688 if let Some(seed) = problem.grad_fitted {
689 mode_seed += &problem.x.t().dot(&seed);
690 out.x += &seed.dot(&state.beta.t());
691 }
692 add_mode_vjp(&problem, &state, mode_seed.view(), 1.0, &mut out);
693
694 out.lambda += problem.grad_lambda;
695 out.lambda += problem.grad_log_lambda / problem.lambda;
696
697 if problem.grad_reml_score != 0.0 {
698 add_score_vjp(&problem, &state, problem.grad_reml_score, &mut out);
699 }
700 if problem.grad_edf != 0.0 {
701 add_face_edf_vjp(&problem, &state, problem.grad_edf, &mut out);
702 }
703
704 if out.lambda != 0.0 && !rho_is_box_active(problem.lambda) {
708 let curvature = rho_score_curvature(&problem, &state);
709 let scale = rho_curvature_scale(&problem, &state);
710 let resolution = f64::EPSILON.sqrt() * ((n + p + d).max(1) as f64).sqrt() * scale.max(1.0);
711 if !curvature.is_finite() || curvature <= resolution {
712 return Err(EstimationError::GradientUnavailable {
713 context: "constrained Gaussian REML backward",
714 mode: "active-face smoothing root has unresolved curvature",
715 });
716 }
717 let root_seed = -problem.lambda * out.lambda / curvature;
718 add_rho_score_vjp(&problem, &state, root_seed, &mut out);
721 }
722
723 symmetrize_in_place(&mut out.penalty);
724 Ok(GaussianRemlBackwardResult {
725 grad_x: out.x,
726 grad_y: out.y,
727 grad_penalty: out.penalty,
728 grad_weights: out.weights,
729 })
730}
731
732fn validate_problem(
733 problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
734) -> Result<(), EstimationError> {
735 let n = problem.x.nrows();
736 let p = problem.x.ncols();
737 let d = problem.y.ncols();
738 if d != 1 {
739 crate::bail_invalid_estim!(
740 "constrained Gaussian REML backward requires one response column; got {d}"
741 );
742 }
743 if problem.y.nrows() != n
744 || problem.penalty.dim() != (p, p)
745 || problem.coefficients.dim() != (p, d)
746 || problem.a_inequality.ncols() != p
747 || problem.b_inequality.len() != problem.a_inequality.nrows()
748 {
749 crate::bail_invalid_estim!(
750 "constrained Gaussian REML backward input shapes are inconsistent"
751 );
752 }
753 if problem.active_indices.is_empty() {
754 crate::bail_invalid_estim!(
755 "constrained Gaussian REML active-face backward requires a non-empty active set"
756 );
757 }
758 if let Some(weights) = problem.weights
759 && weights.len() != n
760 {
761 crate::bail_invalid_estim!(
762 "constrained Gaussian REML weights length {} does not match row count {n}",
763 weights.len()
764 );
765 }
766 if let Some(seed) = problem.grad_coefficients
767 && seed.dim() != (p, d)
768 {
769 crate::bail_invalid_estim!(
770 "constrained Gaussian REML coefficient cotangent shape mismatch"
771 );
772 }
773 if let Some(seed) = problem.grad_fitted
774 && seed.dim() != (n, d)
775 {
776 crate::bail_invalid_estim!("constrained Gaussian REML fitted cotangent shape mismatch");
777 }
778 if !problem.lambda.is_finite() || problem.lambda <= 0.0 {
779 crate::bail_invalid_estim!(
780 "constrained Gaussian REML backward requires a positive finite lambda"
781 );
782 }
783 let all_finite = problem
784 .x
785 .iter()
786 .chain(problem.y.iter())
787 .chain(problem.penalty.iter())
788 .chain(problem.coefficients.iter())
789 .chain(problem.a_inequality.iter())
790 .chain(problem.b_inequality.iter())
791 .all(|value| value.is_finite());
792 if !all_finite {
793 crate::bail_invalid_estim!("constrained Gaussian REML backward inputs must be finite");
794 }
795 if let Some(weights) = problem.weights
796 && weights
797 .iter()
798 .any(|value| !value.is_finite() || *value < 0.0)
799 {
800 crate::bail_invalid_estim!(
801 "constrained Gaussian REML weights must be finite and non-negative"
802 );
803 }
804 Ok(())
805}
806
807fn active_face_from_parts(
808 a_inequality: ArrayView2<'_, f64>,
809 b_inequality: ArrayView1<'_, f64>,
810 active_indices: ArrayView1<'_, u64>,
811 p: usize,
812) -> Result<ActiveFace, EstimationError> {
813 let mut seen = vec![false; a_inequality.nrows()];
814 let mut active = Array2::<f64>::zeros((active_indices.len(), p));
815 let mut active_bounds = Array1::<f64>::zeros(active_indices.len());
816 for (row, &raw_index) in active_indices.iter().enumerate() {
817 let index = raw_index as usize;
818 if index >= a_inequality.nrows() {
819 crate::bail_invalid_estim!(
820 "constrained Gaussian REML active index {index} is out of range"
821 );
822 }
823 if seen[index] {
824 crate::bail_invalid_estim!(
825 "constrained Gaussian REML active index {index} occurs more than once"
826 );
827 }
828 seen[index] = true;
829 active.row_mut(row).assign(&a_inequality.row(index));
830 active_bounds[row] = b_inequality[index];
831 }
832
833 let active_t = active.t().to_owned();
836 let rrqr = rrqr_with_permutation(&active_t, default_rrqr_rank_alpha())
837 .map_err(EstimationError::LinearSystemSolveFailed)?;
838 if rrqr.rank == 0 {
839 return Err(EstimationError::GradientUnavailable {
840 context: "constrained Gaussian REML backward",
841 mode: "active constraint rows have zero numerical rank",
842 });
843 }
844 let mut a = Array2::<f64>::zeros((rrqr.rank, p));
845 let mut b = Array1::<f64>::zeros(rrqr.rank);
846 for (row, &source) in rrqr.column_permutation.iter().take(rrqr.rank).enumerate() {
847 a.row_mut(row).assign(&active.row(source));
848 b[row] = active_bounds[source];
849 }
850 let (z, rank) = rrqr_nullspace_basis(&a.t().to_owned(), default_rrqr_rank_alpha())
851 .map_err(EstimationError::LinearSystemSolveFailed)?;
852 if rank != rrqr.rank {
853 return Err(EstimationError::GradientUnavailable {
854 context: "constrained Gaussian REML backward",
855 mode: "active-face rank certificate is inconsistent",
856 });
857 }
858 Ok(ActiveFace { a, b, z })
859}
860
861fn face_state(
862 problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
863 face: &ActiveFace,
864) -> Result<FaceState, EstimationError> {
865 let n = problem.x.nrows();
866 let p = problem.x.ncols();
867 let penalty = symmetric_average(problem.penalty);
868 let weights = problem
869 .weights
870 .map_or_else(|| Array1::ones(n), |values| values.to_owned());
871 let beta = problem.coefficients.to_owned();
872 let fitted = problem.x.dot(&beta);
873 let residual = problem.y.to_owned() - &fitted;
874 let weighted_x = &problem.x.to_owned() * &weights.view().insert_axis(Axis(1));
875 let gram = problem.x.t().dot(&weighted_x);
876 let hessian = &gram + &(penalty.clone() * problem.lambda);
877
878 let k = face.z.ncols();
879 let (p_response, q_penalty, penalty_rank) = if k == 0 {
880 (Array2::zeros((p, p)), Array2::zeros((p, p)), 0)
881 } else {
882 let tangent_hessian = face.z.t().dot(&hessian).dot(&face.z);
883 let inverse = tangent_hessian
884 .cholesky(Side::Lower)
885 .map_err(EstimationError::LinearSystemSolveFailed)?
886 .solve_mat(&Array2::<f64>::eye(k));
887 let p_response = face.z.dot(&inverse).dot(&face.z.t());
888 let tangent_penalty = face.z.t().dot(&penalty).dot(&face.z);
889 let penalty_geometry = tangent_penalty_geometry(&tangent_penalty)?;
890 let q_penalty = face.z.dot(&penalty_geometry.pseudoinverse).dot(&face.z.t());
891 (p_response, q_penalty, penalty_geometry.rank)
892 };
893 let n_effective = weights.iter().filter(|&&value| value > 0.0).count();
894 let nullity = k.saturating_sub(penalty_rank);
895 if n_effective <= nullity {
896 crate::bail_invalid_estim!(
897 "constrained Gaussian REML requires more positive-weight rows than tangent penalty nullity"
898 );
899 }
900 Ok(FaceState {
901 penalty,
902 weights,
903 beta,
904 residual,
905 gram,
906 p_response,
907 q_penalty,
908 penalty_rank,
909 residual_df: (n_effective - nullity) as f64,
910 })
911}
912
913fn certify_strict_complementarity(
914 problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
915 state: &FaceState,
916 face: &ActiveFace,
917 constraints: &LinearInequalityConstraints,
918) -> Result<(), EstimationError> {
919 let p = problem.x.ncols();
920 let beta = state.beta.column(0);
921 let beta_scale = beta
922 .iter()
923 .fold(1.0_f64, |scale, &value| scale.max(value.abs()));
924 let mut active_mask = vec![false; constraints.a.nrows()];
925 for &index in problem.active_indices {
926 active_mask[index as usize] = true;
927 }
928 for row in 0..constraints.a.nrows() {
929 let a = constraints.a.row(row);
930 if a.iter().all(|&value| value == 0.0) {
931 continue;
932 }
933 let slack = a.dot(&beta) - constraints.b[row];
934 let tolerance = crate::pirls::ACTIVE_SET_PRIMAL_FEASIBILITY_TOL
935 * beta_scale.max(constraints.b[row].abs().max(1.0));
936 if active_mask[row] {
937 if slack.abs() > tolerance {
938 return Err(EstimationError::GradientUnavailable {
939 context: "constrained Gaussian REML backward",
940 mode: "reported active row is not on the accepted face",
941 });
942 }
943 } else if slack <= tolerance {
944 return Err(EstimationError::GradientUnavailable {
945 context: "constrained Gaussian REML backward",
946 mode: "inactive constraint is not strictly separated from the face",
947 });
948 }
949 }
950
951 let weighted_residual = &state.residual.column(0) * &state.weights;
953 let gradient =
954 -problem.x.t().dot(&weighted_residual) + problem.lambda * state.penalty.dot(&beta);
955 let normal_gram = face.a.dot(&face.a.t());
956 let normal_inverse = normal_gram
957 .cholesky(Side::Lower)
958 .map_err(EstimationError::LinearSystemSolveFailed)?
959 .solve_mat(&Array2::<f64>::eye(face.a.nrows()));
960 let multipliers = normal_inverse.dot(&face.a.dot(&gradient));
961 let reconstructed = face.a.t().dot(&multipliers);
962 let residual = &gradient - &reconstructed;
963 let residual_inf = residual
964 .iter()
965 .fold(0.0_f64, |scale, &value| scale.max(value.abs()));
966 let gradient_scale = gradient
967 .iter()
968 .chain(reconstructed.iter())
969 .fold(1.0_f64, |scale, &value| scale.max(value.abs()));
970 let arithmetic_uncertainty =
971 f64::EPSILON * ((p + face.a.nrows()).max(1) as f64) * gradient_scale;
972 let normal_left_inverse = normal_inverse.dot(&face.a);
973 let inverse_inf_norm = normal_left_inverse
974 .rows()
975 .into_iter()
976 .map(|row| row.iter().map(|value| value.abs()).sum::<f64>())
977 .fold(0.0_f64, f64::max);
978 let solver_uncertainty = crate::pirls::ACTIVE_SET_PRIMAL_FEASIBILITY_TOL * gradient_scale;
985 let multiplier_uncertainty =
986 inverse_inf_norm * (residual_inf + arithmetic_uncertainty + solver_uncertainty);
987 if multipliers
988 .iter()
989 .any(|&value| !value.is_finite() || value <= multiplier_uncertainty)
990 {
991 return Err(EstimationError::GradientUnavailable {
992 context: "constrained Gaussian REML backward",
993 mode: "active constraint is weakly active; the derivative is set-valued",
994 });
995 }
996 Ok(())
997}
998
999fn add_mode_vjp(
1000 problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1001 state: &FaceState,
1002 seed: ArrayView2<'_, f64>,
1003 scale: f64,
1004 out: &mut VjpAccumulator,
1005) {
1006 if scale == 0.0 || seed.iter().all(|&value| value == 0.0) {
1007 return;
1008 }
1009 let u = state.p_response.dot(&seed) * scale;
1010 let xu = problem.x.dot(&u);
1011 let weighted_residual = &state.residual * &state.weights.view().insert_axis(Axis(1));
1012 let weighted_xu = &xu * &state.weights.view().insert_axis(Axis(1));
1013 out.y += &weighted_xu;
1014 out.x += &weighted_residual.dot(&u.t());
1015 out.x -= &weighted_xu.dot(&state.beta.t());
1016 out.weights += &(&xu * &state.residual).sum_axis(Axis(1));
1017 let s_beta = state.penalty.dot(&state.beta);
1018 let raw_penalty = u.dot(&state.beta.t());
1019 out.penalty -= &(symmetric_average(raw_penalty.view()) * problem.lambda);
1020 out.lambda -= (&u * &s_beta).sum();
1021}
1022
1023fn add_score_vjp(
1024 problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1025 state: &FaceState,
1026 seed: f64,
1027 out: &mut VjpAccumulator,
1028) {
1029 let d = problem.y.ncols() as f64;
1030 let wxp =
1031 (&problem.x.to_owned() * &state.weights.view().insert_axis(Axis(1))).dot(&state.p_response);
1032 out.x += &(wxp * (seed * d));
1033 out.weights += &(row_quadratic(problem.x, &state.p_response) * (0.5 * seed * d));
1034 out.penalty += &((&state.p_response * problem.lambda - &state.q_penalty) * (0.5 * seed * d));
1035 let trace_ps = trace_product(&state.p_response, &state.penalty);
1036 out.lambda += seed * 0.5 * d * (trace_ps - state.penalty_rank as f64 / problem.lambda);
1037
1038 for output in 0..problem.y.ncols() {
1039 let residual = state.residual.column(output);
1040 let beta = state.beta.column(output);
1041 let weighted_rss = residual.dot(&(&residual * &state.weights));
1042 let s_beta = state.penalty.dot(&beta);
1043 let energy = beta.dot(&s_beta);
1044 let deviance = weighted_rss + problem.lambda * energy;
1045 let tau = state.residual_df / deviance;
1046 let weighted_residual = &residual * &state.weights;
1047 out.y
1048 .column_mut(output)
1049 .scaled_add(seed * tau, &weighted_residual);
1050 let x_term = weighted_residual
1051 .insert_axis(Axis(1))
1052 .dot(&beta.insert_axis(Axis(0)));
1053 out.x -= &(x_term * (seed * tau));
1054 out.weights += &(&residual * &residual * (0.5 * seed * tau));
1055 let beta_outer = beta.insert_axis(Axis(1)).dot(&beta.insert_axis(Axis(0)));
1056 out.penalty += &(beta_outer * (0.5 * seed * tau * problem.lambda));
1057 out.lambda += 0.5 * seed * tau * energy;
1058 }
1059}
1060
1061fn add_face_edf_vjp(
1062 problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1063 state: &FaceState,
1064 seed: f64,
1065 out: &mut VjpAccumulator,
1066) {
1067 let pgp = state.p_response.dot(&state.gram).dot(&state.p_response);
1068 let gram_seed = &state.p_response - &pgp;
1069 add_gram_vjp(problem.x, &state.weights, &gram_seed, seed, out);
1070 out.penalty -= &(pgp.clone() * (seed * problem.lambda));
1071 out.lambda -= seed * trace_product(&pgp, &state.penalty);
1072}
1073
1074fn add_rho_score_vjp(
1075 problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1076 state: &FaceState,
1077 seed: f64,
1078 out: &mut VjpAccumulator,
1079) {
1080 if seed == 0.0 {
1081 return;
1082 }
1083 let psp = state.p_response.dot(&state.penalty).dot(&state.p_response);
1084 let gram_seed = psp.clone() * (-0.5 * seed * problem.lambda * problem.y.ncols() as f64);
1085 add_gram_vjp(problem.x, &state.weights, &gram_seed, 1.0, out);
1086 out.penalty += &((&state.p_response - &(psp * problem.lambda))
1087 * (0.5 * seed * problem.lambda * problem.y.ncols() as f64));
1088
1089 for output in 0..problem.y.ncols() {
1090 let beta = state.beta.column(output);
1091 let residual = state.residual.column(output);
1092 let s_beta = state.penalty.dot(&beta);
1093 let energy = beta.dot(&s_beta);
1094 let weighted_rss = residual.dot(&(&residual * &state.weights));
1095 let deviance = weighted_rss + problem.lambda * energy;
1096 let energy_seed = 0.5 * seed * state.residual_df * problem.lambda / deviance;
1097 let beta_seed = s_beta.insert_axis(Axis(1)) * (2.0 * energy_seed);
1098 add_mode_vjp(problem, state, beta_seed.view(), 1.0, out);
1099 let beta_outer = beta.insert_axis(Axis(1)).dot(&beta.insert_axis(Axis(0)));
1100 out.penalty += &(&beta_outer * energy_seed);
1101
1102 let deviance_seed =
1103 -0.5 * seed * state.residual_df * problem.lambda * energy / (deviance * deviance);
1104 let weighted_residual = &residual * &state.weights;
1105 out.y
1106 .column_mut(output)
1107 .scaled_add(2.0 * deviance_seed, &weighted_residual);
1108 let x_term = weighted_residual
1109 .insert_axis(Axis(1))
1110 .dot(&beta.insert_axis(Axis(0)));
1111 out.x -= &(x_term * (2.0 * deviance_seed));
1112 out.weights += &(&residual * &residual * deviance_seed);
1113 out.penalty += &(beta_outer * (deviance_seed * problem.lambda));
1114 }
1115}
1116
1117fn rho_score_curvature(
1118 problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1119 state: &FaceState,
1120) -> f64 {
1121 let ps = state.p_response.dot(&state.penalty);
1122 let trace_ps = trace(&ps);
1123 let trace_psps = trace(&ps.dot(&ps));
1124 let mut value = 0.5
1125 * problem.y.ncols() as f64
1126 * (problem.lambda * trace_ps - problem.lambda * problem.lambda * trace_psps);
1127 for output in 0..problem.y.ncols() {
1128 let beta = state.beta.column(output);
1129 let residual = state.residual.column(output);
1130 let s_beta = state.penalty.dot(&beta);
1131 let energy = beta.dot(&s_beta);
1132 let curvature_energy = s_beta.dot(&state.p_response.dot(&s_beta));
1133 let deviance = residual.dot(&(&residual * &state.weights)) + problem.lambda * energy;
1134 let first = problem.lambda * energy;
1135 value += 0.5
1136 * state.residual_df
1137 * ((first - 2.0 * problem.lambda * problem.lambda * curvature_energy) / deviance
1138 - (first / deviance).powi(2));
1139 }
1140 value
1141}
1142
1143fn rho_curvature_scale(
1144 problem: &ConstrainedGaussianRemlBackwardProblem<'_>,
1145 state: &FaceState,
1146) -> f64 {
1147 let ps = state.p_response.dot(&state.penalty);
1148 let mut scale = problem.y.ncols() as f64 * (problem.lambda * trace(&ps)).abs()
1149 + problem.y.ncols() as f64 * (problem.lambda * problem.lambda * trace(&ps.dot(&ps))).abs();
1150 for output in 0..problem.y.ncols() {
1151 let beta = state.beta.column(output);
1152 let residual = state.residual.column(output);
1153 let s_beta = state.penalty.dot(&beta);
1154 let energy = beta.dot(&s_beta);
1155 let curvature_energy = s_beta.dot(&state.p_response.dot(&s_beta));
1156 let deviance = residual.dot(&(&residual * &state.weights)) + problem.lambda * energy;
1157 scale += state.residual_df
1158 * ((problem.lambda * energy / deviance).abs()
1159 + (2.0 * problem.lambda * problem.lambda * curvature_energy / deviance).abs()
1160 + (problem.lambda * energy / deviance).powi(2));
1161 }
1162 scale
1163}
1164
1165fn add_gram_vjp(
1166 x: ArrayView2<'_, f64>,
1167 weights: &Array1<f64>,
1168 gram_seed: &Array2<f64>,
1169 scale: f64,
1170 out: &mut VjpAccumulator,
1171) {
1172 let symmetric = symmetric_average(gram_seed.view());
1173 let x_seed = x.dot(&symmetric) * (2.0 * scale);
1174 out.x += &(&x_seed * &weights.view().insert_axis(Axis(1)));
1175 out.weights += &(row_quadratic(x, &symmetric) * scale);
1176}
1177
1178fn row_quadratic(x: ArrayView2<'_, f64>, matrix: &Array2<f64>) -> Array1<f64> {
1179 let xm = x.dot(matrix);
1180 (&xm * &x).sum_axis(Axis(1))
1181}
1182
1183fn symmetric_average(matrix: ArrayView2<'_, f64>) -> Array2<f64> {
1184 (&matrix + &matrix.t()) * 0.5
1185}
1186
1187fn symmetrize_in_place(matrix: &mut Array2<f64>) {
1188 for row in 0..matrix.nrows() {
1189 for col in (row + 1)..matrix.ncols() {
1190 let value = 0.5 * (matrix[[row, col]] + matrix[[col, row]]);
1191 matrix[[row, col]] = value;
1192 matrix[[col, row]] = value;
1193 }
1194 }
1195}
1196
1197fn trace(matrix: &Array2<f64>) -> f64 {
1198 matrix.diag().sum()
1199}
1200
1201fn trace_product(left: &Array2<f64>, right: &Array2<f64>) -> f64 {
1202 (&left.t() * right).sum()
1203}
1204
1205fn rho_is_box_active(lambda: f64) -> bool {
1206 let rho = lambda.ln();
1207 let bound = crate::estimate::RHO_BOUND;
1208 rho.abs() >= bound - f64::EPSILON * bound.max(1.0)
1209}
1210
1211#[cfg(test)]
1212mod tests {
1213 use super::*;
1214 use ndarray::{array, s};
1215
1216 struct Fixture {
1217 x: Array2<f64>,
1218 y: Array2<f64>,
1219 penalty: Array2<f64>,
1220 weights: Array1<f64>,
1221 constraints: LinearInequalityConstraints,
1222 }
1223
1224 fn affine_fixture() -> Fixture {
1225 let n = 16usize;
1226 let mut x = Array2::<f64>::zeros((n, 3));
1227 let mut y = Array2::<f64>::zeros((n, 1));
1228 let mut weights = Array1::<f64>::zeros(n);
1229 for row in 0..n {
1230 let t = -1.0 + 2.0 * row as f64 / (n - 1) as f64;
1231 x[[row, 0]] = 1.0;
1232 x[[row, 1]] = t;
1233 x[[row, 2]] = t * t;
1234 y[[row, 0]] = 0.2 - 0.4 * t - 1.8 * t * t + 0.02 * (3.0 * t).sin();
1235 weights[row] = 0.7 + 0.6 * row as f64 / (n - 1) as f64;
1236 }
1237 let penalty = array![[0.30, 0.05, 0.08], [0.05, 1.00, 0.12], [0.08, 0.12, 1.20]];
1238 let constraints =
1239 LinearInequalityConstraints::new(array![[0.0, 0.0, 1.0]], array![0.1]).unwrap();
1240 Fixture {
1241 x,
1242 y,
1243 penalty,
1244 weights,
1245 constraints,
1246 }
1247 }
1248
1249 fn fit_fixture(fixture: &Fixture) -> ConstrainedGaussianRemlForwardResult {
1250 constrained_gaussian_reml_forward(ConstrainedGaussianRemlForwardProblem {
1251 x: fixture.x.view(),
1252 y: fixture.y.view(),
1253 penalty: fixture.penalty.view(),
1254 weights: Some(fixture.weights.view()),
1255 constraints: Some(&fixture.constraints),
1256 init_lambda: None,
1257 })
1258 .unwrap_or_else(|error| panic!("affine constrained REML fit failed: {error}"))
1259 }
1260
1261 fn scalar_loss(result: &ConstrainedGaussianRemlForwardResult) -> f64 {
1262 let coefficient_seed = array![[0.7], [-0.3], [1.1]];
1263 let fitted_seed = Array2::from_shape_fn(result.fitted.dim(), |(row, _)| {
1264 0.2 + row as f64 / result.fitted.nrows() as f64
1265 });
1266 (&result.coefficients * &coefficient_seed).sum()
1267 + (&result.fitted * &fitted_seed).sum()
1268 + 0.23 * result.lambda
1269 + 0.41 * result.lambda.ln()
1270 + 0.67 * result.reml_score
1271 - 0.29 * result.edf
1272 }
1273
1274 fn finite_difference_step(value: f64) -> f64 {
1275 f64::EPSILON.cbrt() * value.abs().max(1.0)
1276 }
1277
1278 fn assert_fd(analytic: f64, numerical: f64, label: &str) {
1279 let tolerance = 64.0 * f64::EPSILON.cbrt() * (1.0 + analytic.abs().max(numerical.abs()));
1280 assert!(
1281 (analytic - numerical).abs() <= tolerance,
1282 "{label}: analytic={analytic:.12e}, numerical={numerical:.12e}, tolerance={tolerance:.3e}"
1283 );
1284 }
1285
1286 #[test]
1287 fn active_nonzero_affine_face_vjp_matches_central_differences() {
1288 let fixture = affine_fixture();
1289 let fit = fit_fixture(&fixture);
1290 assert_eq!(fit.active_indices.as_slice().unwrap(), &[0]);
1291 assert!((fit.coefficients[[2, 0]] - 0.1).abs() <= 1.0e-8);
1292
1293 let coefficient_seed = array![[0.7], [-0.3], [1.1]];
1294 let fitted_seed = Array2::from_shape_fn(fit.fitted.dim(), |(row, _)| {
1295 0.2 + row as f64 / fit.fitted.nrows() as f64
1296 });
1297 let backward = constrained_gaussian_reml_backward(ConstrainedGaussianRemlBackwardProblem {
1298 x: fixture.x.view(),
1299 y: fixture.y.view(),
1300 penalty: fixture.penalty.view(),
1301 weights: Some(fixture.weights.view()),
1302 a_inequality: fixture.constraints.a.view(),
1303 b_inequality: fixture.constraints.b.view(),
1304 active_indices: fit.active_indices.view(),
1305 lambda: fit.lambda,
1306 coefficients: fit.coefficients.view(),
1307 grad_coefficients: Some(coefficient_seed.view()),
1308 grad_fitted: Some(fitted_seed.view()),
1309 grad_lambda: 0.23,
1310 grad_log_lambda: 0.41,
1311 grad_reml_score: 0.67,
1312 grad_edf: -0.29,
1313 })
1314 .unwrap_or_else(|error| panic!("affine constrained REML backward failed: {error}"));
1315
1316 for row in 0..fixture.x.nrows() {
1317 for col in 0..fixture.x.ncols() {
1318 let step = finite_difference_step(fixture.x[[row, col]]);
1319 let mut plus = affine_fixture();
1320 let mut minus = affine_fixture();
1321 plus.x[[row, col]] += step;
1322 minus.x[[row, col]] -= step;
1323 let numerical = (scalar_loss(&fit_fixture(&plus))
1324 - scalar_loss(&fit_fixture(&minus)))
1325 / (2.0 * step);
1326 assert_fd(backward.grad_x[[row, col]], numerical, "grad_x");
1327 }
1328 }
1329 for row in 0..fixture.y.nrows() {
1330 let step = finite_difference_step(fixture.y[[row, 0]]);
1331 let mut plus = affine_fixture();
1332 let mut minus = affine_fixture();
1333 plus.y[[row, 0]] += step;
1334 minus.y[[row, 0]] -= step;
1335 let numerical = (scalar_loss(&fit_fixture(&plus)) - scalar_loss(&fit_fixture(&minus)))
1336 / (2.0 * step);
1337 assert_fd(backward.grad_y[[row, 0]], numerical, "grad_y");
1338 }
1339 for row in 0..fixture.penalty.nrows() {
1340 for col in 0..fixture.penalty.ncols() {
1341 let step = finite_difference_step(fixture.penalty[[row, col]]);
1342 let mut plus = affine_fixture();
1343 let mut minus = affine_fixture();
1344 plus.penalty[[row, col]] += step;
1345 minus.penalty[[row, col]] -= step;
1346 let numerical = (scalar_loss(&fit_fixture(&plus))
1347 - scalar_loss(&fit_fixture(&minus)))
1348 / (2.0 * step);
1349 assert_fd(backward.grad_penalty[[row, col]], numerical, "grad_penalty");
1350 }
1351 }
1352 for row in 0..fixture.weights.len() {
1353 let step = finite_difference_step(fixture.weights[row]);
1354 let mut plus = affine_fixture();
1355 let mut minus = affine_fixture();
1356 plus.weights[row] += step;
1357 minus.weights[row] -= step;
1358 let numerical = (scalar_loss(&fit_fixture(&plus)) - scalar_loss(&fit_fixture(&minus)))
1359 / (2.0 * step);
1360 assert_fd(backward.grad_weights[row], numerical, "grad_weights");
1361 }
1362 }
1363
1364 #[test]
1365 fn weakly_active_exact_kkt_state_has_no_derivative() {
1366 let n = 24usize;
1367 let mut x = Array2::<f64>::zeros((n, 3));
1368 let mut cubic = Array1::<f64>::zeros(n);
1369 for row in 0..n {
1370 let t = -1.0 + 2.0 * row as f64 / (n - 1) as f64;
1371 x[[row, 0]] = 1.0;
1372 x[[row, 1]] = t;
1373 x[[row, 2]] = t * t;
1374 cubic[row] = t * t * t;
1375 }
1376 let slope_projection = x.column(1).dot(&cubic) / x.column(1).dot(&x.column(1));
1377 let orthogonal = &cubic - &(x.column(1).to_owned() * slope_projection);
1378 let mut y = Array2::<f64>::from_elem((n, 1), 0.3);
1379 y.slice_mut(s![.., 0]).scaled_add(0.1, &orthogonal);
1380 let beta = array![[0.3], [0.0], [0.0]];
1381 let penalty = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
1382 let weights = Array1::<f64>::ones(n);
1383 let a = array![[0.0, 0.0, 1.0]];
1384 let b = array![0.0];
1385 let active = array![0_u64];
1386 let seed = Array2::<f64>::ones((3, 1));
1387 let result = constrained_gaussian_reml_backward(ConstrainedGaussianRemlBackwardProblem {
1388 x: x.view(),
1389 y: y.view(),
1390 penalty: penalty.view(),
1391 weights: Some(weights.view()),
1392 a_inequality: a.view(),
1393 b_inequality: b.view(),
1394 active_indices: active.view(),
1395 lambda: 1.0,
1396 coefficients: beta.view(),
1397 grad_coefficients: Some(seed.view()),
1398 grad_fitted: None,
1399 grad_lambda: 0.0,
1400 grad_log_lambda: 0.0,
1401 grad_reml_score: 0.0,
1402 grad_edf: 0.0,
1403 });
1404 match result {
1405 Err(EstimationError::GradientUnavailable { mode, .. }) => {
1406 assert!(mode.contains("weakly active"), "unexpected mode: {mode}");
1407 }
1408 Err(error) => panic!("expected GradientUnavailable, got {error}"),
1409 Ok(_) => panic!("weakly active KKT state unexpectedly returned a gradient"),
1410 }
1411 }
1412}