1use gam_linalg::faer_ndarray::{FaerSvd, fast_ab};
2use gam_linalg::matrix::{
3 DenseDesignMatrix, DenseDesignOperator, DesignMatrix, FiniteSignedWeightsView, LinearOperator,
4};
5use ndarray::{Array1, Array2, ArrayViewMut2, s};
6use std::ops::Range;
7use std::sync::Arc;
8
9#[derive(Debug, Clone)]
15pub enum ScaleDesignError {
16 InvalidWeights { reason: String },
19 IncompatibleDimensions { reason: String },
21 NonFiniteInput { reason: String },
24 DegenerateDesign { reason: String },
27 RowMaterializationFailed { reason: String },
29 SvdFailed { reason: String },
32}
33
34impl_reason_error_boilerplate! {
35 ScaleDesignError {
36 InvalidWeights,
37 IncompatibleDimensions,
38 NonFiniteInput,
39 DegenerateDesign,
40 RowMaterializationFailed,
41 SvdFailed,
42 }
43}
44
45const COLUMN_TOL: f64 = 1e-12;
46const SCALE_DESIGN_TARGET_CHUNK_BYTES: usize = 8 * 1024 * 1024;
47const SCALE_PROJECTION_REPLAY_RCOND_FLOOR: f64 = 1e-8;
54const SCALE_PROJECTION_LEVERAGE_AMPLIFICATION: f64 = 1.0e8;
63const SCALE_OPERATOR_MATRIX_FREE_PCG_THRESHOLD: usize = 1_000_000;
70
71#[derive(Clone, Debug)]
72pub struct ScaleDeviationTransform {
73 pub projection_coef: Array2<f64>,
74 pub weighted_column_mean: Array1<f64>,
75 pub rescale: Array1<f64>,
76 pub non_intercept_start: usize,
77 pub projection_ridge_alpha: f64,
81}
82
83impl ScaleDeviationTransform {
84 pub fn identity(p_primary: usize, p_noise: usize, non_intercept_start: usize) -> Self {
100 ScaleDeviationTransform {
101 projection_coef: Array2::<f64>::zeros((p_primary, p_noise)),
102 weighted_column_mean: Array1::<f64>::zeros(p_noise),
103 rescale: Array1::<f64>::ones(p_noise),
104 non_intercept_start,
105 projection_ridge_alpha: 0.0,
106 }
107 }
108}
109
110pub fn scale_transform_from_payload(
116 projection: &Option<Vec<Vec<f64>>>,
117 center: &Option<Vec<f64>>,
118 scale: &Option<Vec<f64>>,
119 non_intercept_start: Option<usize>,
120 projection_ridge_alpha: Option<f64>,
121) -> Result<Option<ScaleDeviationTransform>, String> {
122 scale_transform_from_payload_typed(
123 projection,
124 center,
125 scale,
126 non_intercept_start,
127 projection_ridge_alpha,
128 )
129 .map_err(|e| e.to_string())
130}
131
132fn scale_transform_from_payload_typed(
133 projection: &Option<Vec<Vec<f64>>>,
134 center: &Option<Vec<f64>>,
135 scale: &Option<Vec<f64>>,
136 non_intercept_start: Option<usize>,
137 projection_ridge_alpha: Option<f64>,
138) -> Result<Option<ScaleDeviationTransform>, ScaleDesignError> {
139 match (projection, center, scale, non_intercept_start) {
140 (None, None, None, None) => Ok(None),
141 (Some(projection), Some(center), Some(scale), Some(non_intercept_start)) => {
142 let rows = projection.len();
143 let cols = center.len();
144 if cols != scale.len() {
145 return Err(ScaleDesignError::IncompatibleDimensions {
146 reason: "saved scale transform center/scale length mismatch".to_string(),
147 });
148 }
149 if rows == 0 && cols > 0 {
150 return Err(ScaleDesignError::DegenerateDesign {
151 reason: "saved scale transform projection has zero rows".to_string(),
152 });
153 }
154 let mut projection_coef = Array2::<f64>::zeros((rows, cols));
155 for (i, row) in projection.iter().enumerate() {
156 if row.len() != cols {
157 return Err(ScaleDesignError::IncompatibleDimensions {
158 reason: "saved scale transform projection width mismatch".to_string(),
159 });
160 }
161 for (j, &value) in row.iter().enumerate() {
162 projection_coef[[i, j]] = value;
163 }
164 }
165 let Some(projection_ridge_alpha) = projection_ridge_alpha else {
166 return Err(ScaleDesignError::DegenerateDesign {
167 reason:
168 "saved scale transform payload is missing projection_ridge_alpha; refit"
169 .to_string(),
170 });
171 };
172 if !projection_ridge_alpha.is_finite() || projection_ridge_alpha < 0.0 {
173 return Err(ScaleDesignError::NonFiniteInput {
174 reason: format!(
175 "saved scale transform projection_ridge_alpha must be finite and non-negative, got {projection_ridge_alpha}"
176 ),
177 });
178 }
179 Ok(Some(ScaleDeviationTransform {
180 projection_coef,
181 weighted_column_mean: Array1::from_vec(center.clone()),
182 rescale: Array1::from_vec(scale.clone()),
183 non_intercept_start,
184 projection_ridge_alpha,
185 }))
186 }
187 _ => Err(ScaleDesignError::DegenerateDesign {
188 reason: "saved scale transform payload is only partially populated; refit".to_string(),
189 }),
190 }
191}
192
193#[derive(Clone, Copy)]
194enum ScaleDesignMatrixRef<'a> {
195 Dense(&'a Array2<f64>),
196 Design(&'a DesignMatrix),
197}
198
199impl ScaleDesignMatrixRef<'_> {
200 #[inline]
201 fn nrows(self) -> usize {
202 match self {
203 Self::Dense(matrix) => matrix.nrows(),
204 Self::Design(matrix) => matrix.nrows(),
205 }
206 }
207
208 #[inline]
209 fn ncols(self) -> usize {
210 match self {
211 Self::Dense(matrix) => matrix.ncols(),
212 Self::Design(matrix) => matrix.ncols(),
213 }
214 }
215
216 fn row_chunk(self, rows: Range<usize>) -> Result<Array2<f64>, ScaleDesignError> {
217 match self {
218 Self::Dense(matrix) => Ok(matrix.slice(s![rows, ..]).to_owned()),
219 Self::Design(matrix) => {
220 matrix
221 .try_row_chunk(rows)
222 .map_err(|e| ScaleDesignError::RowMaterializationFailed {
223 reason: format!("scale deviation row materialization failed: {e}"),
224 })
225 }
226 }
227 }
228}
229
230pub fn infer_non_intercept_start(design: &Array2<f64>, weights: &Array1<f64>) -> usize {
231 infer_non_intercept_start_impl(
232 ScaleDesignMatrixRef::Dense(design),
233 weights,
234 "weighted column stats row mismatch".to_string(),
235 )
236 .unwrap_or(0)
237}
238
239fn dim_err(reason: impl Into<String>) -> ScaleDesignError {
240 ScaleDesignError::IncompatibleDimensions {
241 reason: reason.into(),
242 }
243}
244
245pub fn build_scale_deviation_transform(
246 primary_design: &Array2<f64>,
247 noise_design: &Array2<f64>,
248 weights: &Array1<f64>,
249 non_intercept_start: usize,
250) -> Result<ScaleDeviationTransform, String> {
251 build_scale_deviation_transform_impl(
252 ScaleDesignMatrixRef::Dense(primary_design),
253 ScaleDesignMatrixRef::Dense(noise_design),
254 weights,
255 non_intercept_start,
256 "scale deviation transform row mismatch",
257 )
258 .map_err(|e| e.to_string())
259}
260
261pub fn apply_scale_deviation_transform(
262 primary_design: &Array2<f64>,
263 rawnoise_design: &Array2<f64>,
264 transform: &ScaleDeviationTransform,
265) -> Result<Array2<f64>, String> {
266 apply_scale_deviation_transform_typed(primary_design, rawnoise_design, transform)
267 .map_err(|e| e.to_string())
268}
269
270fn apply_scale_deviation_transform_typed(
271 primary_design: &Array2<f64>,
272 rawnoise_design: &Array2<f64>,
273 transform: &ScaleDeviationTransform,
274) -> Result<Array2<f64>, ScaleDesignError> {
275 if primary_design.nrows() != rawnoise_design.nrows() {
276 return Err(dim_err("scale deviation apply row mismatch"));
277 }
278 if primary_design.ncols() != transform.projection_coef.nrows()
279 || rawnoise_design.ncols() != transform.projection_coef.ncols()
280 {
281 return Err(dim_err("scale deviation apply column mismatch"));
282 }
283 let n = rawnoise_design.nrows();
284 let p_primary = primary_design.ncols();
285 let p_noise = rawnoise_design.ncols();
286 let chunk_rows = scale_design_row_chunk_size(n, p_primary.max(p_noise));
287 let mut out = Array2::<f64>::zeros((n, p_noise));
288 for start in (0..n).step_by(chunk_rows) {
289 let end = (start + chunk_rows).min(n);
290 let primary_chunk = primary_design.slice(s![start..end, ..]).to_owned();
291 let noise_chunk = rawnoise_design.slice(s![start..end, ..]).to_owned();
292 let chunk = apply_scale_deviation_reparam_chunk(&primary_chunk, &noise_chunk, transform);
293 out.slice_mut(s![start..end, ..]).assign(&chunk);
294 }
295 Ok(out)
296}
297
298#[derive(Clone)]
299struct ScaleDeviationOperator {
300 primary_design: DesignMatrix,
301 rawnoise_design: DesignMatrix,
302 transform: ScaleDeviationTransform,
303 chunk_rows: usize,
304}
305
306impl ScaleDeviationOperator {
307 fn row_chunk(&self, rows: Range<usize>) -> Result<Array2<f64>, ScaleDesignError> {
308 let primary_chunk = self
309 .primary_design
310 .try_row_chunk(rows.clone())
311 .map_err(|e| ScaleDesignError::RowMaterializationFailed {
312 reason: format!("scale deviation operator primary chunk: {e}"),
313 })?;
314 let noise_chunk = self.rawnoise_design.try_row_chunk(rows).map_err(|e| {
315 ScaleDesignError::RowMaterializationFailed {
316 reason: format!("scale deviation operator noise chunk: {e}"),
317 }
318 })?;
319 Ok(apply_scale_deviation_reparam_chunk(
320 &primary_chunk,
321 &noise_chunk,
322 &self.transform,
323 ))
324 }
325}
326
327impl LinearOperator for ScaleDeviationOperator {
328 fn nrows(&self) -> usize {
329 self.rawnoise_design.nrows()
330 }
331
332 fn ncols(&self) -> usize {
333 self.rawnoise_design.ncols()
334 }
335
336 fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
337 assert_eq!(vector.len(), self.ncols());
338 let n = self.nrows();
339 let mut out = Array1::<f64>::zeros(n);
340 for start in (0..n).step_by(self.chunk_rows) {
341 let end = (start + self.chunk_rows).min(n);
342 let chunk = self
343 .row_chunk(start..end)
344 .expect("scale deviation operator row chunk failed");
345 out.slice_mut(s![start..end]).assign(&chunk.dot(vector));
346 }
347 out
348 }
349
350 fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
351 assert_eq!(vector.len(), self.nrows());
352 let n = self.nrows();
353 let p = self.ncols();
354 let mut out = Array1::<f64>::zeros(p);
355 for start in (0..n).step_by(self.chunk_rows) {
356 let end = (start + self.chunk_rows).min(n);
357 let chunk = self
358 .row_chunk(start..end)
359 .expect("scale deviation operator row chunk failed");
360 out += &chunk.t().dot(&vector.slice(s![start..end]).to_owned());
361 }
362 out
363 }
364
365 fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
366 if weights.len() != self.nrows() {
367 return Err(dim_err(format!(
368 "scale deviation operator XtWX weight mismatch: weights={}, rows={}",
369 weights.len(),
370 self.nrows()
371 ))
372 .to_string());
373 }
374 FiniteSignedWeightsView::try_from_array(weights)
375 .map_err(|reason| format!("scale deviation operator XtWX: {reason}"))?;
376 let n = self.nrows();
377 let p = self.ncols();
378 let mut out = Array2::<f64>::zeros((p, p));
379 for start in (0..n).step_by(self.chunk_rows) {
380 let end = (start + self.chunk_rows).min(n);
381 let chunk = self.row_chunk(start..end).map_err(|e| e.to_string())?;
382 for local in 0..chunk.nrows() {
383 let w = weights[start + local];
384 if w == 0.0 {
385 continue;
386 }
387 for a in 0..p {
388 let xa = chunk[[local, a]];
389 for b in a..p {
390 let value = w * xa * chunk[[local, b]];
391 out[[a, b]] += value;
392 if a != b {
393 out[[b, a]] += value;
394 }
395 }
396 }
397 }
398 }
399 Ok(out)
400 }
401
402 fn uses_matrix_free_pcg(&self) -> bool {
403 self.primary_design
404 .nrows()
405 .saturating_mul(self.rawnoise_design.ncols())
406 > SCALE_OPERATOR_MATRIX_FREE_PCG_THRESHOLD
407 }
408}
409
410impl DenseDesignOperator for ScaleDeviationOperator {
411 fn row_chunk_into(
412 &self,
413 rows: Range<usize>,
414 mut out: ArrayViewMut2<'_, f64>,
415 ) -> Result<(), gam_runtime::resource::MatrixMaterializationError> {
416 let chunk = self.row_chunk(rows).map_err(|err| {
417 gam_runtime::resource::MatrixMaterializationError::RowMaterializationFailed {
418 context: "ScaleDeviationOperator::row_chunk_into",
419 reason: err.to_string(),
420 }
421 })?;
422 out.assign(&chunk);
423 Ok(())
424 }
425
426 fn to_dense(&self) -> Array2<f64> {
427 let n = self.nrows();
428 let p = self.ncols();
429 let mut out = Array2::<f64>::zeros((n, p));
430 for start in (0..n).step_by(self.chunk_rows) {
431 let end = (start + self.chunk_rows).min(n);
432 let chunk = self
433 .row_chunk(start..end)
434 .expect("scale deviation operator row chunk failed");
435 out.slice_mut(s![start..end, ..]).assign(&chunk);
436 }
437 out
438 }
439}
440
441#[derive(Debug)]
442struct WeightedColumnStats {
443 weighted_sum: Array1<f64>,
444 weighted_sum_sq: Array1<f64>,
445 total_weight: f64,
446}
447
448fn validate_scale_weights(weights: &Array1<f64>) -> Result<f64, ScaleDesignError> {
449 let mut total_weight = 0.0;
450 for (idx, &w) in weights.iter().enumerate() {
451 if !w.is_finite() {
452 return Err(ScaleDesignError::NonFiniteInput {
453 reason: format!("scale deviation weight {idx} is not finite"),
454 });
455 }
456 if w < 0.0 {
457 return Err(ScaleDesignError::InvalidWeights {
458 reason: format!(
459 "scale deviation requires non-negative weights, got {w} at index {idx}"
460 ),
461 });
462 }
463 total_weight += w;
464 }
465 if !total_weight.is_finite() || total_weight <= 0.0 {
466 return Err(ScaleDesignError::InvalidWeights {
467 reason: "scale deviation requires positive finite total weight".to_string(),
468 });
469 }
470 Ok(total_weight)
471}
472
473fn scale_design_row_chunk_size(nrows: usize, max_cols: usize) -> usize {
474 (SCALE_DESIGN_TARGET_CHUNK_BYTES / (max_cols.max(1) * std::mem::size_of::<f64>()))
475 .max(1)
476 .min(nrows.max(1))
477}
478
479fn weighted_column_stats(
480 design: ScaleDesignMatrixRef<'_>,
481 weights: &Array1<f64>,
482 row_mismatch_error: String,
483) -> Result<WeightedColumnStats, ScaleDesignError> {
484 if design.nrows() != weights.len() {
485 return Err(dim_err(row_mismatch_error));
486 }
487 let total_weight = validate_scale_weights(weights)?;
488 let p = design.ncols();
489 let mut weighted_sum = Array1::<f64>::zeros(p);
490 let mut weighted_sum_sq = Array1::<f64>::zeros(p);
491 let chunk_rows = scale_design_row_chunk_size(design.nrows(), p);
492 for start in (0..design.nrows()).step_by(chunk_rows) {
493 let end = (start + chunk_rows).min(design.nrows());
494 let chunk = design.row_chunk(start..end)?;
495 for local in 0..(end - start) {
496 let w = weights[start + local];
497 if w == 0.0 {
498 continue;
499 }
500 for j in 0..p {
501 let x = chunk[[local, j]];
502 weighted_sum[j] += w * x;
503 weighted_sum_sq[j] += w * x * x;
504 }
505 }
506 }
507 Ok(WeightedColumnStats {
508 weighted_sum,
509 weighted_sum_sq,
510 total_weight,
511 })
512}
513
514fn infer_non_intercept_start_impl(
515 design: ScaleDesignMatrixRef<'_>,
516 weights: &Array1<f64>,
517 row_mismatch_error: String,
518) -> Result<usize, ScaleDesignError> {
519 let stats = weighted_column_stats(design, weights, row_mismatch_error)?;
520 let mut end = 0;
521 for j in 0..stats.weighted_sum.len() {
522 let centered_ss = stats.weighted_sum_sq[j]
523 - stats.weighted_sum[j] * stats.weighted_sum[j] / stats.total_weight;
524 if centered_ss <= COLUMN_TOL {
525 end = j + 1;
526 } else {
527 break;
528 }
529 }
530 Ok(end)
531}
532
533fn build_weighted_primary_design(
534 primary_design: ScaleDesignMatrixRef<'_>,
535 sqrtw: &Array1<f64>,
536 chunk_rows: usize,
537) -> Result<Array2<f64>, ScaleDesignError> {
538 let n = primary_design.nrows();
539 let p_primary = primary_design.ncols();
540 let mut wx = Array2::<f64>::zeros((n, p_primary));
541 for start in (0..n).step_by(chunk_rows) {
542 let end = (start + chunk_rows).min(n);
543 let x_chunk = primary_design.row_chunk(start..end)?;
544 for local in 0..(end - start) {
545 let sw = sqrtw[start + local];
546 for col in 0..p_primary {
547 wx[[start + local, col]] = sw * x_chunk[[local, col]];
548 }
549 }
550 }
551 Ok(wx)
552}
553
554fn choose_scale_projection_ridge_alpha(singular: &[f64]) -> f64 {
563 if singular.is_empty() {
564 return 0.0;
565 }
566 let sigma_max = singular.iter().copied().fold(0.0_f64, f64::max);
567 if !sigma_max.is_finite() || sigma_max <= 0.0 {
568 return 0.0;
569 }
570 let derived_tol = sigma_max / SCALE_PROJECTION_LEVERAGE_AMPLIFICATION;
571 let truncation_tol = derived_tol.max(SCALE_PROJECTION_REPLAY_RCOND_FLOOR * sigma_max);
572 truncation_tol * truncation_tol
573}
574
575fn solve_scale_projection(
576 primary_design: ScaleDesignMatrixRef<'_>,
577 noise_design: ScaleDesignMatrixRef<'_>,
578 weights: &Array1<f64>,
579 first_active: usize,
580 chunk_rows: usize,
581) -> Result<(Array2<f64>, f64), ScaleDesignError> {
582 let n = primary_design.nrows();
583 let p_primary = primary_design.ncols();
584 let p_noise = noise_design.ncols();
585 let mut projection_coef = Array2::<f64>::zeros((p_primary, p_noise));
586 let active_cols = p_noise.saturating_sub(first_active);
587
588 if active_cols == 0 || p_primary == 0 {
589 return Ok((projection_coef, 0.0));
590 }
591
592 let sqrtw = weights.mapv(f64::sqrt);
593 let wx = build_weighted_primary_design(primary_design, &sqrtw, chunk_rows)?;
594 let (u_opt, singular, vt_opt) =
598 wx.svd(true, true)
599 .map_err(|e| ScaleDesignError::SvdFailed {
600 reason: format!("scale projection SVD failed: {e:?}"),
601 })?;
602 let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
603 return Err(ScaleDesignError::SvdFailed {
604 reason: "scale projection SVD did not return singular vectors".to_string(),
605 });
606 };
607 let alpha = choose_scale_projection_ridge_alpha(singular.as_slice().unwrap_or(&[]));
608 let rank = singular.len();
609 if rank == 0 {
610 return Ok((projection_coef, alpha));
611 }
612 let cutoff = alpha.sqrt();
622 let mut filter = Array1::<f64>::zeros(rank);
623 for k in 0..rank {
624 let s = singular[k];
625 filter[k] = if s > cutoff && s > 0.0 { 1.0 / s } else { 0.0 };
626 }
627
628 let chunk_cols = (SCALE_DESIGN_TARGET_CHUNK_BYTES / (n.max(1) * std::mem::size_of::<f64>()))
629 .max(1)
630 .min(active_cols);
631
632 for chunk_start in (0..active_cols).step_by(chunk_cols) {
633 let width = (active_cols - chunk_start).min(chunk_cols);
634 let mut rhs = Array2::<f64>::zeros((n, width));
635 for start in (0..n).step_by(chunk_rows) {
636 let end = (start + chunk_rows).min(n);
637 let noise_chunk = noise_design.row_chunk(start..end)?;
638 for local in 0..(end - start) {
639 let sw = sqrtw[start + local];
640 for col in 0..width {
641 rhs[[start + local, col]] =
642 sw * noise_chunk[[local, first_active + chunk_start + col]];
643 }
644 }
645 }
646
647 let mut t = u.t().dot(&rhs);
649 for k in 0..rank {
651 let f = filter[k];
652 for col in 0..width {
653 t[[k, col]] *= f;
654 }
655 }
656 let block = vt.t().dot(&t);
659 for col in 0..width {
660 for row in 0..p_primary {
661 projection_coef[[row, first_active + chunk_start + col]] = block[[row, col]];
662 }
663 }
664 }
665
666 Ok((projection_coef, alpha))
667}
668
669fn apply_projection_chunk(
670 primary_chunk: &Array2<f64>,
671 projection_coef: &Array2<f64>,
672 first_active: usize,
673) -> Array2<f64> {
674 if first_active >= projection_coef.ncols() {
675 Array2::<f64>::zeros((primary_chunk.nrows(), 0))
676 } else {
677 fast_ab(
678 primary_chunk,
679 &projection_coef.slice(s![.., first_active..]).to_owned(),
680 )
681 }
682}
683
684fn build_scale_deviation_transform_impl(
685 primary_design: ScaleDesignMatrixRef<'_>,
686 noise_design: ScaleDesignMatrixRef<'_>,
687 weights: &Array1<f64>,
688 non_intercept_start: usize,
689 row_mismatch_error: &str,
690) -> Result<ScaleDeviationTransform, ScaleDesignError> {
691 if primary_design.nrows() != noise_design.nrows() || weights.len() != noise_design.nrows() {
692 return Err(dim_err(row_mismatch_error.to_string()));
693 }
694 validate_scale_weights(weights)?;
695
696 let n = primary_design.nrows();
697 let p_primary = primary_design.ncols();
698 let p_noise = noise_design.ncols();
699 let first_active = non_intercept_start.min(p_noise);
700 let chunk_rows = scale_design_row_chunk_size(n, p_primary.max(p_noise));
701 let (projection_coef, projection_ridge_alpha) = solve_scale_projection(
702 primary_design,
703 noise_design,
704 weights,
705 first_active,
706 chunk_rows,
707 )?;
708 let mut weighted_column_mean = Array1::<f64>::zeros(p_noise);
709 let mut rescale = Array1::<f64>::ones(p_noise);
710 let active_cols = p_noise - first_active;
711
712 if active_cols > 0 {
713 let projection_only_transform = ScaleDeviationTransform {
714 projection_coef: projection_coef.clone(),
715 weighted_column_mean: Array1::<f64>::zeros(p_noise),
716 rescale: Array1::<f64>::ones(p_noise),
717 non_intercept_start,
718 projection_ridge_alpha,
719 };
720 let mut w_sum = 0.0;
721 let mut w_resid_sum = Array1::<f64>::zeros(active_cols);
722 let mut w_noise_sum = Array1::<f64>::zeros(active_cols);
723
724 for start in (0..n).step_by(chunk_rows) {
725 let end = (start + chunk_rows).min(n);
726 let x_chunk = primary_design.row_chunk(start..end)?;
727 let noise_chunk = noise_design.row_chunk(start..end)?;
728 let resid_chunk = apply_scale_deviation_reparam_chunk(
729 &x_chunk,
730 &noise_chunk,
731 &projection_only_transform,
732 );
733 for local in 0..(end - start) {
734 let w = weights[start + local];
735 if w == 0.0 {
736 continue;
737 }
738 w_sum += w;
739 for jj in 0..active_cols {
740 let nij = noise_chunk[[local, first_active + jj]];
741 w_noise_sum[jj] += w * nij;
742 w_resid_sum[jj] += w * resid_chunk[[local, first_active + jj]];
743 }
744 }
745 }
746
747 if !w_sum.is_finite() || w_sum <= 0.0 {
748 return Err(ScaleDesignError::InvalidWeights {
749 reason: "scale deviation requires positive finite total weight".to_string(),
750 });
751 }
752
753 let resid_center = w_resid_sum.mapv(|sum| sum / w_sum);
754 let noise_mean = w_noise_sum.mapv(|sum| sum / w_sum);
755 let mut orig_css = Array1::<f64>::zeros(active_cols);
756 let mut resid_css = Array1::<f64>::zeros(active_cols);
757
758 for start in (0..n).step_by(chunk_rows) {
759 let end = (start + chunk_rows).min(n);
760 let x_chunk = primary_design.row_chunk(start..end)?;
761 let noise_chunk = noise_design.row_chunk(start..end)?;
762 let resid_chunk = apply_scale_deviation_reparam_chunk(
763 &x_chunk,
764 &noise_chunk,
765 &projection_only_transform,
766 );
767 for local in 0..(end - start) {
768 let w = weights[start + local];
769 if w == 0.0 {
770 continue;
771 }
772 for jj in 0..active_cols {
773 let nij = noise_chunk[[local, first_active + jj]];
774 let d_orig = nij - noise_mean[jj];
775 orig_css[jj] += w * d_orig * d_orig;
776 let d_resid = resid_chunk[[local, first_active + jj]] - resid_center[jj];
777 resid_css[jj] += w * d_resid * d_resid;
778 }
779 }
780 }
781
782 for jj in 0..active_cols {
783 let j = first_active + jj;
784 let scale = if resid_css[jj].is_finite()
785 && resid_css[jj] > COLUMN_TOL
786 && orig_css[jj].is_finite()
787 && orig_css[jj] > COLUMN_TOL
788 {
789 (orig_css[jj] / resid_css[jj]).sqrt()
790 } else {
791 1.0
792 };
793 weighted_column_mean[j] = resid_center[jj];
794 rescale[j] = scale;
795 }
796 }
797
798 Ok(ScaleDeviationTransform {
799 projection_coef,
800 weighted_column_mean,
801 rescale,
802 non_intercept_start,
803 projection_ridge_alpha,
804 })
805}
806
807pub fn infer_non_intercept_start_design(
808 design: &DesignMatrix,
809 weights: &Array1<f64>,
810) -> Result<usize, String> {
811 infer_non_intercept_start_impl(
812 ScaleDesignMatrixRef::Design(design),
813 weights,
814 format!(
815 "weighted column stats row mismatch: design has {} rows, weights have {} entries",
816 design.nrows(),
817 weights.len()
818 ),
819 )
820 .map_err(|e| e.to_string())
821}
822
823pub fn build_scale_deviation_transform_design(
824 primary_design: &DesignMatrix,
825 noise_design: &DesignMatrix,
826 weights: &Array1<f64>,
827 non_intercept_start: usize,
828) -> Result<ScaleDeviationTransform, String> {
829 build_scale_deviation_transform_impl(
830 ScaleDesignMatrixRef::Design(primary_design),
831 ScaleDesignMatrixRef::Design(noise_design),
832 weights,
833 non_intercept_start,
834 "scale deviation transform design row mismatch",
835 )
836 .map_err(|e| e.to_string())
837}
838
839fn apply_scale_deviation_reparam_chunk(
847 primary_chunk: &Array2<f64>,
848 noise_chunk: &Array2<f64>,
849 transform: &ScaleDeviationTransform,
850) -> Array2<f64> {
851 let rows = noise_chunk.nrows();
852 let p_noise = noise_chunk.ncols();
853 let first_active = transform.non_intercept_start.min(p_noise);
854 let mut out = Array2::<f64>::zeros((rows, p_noise));
855
856 for j in 0..first_active {
858 for i in 0..rows {
859 out[[i, j]] = noise_chunk[[i, j]];
860 }
861 }
862
863 if first_active < p_noise {
865 let fitted =
866 apply_projection_chunk(primary_chunk, &transform.projection_coef, first_active);
867 for j in first_active..p_noise {
868 let jj = j - first_active;
869 let scale = transform.rescale[j];
870 let center = transform.weighted_column_mean[j];
871 for i in 0..rows {
872 out[[i, j]] = (noise_chunk[[i, j]] - fitted[[i, jj]] - center) * scale;
873 }
874 }
875 }
876
877 out
878}
879
880pub fn build_scale_deviation_operator(
881 primary_design: DesignMatrix,
882 rawnoise_design: DesignMatrix,
883 transform: &ScaleDeviationTransform,
884) -> Result<DesignMatrix, String> {
885 build_scale_deviation_operator_typed(primary_design, rawnoise_design, transform)
886 .map_err(|e| e.to_string())
887}
888
889fn build_scale_deviation_operator_typed(
890 primary_design: DesignMatrix,
891 rawnoise_design: DesignMatrix,
892 transform: &ScaleDeviationTransform,
893) -> Result<DesignMatrix, ScaleDesignError> {
894 if primary_design.nrows() != rawnoise_design.nrows() {
895 return Err(dim_err(format!(
896 "scale deviation operator row mismatch: primary rows={}, noise rows={}",
897 primary_design.nrows(),
898 rawnoise_design.nrows()
899 )));
900 }
901 if primary_design.ncols() != transform.projection_coef.nrows()
902 || rawnoise_design.ncols() != transform.projection_coef.ncols()
903 {
904 return Err(dim_err(format!(
905 "scale deviation operator column mismatch: primary cols={}, noise cols={}, transform is {}x{}",
906 primary_design.ncols(),
907 rawnoise_design.ncols(),
908 transform.projection_coef.nrows(),
909 transform.projection_coef.ncols()
910 )));
911 }
912 let n = rawnoise_design.nrows();
913 let p_primary = primary_design.ncols();
914 let p_noise = rawnoise_design.ncols();
915 let chunk_rows = scale_design_row_chunk_size(n, p_primary.max(p_noise));
916 Ok(DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(
917 ScaleDeviationOperator {
918 primary_design,
919 rawnoise_design,
920 transform: transform.clone(),
921 chunk_rows,
922 },
923 ))))
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use gam_linalg::matrix::DesignMatrix;
930 use ndarray::array;
931
932 fn assert_matrix_close(lhs: &Array2<f64>, rhs: &Array2<f64>, tol: f64, label: &str) {
933 assert_eq!(
934 lhs.dim(),
935 rhs.dim(),
936 "{label} shape mismatch: left {:?}, right {:?}",
937 lhs.dim(),
938 rhs.dim()
939 );
940 for i in 0..lhs.nrows() {
941 for j in 0..lhs.ncols() {
942 assert!(
943 (lhs[[i, j]] - rhs[[i, j]]).abs() <= tol,
944 "{label} mismatch at ({i}, {j}): {} vs {}",
945 lhs[[i, j]],
946 rhs[[i, j]]
947 );
948 }
949 }
950 }
951
952 fn assert_transform_close(
953 lhs: &ScaleDeviationTransform,
954 rhs: &ScaleDeviationTransform,
955 tol: f64,
956 ) {
957 assert_eq!(lhs.non_intercept_start, rhs.non_intercept_start);
958 assert_matrix_close(
959 &lhs.projection_coef,
960 &rhs.projection_coef,
961 tol,
962 "projection coefficients",
963 );
964 assert_eq!(
965 lhs.weighted_column_mean.len(),
966 rhs.weighted_column_mean.len()
967 );
968 assert_eq!(lhs.rescale.len(), rhs.rescale.len());
969 for j in 0..lhs.weighted_column_mean.len() {
970 assert!(
971 (lhs.weighted_column_mean[j] - rhs.weighted_column_mean[j]).abs() <= tol,
972 "weighted column mean mismatch at {j}: {} vs {}",
973 lhs.weighted_column_mean[j],
974 rhs.weighted_column_mean[j]
975 );
976 assert!(
977 (lhs.rescale[j] - rhs.rescale[j]).abs() <= tol,
978 "rescale mismatch at {j}: {} vs {}",
979 lhs.rescale[j],
980 rhs.rescale[j]
981 );
982 }
983 }
984
985 #[test]
986 fn scale_deviation_transform_overdetermined() {
987 let n = 1000;
988 let p_primary = 10;
989 let p_noise = 5;
990
991 let mut primary = Array2::<f64>::zeros((n, p_primary));
992 let mut noise = Array2::<f64>::zeros((n, p_noise));
993 for i in 0..n {
994 for j in 0..p_primary {
995 primary[[i, j]] = ((i * 3 + j * 11) as f64 * 0.1).sin();
996 }
997 for j in 0..p_noise {
998 noise[[i, j]] = ((i * 5 + j * 13) as f64 * 0.1).cos();
999 }
1000 }
1001 noise.column_mut(0).fill(1.0);
1002 let weights = Array1::<f64>::ones(n);
1003
1004 let transform = build_scale_deviation_transform(&primary, &noise, &weights, 1)
1005 .expect("transform should succeed for overdetermined inputs");
1006 let transformed = apply_scale_deviation_transform(&primary, &noise, &transform)
1007 .expect("apply should succeed for overdetermined inputs");
1008
1009 assert_eq!(transform.projection_coef.dim(), (p_primary, p_noise));
1010 assert_eq!(transformed.dim(), (n, p_noise));
1011 assert!(transformed.iter().all(|v| v.is_finite()));
1012 assert!(transformed.column(0).iter().all(|&v| v == 1.0));
1013
1014 let primary_design =
1015 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(primary.clone()));
1016 let noise_design =
1017 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(noise.clone()));
1018 let non_intercept_start = infer_non_intercept_start_design(&noise_design, &weights)
1019 .expect("design-native non-intercept detection should succeed");
1020 assert_eq!(non_intercept_start, 1);
1021 let design_transform = build_scale_deviation_transform_design(
1022 &primary_design,
1023 &noise_design,
1024 &weights,
1025 non_intercept_start,
1026 )
1027 .expect("design-native transform should succeed");
1028 let transformed_design =
1029 build_scale_deviation_operator(primary_design, noise_design, &design_transform)
1030 .expect("design-native operator should build")
1031 .to_dense();
1032
1033 assert_eq!(design_transform.projection_coef.dim(), (p_primary, p_noise));
1034 assert_eq!(transformed_design.dim(), transformed.dim());
1035 assert_transform_close(&transform, &design_transform, 1e-10);
1036 assert_matrix_close(
1037 &transformed_design,
1038 &transformed,
1039 1e-8,
1040 "transformed design",
1041 );
1042 }
1043
1044 #[test]
1045 fn scale_deviation_operator_gram_preserves_signed_weights() {
1046 let primary = array![[1.0], [2.0], [-1.0], [0.5]];
1047 let noise = array![[1.0, 2.0], [3.0, -1.0], [0.5, 4.0], [-2.0, 1.5]];
1048 let transform = ScaleDeviationTransform::identity(1, 2, 0);
1049 let design = build_scale_deviation_operator(
1050 DesignMatrix::Dense(DenseDesignMatrix::from(primary)),
1051 DesignMatrix::Dense(DenseDesignMatrix::from(noise.clone())),
1052 &transform,
1053 )
1054 .unwrap();
1055 let weights = array![2.0, -3.0, 0.25, -1.5];
1056 let weighted_noise = noise.clone() * weights.view().insert_axis(ndarray::Axis(1));
1057 let expected = noise.t().dot(&weighted_noise);
1058 let got = design.diag_xtw_x(&weights).unwrap();
1059 assert_matrix_close(&got, &expected, 1e-12, "signed scale-deviation Gram");
1060
1061 let bad = array![1.0, f64::NAN, f64::INFINITY, 1.0];
1062 let err = design.diag_xtw_x(&bad).unwrap_err();
1063 assert!(err.contains("row 1"), "unexpected diagnostic: {err}");
1064 }
1065
1066 #[test]
1067 fn scale_deviation_transform_rank_deficient_primary_matches_design_path() {
1068 let n = 384;
1069 let p_primary = 4;
1070 let p_noise = 4;
1071 let mut primary = Array2::<f64>::zeros((n, p_primary));
1072 let mut noise = Array2::<f64>::zeros((n, p_noise));
1073 let mut weights = Array1::<f64>::zeros(n);
1074
1075 for i in 0..n {
1076 let t = i as f64 / n as f64;
1077 let wobble = (17.0 * t).sin();
1078 primary[[i, 0]] = 1.0;
1079 primary[[i, 1]] = t;
1080 primary[[i, 2]] = t + 1e-12 * wobble;
1081 primary[[i, 3]] = 2.0 * t - 1e-12 * wobble;
1082
1083 noise[[i, 0]] = 1.0;
1084 noise[[i, 1]] = 0.7 * t + 0.2 * (9.0 * t).cos();
1085 noise[[i, 2]] = primary[[i, 1]] - primary[[i, 2]] + 0.1 * (13.0 * t).sin();
1086 noise[[i, 3]] = 0.5 * primary[[i, 3]] + 0.3 * (5.0 * t).cos();
1087
1088 weights[i] = if i % 17 == 0 {
1089 0.0
1090 } else {
1091 0.5 + (11.0 * t).sin().abs()
1092 };
1093 }
1094
1095 let transform = build_scale_deviation_transform(&primary, &noise, &weights, 1)
1096 .expect("dense transform should succeed for ill-conditioned primary");
1097 let transformed = apply_scale_deviation_transform(&primary, &noise, &transform)
1098 .expect("dense apply should succeed for ill-conditioned primary");
1099
1100 let primary_design =
1101 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(primary.clone()));
1102 let noise_design =
1103 DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(noise.clone()));
1104 let non_intercept_start = infer_non_intercept_start_design(&noise_design, &weights)
1105 .expect("design-native non-intercept detection should succeed");
1106 assert_eq!(non_intercept_start, 1);
1107
1108 let design_transform = build_scale_deviation_transform_design(
1109 &primary_design,
1110 &noise_design,
1111 &weights,
1112 non_intercept_start,
1113 )
1114 .expect("design-native transform should succeed for ill-conditioned primary");
1115 let transformed_design =
1116 build_scale_deviation_operator(primary_design, noise_design, &design_transform)
1117 .expect("design-native operator should build for ill-conditioned primary")
1118 .to_dense();
1119
1120 assert_transform_close(&transform, &design_transform, 1e-10);
1121 assert_matrix_close(
1122 &transformed_design,
1123 &transformed,
1124 1e-8,
1125 "ill-conditioned transformed design",
1126 );
1127 }
1128
1129 #[test]
1130 fn choose_scale_projection_ridge_alpha_scales_with_sigma_max() {
1131 let alpha_unit = choose_scale_projection_ridge_alpha(&[1.0, 0.5, 1e-6]);
1135 let expected_unit = SCALE_PROJECTION_REPLAY_RCOND_FLOOR.powi(2);
1136 assert!(alpha_unit > 0.0);
1137 assert!(
1138 (alpha_unit - expected_unit).abs() < 1e-24,
1139 "alpha should be {expected_unit:e} for sigma_max=1, got {alpha_unit}"
1140 );
1141
1142 let alpha_scaled = choose_scale_projection_ridge_alpha(&[100.0, 1.0]);
1143 let expected_scaled = (SCALE_PROJECTION_REPLAY_RCOND_FLOOR * 100.0).powi(2);
1144 assert!(
1145 (alpha_scaled - expected_scaled).abs() < 1e-18,
1146 "alpha should be {expected_scaled:e} for sigma_max=100, got {alpha_scaled}"
1147 );
1148 assert!(
1150 (alpha_scaled / alpha_unit - 1.0e4).abs() < 1e-6,
1151 "alpha should scale as sigma_max^2; got ratio {}",
1152 alpha_scaled / alpha_unit
1153 );
1154
1155 let alpha_floor = choose_scale_projection_ridge_alpha(&[]);
1156 assert_eq!(alpha_floor, 0.0);
1157 }
1158
1159 #[test]
1160 fn ridge_replay_continuous_under_input_sweep() {
1161 let n = 64;
1166 let mut primary = Array2::<f64>::zeros((n, 3));
1167 let mut noise = Array2::<f64>::zeros((n, 2));
1168 let weights = Array1::<f64>::ones(n);
1169 for i in 0..n {
1170 let t = i as f64 / n as f64;
1171 primary[[i, 0]] = 1.0;
1172 primary[[i, 1]] = t;
1173 primary[[i, 2]] = t + 1e-9 * (5.0 * t).sin();
1175 noise[[i, 0]] = 1.0;
1176 noise[[i, 1]] = (0.4 * t).cos();
1177 }
1178
1179 let mut last: Option<f64> = None;
1183 let mut max_step: f64 = 0.0;
1184 for k in 0..50 {
1185 let s = k as f64 / 49.0;
1186 let mut perturbed = noise.clone();
1187 for i in 0..n {
1188 perturbed[[i, 1]] += s;
1189 }
1190 let transform = build_scale_deviation_transform(&primary, &perturbed, &weights, 1)
1191 .expect("ridge transform should succeed under input sweep");
1192 let val = transform.projection_coef[[2, 1]];
1193 if let Some(prev) = last {
1194 let step = (val - prev).abs();
1195 max_step = max_step.max(step);
1196 }
1197 last = Some(val);
1198 }
1199 assert!(
1203 max_step < 0.5,
1204 "replay coefficient sweep should be continuous, got max step {max_step}"
1205 );
1206 }
1207
1208 #[test]
1209 fn ridge_replay_noise_free_is_near_identity() {
1210 let n = 128;
1215 let p_primary = 4;
1216 let p_noise = 3;
1217 let mut primary = Array2::<f64>::zeros((n, p_primary));
1218 let mut noise = Array2::<f64>::zeros((n, p_noise));
1219 let weights = Array1::<f64>::ones(n);
1220 for i in 0..n {
1221 let t = i as f64 / n as f64;
1222 primary[[i, 0]] = 1.0;
1223 primary[[i, 1]] = t;
1224 primary[[i, 2]] = (3.0 * t).sin();
1225 primary[[i, 3]] = (2.0 * t - 0.4).powi(2);
1226 noise[[i, 0]] = 1.0;
1227 noise[[i, 1]] = 0.7 * primary[[i, 1]] - 0.3 * primary[[i, 2]];
1230 noise[[i, 2]] = 0.2 * primary[[i, 3]] + 0.1 * primary[[i, 1]];
1231 }
1232
1233 let transform = build_scale_deviation_transform(&primary, &noise, &weights, 1)
1234 .expect("transform should succeed");
1235 let transformed = apply_scale_deviation_transform(&primary, &noise, &transform)
1236 .expect("apply should succeed");
1237
1238 for i in 0..n {
1240 assert_eq!(transformed[[i, 0]], 1.0);
1241 }
1242 for j in 1..p_noise {
1246 for i in 0..n {
1247 assert!(
1248 transformed[[i, j]].abs() < 1e-6,
1249 "noise-free residual should be near zero at ({i},{j}), got {}",
1250 transformed[[i, j]]
1251 );
1252 }
1253 }
1254 assert!(transform.projection_ridge_alpha > 0.0);
1255 }
1256
1257 #[test]
1258 fn scale_transform_payload_round_trips_alpha() {
1259 let n = 64;
1260 let mut primary = Array2::<f64>::zeros((n, 3));
1261 let mut noise = Array2::<f64>::zeros((n, 2));
1262 let weights = Array1::<f64>::ones(n);
1263 for i in 0..n {
1264 let t = i as f64 / n as f64;
1265 primary[[i, 0]] = 1.0;
1266 primary[[i, 1]] = t;
1267 primary[[i, 2]] = (4.0 * t).cos();
1268 noise[[i, 0]] = 1.0;
1269 noise[[i, 1]] = (2.0 * t).sin();
1270 }
1271 let transform = build_scale_deviation_transform(&primary, &noise, &weights, 1)
1272 .expect("transform should succeed");
1273
1274 let projection: Vec<Vec<f64>> = transform
1275 .projection_coef
1276 .rows()
1277 .into_iter()
1278 .map(|row| row.to_vec())
1279 .collect();
1280 let center = transform.weighted_column_mean.to_vec();
1281 let scale = transform.rescale.to_vec();
1282 let restored = scale_transform_from_payload(
1283 &Some(projection),
1284 &Some(center),
1285 &Some(scale),
1286 Some(transform.non_intercept_start),
1287 Some(transform.projection_ridge_alpha),
1288 )
1289 .expect("payload round-trip should succeed")
1290 .expect("payload should produce a transform");
1291 assert_eq!(
1292 restored.projection_ridge_alpha, transform.projection_ridge_alpha,
1293 "alpha must round-trip exactly through payload serialization"
1294 );
1295 }
1296}