1use super::*;
2
3pub(crate) fn beta_bits_match(cached: &Array1<f64>, candidate: &Array1<f64>) -> bool {
4 cached.len() == candidate.len()
5 && cached
6 .iter()
7 .zip(candidate.iter())
8 .all(|(&left, &right)| left.to_bits() == right.to_bits())
9}
10
11#[derive(Clone, Debug)]
14pub struct TransformationWarmStart {
15 pub location: Array1<f64>,
17 pub scale: Array1<f64>,
19}
20
21#[derive(Clone)]
32pub struct TransformationNormalFamily {
33 pub(crate) x_val_kron: KroneckerDesign,
37 pub(crate) x_deriv_kron: KroneckerDesign,
39 pub(crate) response_val_basis: Array2<f64>,
42 pub(crate) response_lower_basis: Array1<f64>,
44 pub(crate) response_upper_basis: Array1<f64>,
46 pub(crate) response_deriv_basis: Array2<f64>,
48
49 pub(crate) covariate_design: DesignMatrix,
52 pub(crate) covariate_dense_cache: Arc<Mutex<Option<Arc<Array2<f64>>>>>,
59 pub(crate) weights: Arc<Array1<f64>>,
61 pub(crate) offset: Arc<Array1<f64>>,
63 pub(crate) tensor_penalties: Vec<PenaltyMatrix>,
65 pub(crate) tensor_penalty_layout: CtnTensorPenaltyLayout,
70
71 pub(crate) initial_beta: Array1<f64>,
73
74 pub(crate) block_name: String,
76
77 pub(crate) response_knots: Array1<f64>,
79 pub(crate) response_transform: Array2<f64>,
80 pub(crate) response_degree: usize,
81 pub(crate) response_median: f64,
82 pub(crate) response_floor_offset: Arc<Array1<f64>>,
83 pub(crate) response_lower_floor_offset: f64,
84 pub(crate) response_upper_floor_offset: f64,
85
86 pub(crate) row_quantity_cache: Arc<Mutex<Option<TransformationNormalRowQuantityCache>>>,
94 pub(crate) outer_subsample_weights: Option<Arc<Array1<f64>>>,
109}
110
111#[derive(Clone)]
112pub(crate) struct TransformationNormalRowQuantityCache {
113 pub(crate) beta: Arc<Array1<f64>>,
114 pub(crate) alpha: Arc<Array2<f64>>,
116 pub(crate) h: Arc<Array1<f64>>,
117 pub(crate) h_prime: Arc<Array1<f64>>,
118 pub(crate) log_likelihood: f64,
119}
120
121#[derive(Debug)]
122pub(crate) struct TransformationNormalRowDerived {
123 pub(crate) log_likelihood: f64,
124}
125
126impl TransformationNormalRowQuantityCache {
127 pub(crate) fn matches_beta(&self, beta: &Array1<f64>) -> bool {
128 beta_bits_match(&self.beta, beta)
129 }
130}
131
132pub(crate) fn build_transformation_row_derived(
133 h: &Array1<f64>,
134 h_prime: &Array1<f64>,
135 weights: &Array1<f64>,
136) -> Result<TransformationNormalRowDerived, String> {
137 let n = h_prime.len();
138 assert_eq!(h.len(), n);
139 assert_eq!(weights.len(), n);
140
141 if let Some((i, value)) = h
142 .iter()
143 .copied()
144 .enumerate()
145 .find(|(_, value)| !value.is_finite())
146 {
147 return Err(TransformationNormalError::NonFinite {
148 reason: format!(
149 "TransformationNormalFamily row_quantities: h[{i}] = {value} is not finite"
150 ),
151 }
152 .into());
153 }
154 if let Some((i, value)) = weights
155 .iter()
156 .copied()
157 .enumerate()
158 .find(|(_, value)| !value.is_finite())
159 {
160 return Err(TransformationNormalError::NonFinite {
161 reason: format!(
162 "TransformationNormalFamily row_quantities: weight[{i}] = {value} is not finite"
163 ),
164 }
165 .into());
166 }
167
168 use rayon::iter::{IntoParallelIterator, ParallelIterator};
174 let rows: Vec<f64> = (0..n)
175 .into_par_iter()
176 .map(|i| -> Result<f64, String> {
177 let hp = h_prime[i];
178 let inv_h_prime = 1.0 / hp;
179 let inv_h_prime_sq = inv_h_prime * inv_h_prime;
180 let inv_h_prime_cu = inv_h_prime_sq * inv_h_prime;
181 let inv_h_prime_qu = inv_h_prime_sq * inv_h_prime_sq;
182 let w_i = weights[i];
183 let h_i = h[i];
184 let weighted_h = w_i * h_i;
185 let weighted_inv_h_prime = w_i * inv_h_prime;
186 let weighted_inv_h_prime_sq = w_i * inv_h_prime_sq;
187 let row_ll =
193 w_i * (-0.5 * h_i * h_i - 0.5 * (2.0 * std::f64::consts::PI).ln() + hp.ln());
194 if !(inv_h_prime.is_finite()
198 && inv_h_prime_sq.is_finite()
199 && inv_h_prime_cu.is_finite()
200 && inv_h_prime_qu.is_finite()
201 && weighted_h.is_finite()
202 && weighted_inv_h_prime.is_finite()
203 && weighted_inv_h_prime_sq.is_finite())
204 {
205 let derived_values = [
206 ("1/h'", inv_h_prime),
207 ("1/h'^2", inv_h_prime_sq),
208 ("1/h'^3", inv_h_prime_cu),
209 ("1/h'^4", inv_h_prime_qu),
210 ("w*h", weighted_h),
211 ("w/h'", weighted_inv_h_prime),
212 ("w/h'^2", weighted_inv_h_prime_sq),
213 ];
214 for (name, value) in derived_values {
215 if !value.is_finite() {
216 return Err(TransformationNormalError::NonFinite { reason: format!(
217 "TransformationNormalFamily row_quantities: {name} at row {i} is not finite ({value}); h'={hp} is outside the finite exact-derivative range",
218 ) }.into());
219 }
220 }
221 return Err(TransformationNormalError::NonFinite { reason: format!(
222 "TransformationNormalFamily row_quantities: row {i} entered non-finite branch but no named field was non-finite; h'={hp}",
223 ) }.into());
224 }
225 Ok(row_ll)
226 })
227 .collect::<Result<Vec<_>, _>>()?;
228
229 let mut log_likelihood = 0.0_f64;
235 for row_ll in rows {
236 log_likelihood += row_ll;
237 }
238 if !log_likelihood.is_finite() {
239 return Err(TransformationNormalError::NonFinite { reason: format!(
240 "TransformationNormalFamily row_quantities: log-likelihood is not finite ({log_likelihood})"
241 ) }.into());
242 }
243
244 Ok(TransformationNormalRowDerived { log_likelihood })
245}
246
247impl TransformationNormalFamily {
248 pub fn new(
259 response: &Array1<f64>,
260 weights: &Array1<f64>,
261 offset: &Array1<f64>,
262 covariate_design: DesignMatrix,
263 covariate_penalties: Vec<PenaltyMatrix>,
264 config: &TransformationNormalConfig,
265 warm_start: Option<&TransformationWarmStart>,
266 ) -> Result<Self, String> {
267 let n = response.len();
268 if covariate_design.nrows() != n {
269 return Err(TransformationNormalError::InvalidInput {
270 reason: format!(
271 "response length {} != covariate design rows {}",
272 n,
273 covariate_design.nrows()
274 ),
275 }
276 .into());
277 }
278 let p_cov = covariate_design.ncols();
279 if p_cov == 0 {
280 return Err(TransformationNormalError::DesignDegenerate {
281 reason: "covariate design has zero columns".to_string(),
282 }
283 .into());
284 }
285 if weights.len() != n {
286 return Err(TransformationNormalError::InvalidInput {
287 reason: format!("response length {} != weights length {}", n, weights.len()),
288 }
289 .into());
290 }
291 if offset.len() != n {
292 return Err(TransformationNormalError::InvalidInput {
293 reason: format!("response length {} != offset length {}", n, offset.len()),
294 }
295 .into());
296 }
297 for (i, &weight) in weights.iter().enumerate() {
298 if !weight.is_finite() {
299 return Err(TransformationNormalError::NonFinite {
300 reason: format!("weights[{i}] is not finite: {weight}"),
301 }
302 .into());
303 }
304 if weight < 0.0 {
305 return Err(TransformationNormalError::InvalidInput {
306 reason: format!("weights[{i}] must be non-negative: {weight}"),
307 }
308 .into());
309 }
310 }
311 for (i, &value) in offset.iter().enumerate() {
312 if !value.is_finite() {
313 return Err(TransformationNormalError::NonFinite {
314 reason: format!("offset[{i}] is not finite: {value}"),
315 }
316 .into());
317 }
318 }
319 for (i, sp) in covariate_penalties.iter().enumerate() {
320 let (r, c) = sp.shape();
321 if r != p_cov || c != p_cov {
322 return Err(TransformationNormalError::InvalidInput {
323 reason: format!(
324 "covariate penalty {} has shape ({r}, {c}), expected ({p_cov}, {p_cov})",
325 i,
326 ),
327 }
328 .into());
329 }
330 }
331
332 let (resp_val, resp_deriv, resp_penalties, resp_knots, resp_transform) =
334 build_response_basis(response, config)?;
335 let p_resp = resp_val.ncols();
336 let (response_lower_basis, response_upper_basis) = ctn_endpoint_bases(&resp_transform);
337
338 let x_val_kron = KroneckerDesign::new_khatri_rao(&resp_val, covariate_design.clone())?;
340 let x_deriv_kron = KroneckerDesign::new_khatri_rao(&resp_deriv, covariate_design.clone())?;
341 let p_total = p_resp * p_cov;
342 assert_eq!(x_val_kron.ncols(), p_total);
343 assert_eq!(x_deriv_kron.ncols(), p_total);
344
345 let initial_beta = compute_warm_start(
347 response,
348 weights,
349 offset,
350 &x_val_kron,
351 &x_deriv_kron,
352 &covariate_design,
353 &covariate_penalties,
354 p_resp,
355 p_cov,
356 warm_start,
357 )?;
358
359 let covariate_dense = covariate_design
361 .try_row_chunk(0..n)
362 .map_err(|e| format!("SCOP covariate dense materialization failed: {e}"))?;
363 let affine_shape = affine_shape_direction(
364 resp_knots.view(),
365 config.response_degree,
366 p_resp.saturating_sub(1),
367 )?;
368 let (tensor_penalties, tensor_penalty_layout) = build_tensor_penalties_kronecker(
369 &resp_penalties,
370 covariate_penalties,
371 resp_val.view(),
372 covariate_dense.view(),
373 weights.view(),
374 p_resp,
375 p_cov,
376 affine_shape.view(),
377 config,
378 )?;
379 let mut sorted_resp = response.to_vec();
381 sorted_resp.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
382 let resp_median = if sorted_resp.len() % 2 == 1 {
383 sorted_resp[sorted_resp.len() / 2]
384 } else {
385 0.5 * (sorted_resp[sorted_resp.len() / 2 - 1] + sorted_resp[sorted_resp.len() / 2])
386 };
387 let (response_floor_offset, response_lower_floor_offset, response_upper_floor_offset) =
388 ctn_floor_offsets(response.view(), resp_knots.view(), resp_median)?;
389
390 Ok(Self {
391 x_val_kron,
392 x_deriv_kron,
393 response_val_basis: resp_val,
394 response_lower_basis,
395 response_upper_basis,
396 response_deriv_basis: resp_deriv,
397 covariate_design,
398 weights: Arc::new(weights.clone()),
399 offset: Arc::new(offset.clone()),
400 tensor_penalties,
401 tensor_penalty_layout,
402 initial_beta,
403 block_name: "transformation".to_string(),
404 response_knots: resp_knots,
405 response_transform: resp_transform,
406 response_degree: config.response_degree,
407 response_median: resp_median,
408 response_floor_offset: Arc::new(response_floor_offset),
409 response_lower_floor_offset,
410 response_upper_floor_offset,
411 covariate_dense_cache: Arc::new(Mutex::new(None)),
412 row_quantity_cache: Arc::new(Mutex::new(None)),
413 outer_subsample_weights: None,
414 })
415 }
416
417 pub(crate) fn from_prebuilt_response_basis(
422 response: &Array1<f64>,
423 response_val_basis: Array2<f64>,
424 response_deriv_basis: Array2<f64>,
425 response_penalties: Vec<Array2<f64>>,
426 response_knots: Array1<f64>,
427 response_degree: usize,
428 response_transform: Array2<f64>,
429 weights: &Array1<f64>,
430 offset: &Array1<f64>,
431 covariate_design: DesignMatrix,
432 covariate_penalties: Vec<PenaltyMatrix>,
433 config: &TransformationNormalConfig,
434 warm_start: Option<&TransformationWarmStart>,
435 ) -> Result<Self, String> {
436 let n = response_val_basis.nrows();
437 if n == 0 {
438 return Err(TransformationNormalError::InvalidInput {
439 reason: "response basis has zero rows".to_string(),
440 }
441 .into());
442 }
443 if response.len() != n {
444 return Err(TransformationNormalError::InvalidInput {
445 reason: format!(
446 "response length {} != response basis rows {}",
447 response.len(),
448 n
449 ),
450 }
451 .into());
452 }
453 if covariate_design.nrows() != n {
454 return Err(TransformationNormalError::InvalidInput {
455 reason: format!(
456 "response basis rows {} != covariate design rows {}",
457 n,
458 covariate_design.nrows()
459 ),
460 }
461 .into());
462 }
463 let p_cov = covariate_design.ncols();
464 if p_cov == 0 {
465 return Err(TransformationNormalError::DesignDegenerate {
466 reason: "covariate design has zero columns".to_string(),
467 }
468 .into());
469 }
470 if weights.len() != n {
471 return Err(TransformationNormalError::InvalidInput {
472 reason: format!(
473 "response basis rows {} != weights length {}",
474 n,
475 weights.len()
476 ),
477 }
478 .into());
479 }
480 if offset.len() != n {
481 return Err(TransformationNormalError::InvalidInput {
482 reason: format!(
483 "response basis rows {} != offset length {}",
484 n,
485 offset.len()
486 ),
487 }
488 .into());
489 }
490 for (i, &weight) in weights.iter().enumerate() {
491 if !weight.is_finite() {
492 return Err(TransformationNormalError::NonFinite {
493 reason: format!("weights[{i}] is not finite: {weight}"),
494 }
495 .into());
496 }
497 if weight < 0.0 {
498 return Err(TransformationNormalError::InvalidInput {
499 reason: format!("weights[{i}] must be non-negative: {weight}"),
500 }
501 .into());
502 }
503 }
504 for (i, &value) in offset.iter().enumerate() {
505 if !value.is_finite() {
506 return Err(TransformationNormalError::NonFinite {
507 reason: format!("offset[{i}] is not finite: {value}"),
508 }
509 .into());
510 }
511 }
512 for (i, sp) in covariate_penalties.iter().enumerate() {
513 let (r, c) = sp.shape();
514 if r != p_cov || c != p_cov {
515 return Err(TransformationNormalError::InvalidInput {
516 reason: format!(
517 "covariate penalty {} has shape ({r}, {c}), expected ({p_cov}, {p_cov})",
518 i,
519 ),
520 }
521 .into());
522 }
523 }
524
525 let p_resp = response_val_basis.ncols();
526 if response_transform.ncols() + 1 != p_resp {
527 return Err(TransformationNormalError::InvalidInput { reason: format!(
528 "response transform columns {} imply p_resp {}, but response value basis has {} columns",
529 response_transform.ncols(),
530 response_transform.ncols() + 1,
531 p_resp
532 ) }.into());
533 }
534 let (response_lower_basis, response_upper_basis) = ctn_endpoint_bases(&response_transform);
535
536 let x_val_kron =
538 KroneckerDesign::new_khatri_rao(&response_val_basis, covariate_design.clone())?;
539 let x_deriv_kron =
540 KroneckerDesign::new_khatri_rao(&response_deriv_basis, covariate_design.clone())?;
541 let p_total = p_resp * p_cov;
542 assert_eq!(x_val_kron.ncols(), p_total);
543 assert_eq!(x_deriv_kron.ncols(), p_total);
544
545 let initial_beta = compute_warm_start(
546 response,
547 weights,
548 offset,
549 &x_val_kron,
550 &x_deriv_kron,
551 &covariate_design,
552 &covariate_penalties,
553 p_resp,
554 p_cov,
555 warm_start,
556 )?;
557
558 let covariate_dense = covariate_design
560 .try_row_chunk(0..n)
561 .map_err(|e| format!("SCOP covariate dense materialization failed: {e}"))?;
562 let affine_shape = affine_shape_direction(
563 response_knots.view(),
564 response_degree,
565 p_resp.saturating_sub(1),
566 )?;
567 let (tensor_penalties, tensor_penalty_layout) = build_tensor_penalties_kronecker(
568 &response_penalties,
569 covariate_penalties,
570 response_val_basis.view(),
571 covariate_dense.view(),
572 weights.view(),
573 p_resp,
574 p_cov,
575 affine_shape.view(),
576 config,
577 )?;
578 let mut sorted_resp = response.to_vec();
580 sorted_resp.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
581 let resp_median = if sorted_resp.len() % 2 == 1 {
582 sorted_resp[sorted_resp.len() / 2]
583 } else {
584 0.5 * (sorted_resp[sorted_resp.len() / 2 - 1] + sorted_resp[sorted_resp.len() / 2])
585 };
586 let (response_floor_offset, response_lower_floor_offset, response_upper_floor_offset) =
587 ctn_floor_offsets(response.view(), response_knots.view(), resp_median)?;
588
589 Ok(Self {
590 x_val_kron,
591 x_deriv_kron,
592 response_val_basis,
593 response_lower_basis,
594 response_upper_basis,
595 response_deriv_basis,
596 covariate_design,
597 weights: Arc::new(weights.clone()),
598 offset: Arc::new(offset.clone()),
599 tensor_penalties,
600 tensor_penalty_layout,
601 initial_beta,
602 block_name: "transformation".to_string(),
603 response_knots: response_knots.clone(),
604 response_transform: response_transform.clone(),
605 response_degree,
606 response_median: resp_median,
607 response_floor_offset: Arc::new(response_floor_offset),
608 response_lower_floor_offset,
609 response_upper_floor_offset,
610 covariate_dense_cache: Arc::new(Mutex::new(None)),
611 row_quantity_cache: Arc::new(Mutex::new(None)),
612 outer_subsample_weights: None,
613 })
614 }
615
616 pub fn response_knots(&self) -> &Array1<f64> {
618 &self.response_knots
619 }
620 pub fn response_transform(&self) -> &Array2<f64> {
621 &self.response_transform
622 }
623 pub fn response_degree(&self) -> usize {
624 self.response_degree
625 }
626 pub fn response_median(&self) -> f64 {
627 self.response_median
628 }
629
630 pub(crate) fn penalty_scale_log_lambdas(&self) -> Result<Array1<f64>, String> {
633 let policy = ResourcePolicy::default_library();
634 let likelihood_diagonal_mean = self
635 .x_val_kron
636 .weighted_gram_diagonal_mean(self.weights.as_ref(), &policy)?;
637 Ok(ctn_penalty_scale_log_lambdas(
638 &self.tensor_penalties,
639 likelihood_diagonal_mean,
640 ))
641 }
642
643 pub(crate) fn block_spec(
647 &self,
648 initial_log_lambdas: &Array1<f64>,
649 ) -> Result<ParameterBlockSpec, String> {
650 if initial_log_lambdas.len() != self.tensor_penalties.len() {
651 return Err(TransformationNormalError::InvalidInput {
652 reason: format!(
653 "transformation smoothing vector has length {}, expected {}",
654 initial_log_lambdas.len(),
655 self.tensor_penalties.len(),
656 ),
657 }
658 .into());
659 }
660 gam_problem::validate_log_strengths(initial_log_lambdas.iter().copied())
661 .map_err(|error| format!("invalid transformation smoothing strength: {error}"))?;
662 let offset = self.offset.as_ref() + self.response_floor_offset.as_ref();
663 Ok(ParameterBlockSpec {
664 name: self.block_name.clone(),
665 design: DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(self.x_val_kron.clone()))),
666 offset,
667 penalties: self.tensor_penalties.clone(),
668 nullspace_dims: vec![],
669 initial_log_lambdas: initial_log_lambdas.clone(),
670 initial_beta: Some(self.initial_beta.clone()),
671 gauge_priority: 100,
672 jacobian_callback: None,
673 stacked_design: None,
674 stacked_offset: None,
675 })
676 }
677
678 pub fn p_total(&self) -> usize {
680 self.x_val_kron.ncols()
681 }
682
683 pub fn n_obs(&self) -> usize {
685 self.x_val_kron.nrows()
686 }
687
688 pub(crate) fn p_resp(&self) -> usize {
690 self.response_val_basis.ncols()
691 }
692
693 pub(crate) fn p_cov(&self) -> usize {
695 self.covariate_design.ncols()
696 }
697
698 pub(crate) fn response_lower_basis(&self) -> &Array1<f64> {
701 &self.response_lower_basis
702 }
703
704 pub(crate) fn response_upper_basis(&self) -> &Array1<f64> {
707 &self.response_upper_basis
708 }
709
710 pub(crate) fn response_lower_floor_offset(&self) -> f64 {
713 self.response_lower_floor_offset
714 }
715
716 pub(crate) fn response_upper_floor_offset(&self) -> f64 {
719 self.response_upper_floor_offset
720 }
721
722 #[inline]
735 pub(crate) fn effective_weights(&self) -> &Array1<f64> {
736 match self.outer_subsample_weights.as_ref() {
737 Some(w) => w.as_ref(),
738 None => self.weights.as_ref(),
739 }
740 }
741
742 pub(crate) fn evaluate_response_bases(
753 &self,
754 response: ArrayView1<'_, f64>,
755 ) -> Result<(Array2<f64>, Array2<f64>), String> {
756 for (i, &v) in response.iter().enumerate() {
757 if !v.is_finite() {
758 return Err(TransformationNormalError::NonFinite {
759 reason: format!("evaluate_response_bases: response[{i}] is not finite: {v}"),
760 }
761 .into());
762 }
763 }
764 let (value, derivative) = ctn_response_bases_at(
765 response,
766 self.response_knots.view(),
767 self.response_degree,
768 None,
769 )?;
770 let p_resp = self.response_val_basis.ncols();
771 if value.ncols() != p_resp {
772 return Err(TransformationNormalError::InvalidInput {
773 reason: format!(
774 "evaluate_response_bases: rebuilt basis has {} columns but the fitted basis \
775 has {p_resp}",
776 value.ncols()
777 ),
778 }
779 .into());
780 }
781 Ok((value, derivative))
782 }
783
784 pub(crate) fn with_outer_subsample(
795 &self,
796 mask: &Array1<f64>,
797 ) -> Result<Self, TransformationNormalError> {
798 let n = self.weights.len();
799 if mask.len() != n {
800 bail_invalid_tnorm!(
801 "outer-score subsample mask length {} != n={}",
802 mask.len(),
803 n
804 );
805 }
806 let mut effective = Array1::<f64>::zeros(n);
807 for i in 0..n {
808 let m = mask[i];
809 if !m.is_finite() || m < 0.0 {
810 bail_invalid_tnorm!(
811 "outer-score subsample mask[{i}] = {m} is invalid (must be finite and >= 0)"
812 );
813 }
814 effective[i] = self.weights[i] * m;
815 }
816 Ok(Self {
817 x_val_kron: self.x_val_kron.clone(),
819 x_deriv_kron: self.x_deriv_kron.clone(),
820 response_val_basis: self.response_val_basis.clone(),
821 response_lower_basis: self.response_lower_basis.clone(),
822 response_upper_basis: self.response_upper_basis.clone(),
823 response_deriv_basis: self.response_deriv_basis.clone(),
824 covariate_design: self.covariate_design.clone(),
825 covariate_dense_cache: Arc::clone(&self.covariate_dense_cache),
826 weights: Arc::clone(&self.weights),
827 offset: Arc::clone(&self.offset),
828 tensor_penalties: self.tensor_penalties.clone(),
829 tensor_penalty_layout: self.tensor_penalty_layout,
830 initial_beta: self.initial_beta.clone(),
831 block_name: self.block_name.clone(),
832 response_knots: self.response_knots.clone(),
833 response_transform: self.response_transform.clone(),
834 response_degree: self.response_degree,
835 response_median: self.response_median,
836 response_floor_offset: Arc::clone(&self.response_floor_offset),
837 response_lower_floor_offset: self.response_lower_floor_offset,
838 response_upper_floor_offset: self.response_upper_floor_offset,
839 row_quantity_cache: Arc::new(Mutex::new(None)),
843 outer_subsample_weights: Some(Arc::new(effective)),
844 })
845 }
846
847 pub(crate) fn maybe_with_outer_subsample_from_options(
850 &self,
851 options: &BlockwiseFitOptions,
852 ) -> Result<Option<Self>, TransformationNormalError> {
853 let Some(sub) = options.outer_score_subsample.as_ref() else {
854 return Ok(None);
855 };
856 let n = self.weights.len();
857 let mut mask = Array1::<f64>::zeros(n);
858 for row in sub.rows.iter() {
859 if row.index < n {
860 mask[row.index] = row.weight;
861 }
862 }
863 Ok(Some(self.with_outer_subsample(&mask)?))
864 }
865
866 pub(crate) fn covariate_dense_arc(&self) -> Result<Arc<Array2<f64>>, String> {
869 let mut cache = self
870 .covariate_dense_cache
871 .lock()
872 .expect("CTN covariate dense cache mutex poisoned");
873 if let Some(cached) = cache.as_ref() {
874 return Ok(cached.clone());
875 }
876 let dense = Arc::new(
877 self.covariate_design
878 .try_row_chunk(0..self.response_val_basis.nrows())
879 .map_err(|e| format!("SCOP covariate dense materialization failed: {e}"))?,
880 );
881 *cache = Some(dense.clone());
882 Ok(dense)
883 }
884
885 pub(crate) fn row_quantities(
886 &self,
887 beta: &Array1<f64>,
888 ) -> Result<TransformationNormalRowQuantityCache, String> {
889 {
890 let cache = self
891 .row_quantity_cache
892 .lock()
893 .expect("CTN row quantity cache mutex poisoned");
894 if let Some(cached) = cache.as_ref().filter(|cached| cached.matches_beta(beta)) {
895 return Ok(cached.clone());
896 }
897 }
898
899 let p_resp = self.response_val_basis.ncols();
900 let p_cov = self.covariate_design.ncols();
901 let beta_mat = beta
902 .view()
903 .into_shape_with_order((p_resp, p_cov))
904 .map_err(|e| format!("SCOP endpoint beta reshape failed: {e}"))?;
905 let cov = self.covariate_dense_arc()?;
906
907 let alpha = fast_abt(cov.as_ref(), &beta_mat);
921 let n = alpha.nrows();
922 let mut h = Array1::<f64>::zeros(n);
923 let mut h_prime = Array1::<f64>::zeros(n);
924 ndarray::Zip::indexed(&mut h)
932 .and(&mut h_prime)
933 .par_for_each(|i, h_i, hp_i| {
934 let alpha_row = alpha.row(i);
935 let val_row = self.response_val_basis.row(i);
936 let deriv_row = self.response_deriv_basis.row(i);
937 let geometry = ctn_row_geometry(
939 TransformationNormalParameterization::DirectAlpha,
940 alpha_row,
941 CtnRowBases {
942 value: val_row,
943 derivative: deriv_row,
944 lower: self.response_lower_basis.view(),
945 upper: self.response_upper_basis.view(),
946 },
947 CtnRowFloors {
948 additive_offset: self.offset[i],
949 value_floor: self.response_floor_offset[i],
950 lower_floor: self.response_lower_floor_offset,
951 upper_floor: self.response_upper_floor_offset,
952 },
953 );
954 *h_i = geometry.h;
955 *hp_i = geometry.h_prime;
956 });
957 for (i, &value) in h.iter().enumerate() {
958 if !value.is_finite() {
959 return Err(TransformationNormalError::NonFinite {
960 reason: format!(
961 "TransformationNormalFamily row_quantities: h[{i}] = {value} is not finite"
962 ),
963 }
964 .into());
965 }
966 if value.abs() > TRANSFORMATION_NORMAL_H_ABS_MAX {
967 return Err(TransformationNormalError::InvalidInput { reason: format!(
968 "TransformationNormalFamily row_quantities: h[{i}] = {value:.6e} exceeds the standard-normal domain bound ±{TRANSFORMATION_NORMAL_H_ABS_MAX}"
969 ) }.into());
970 }
971 }
972 let mut min_hp = f64::INFINITY;
984 let mut nonfinite_idx: Option<usize> = None;
985 for (i, &hp) in h_prime.iter().enumerate() {
986 if !hp.is_finite() {
987 nonfinite_idx = Some(i);
988 break;
989 }
990 if hp < min_hp {
991 min_hp = hp;
992 }
993 }
994 if let Some(i) = nonfinite_idx {
995 return Err(TransformationNormalError::NonFinite {
996 reason: format!(
997 "TransformationNormalFamily row_quantities: h'[{i}] = {} is not finite",
998 h_prime[i]
999 ),
1000 }
1001 .into());
1002 }
1003 if min_hp <= 0.0 {
1004 return Err(TransformationNormalError::MonotonicityViolated { reason: format!(
1005 "TransformationNormalFamily row_quantities: h' has non-positive values (min = {min_hp:.6e}). \
1006 Monotonicity constraint may be violated."
1007 ) }.into());
1008 }
1009 let derived =
1014 build_transformation_row_derived(&h, &h_prime, self.effective_weights())?;
1015 let row_quantities = TransformationNormalRowQuantityCache {
1016 beta: Arc::new(beta.clone()),
1017 alpha: Arc::new(alpha),
1018 h: Arc::new(h),
1019 h_prime: Arc::new(h_prime),
1020 log_likelihood: derived.log_likelihood,
1021 };
1022
1023 let mut cache = self
1024 .row_quantity_cache
1025 .lock()
1026 .expect("CTN row quantity cache mutex poisoned");
1027 *cache = Some(row_quantities.clone());
1028 Ok(row_quantities)
1029 }
1030}