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