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
66 pub(crate) initial_beta: Array1<f64>,
68 pub(crate) initial_log_lambdas: Array1<f64>,
69
70 pub(crate) block_name: String,
72
73 pub(crate) response_knots: Array1<f64>,
75 pub(crate) response_transform: Array2<f64>,
76 pub(crate) response_degree: usize,
77 pub(crate) response_median: f64,
78 pub(crate) response_floor_offset: Arc<Array1<f64>>,
79 pub(crate) response_lower_floor_offset: f64,
80 pub(crate) response_upper_floor_offset: f64,
81
82 pub(crate) row_quantity_cache: Arc<Mutex<Option<TransformationNormalRowQuantityCache>>>,
90 pub(crate) outer_subsample_weights: Option<Arc<Array1<f64>>>,
105}
106
107#[derive(Clone)]
108pub(crate) struct TransformationNormalRowQuantityCache {
109 pub(crate) beta: Arc<Array1<f64>>,
110 pub(crate) gamma: Arc<Array2<f64>>,
111 pub(crate) h: Arc<Array1<f64>>,
112 pub(crate) h_prime: Arc<Array1<f64>>,
113 pub(crate) h_lower: Arc<Array1<f64>>,
114 pub(crate) h_upper: Arc<Array1<f64>>,
115 pub(crate) endpoint_q: Arc<Vec<LogNormalCdfDiffDerivatives>>,
116 pub(crate) log_likelihood: f64,
117}
118
119#[derive(Debug)]
120pub(crate) struct TransformationNormalRowDerived {
121 pub(crate) log_likelihood: f64,
122 pub(crate) endpoint_q: Vec<LogNormalCdfDiffDerivatives>,
123}
124
125impl TransformationNormalRowQuantityCache {
126 pub(crate) fn matches_beta(&self, beta: &Array1<f64>) -> bool {
127 beta_bits_match(&self.beta, beta)
128 }
129}
130
131pub(crate) fn build_transformation_row_derived(
132 h: &Array1<f64>,
133 h_prime: &Array1<f64>,
134 h_lower: &Array1<f64>,
135 h_upper: &Array1<f64>,
136 weights: &Array1<f64>,
137) -> Result<TransformationNormalRowDerived, String> {
138 let n = h_prime.len();
139 assert_eq!(h.len(), n);
140 assert_eq!(h_lower.len(), n);
141 assert_eq!(h_upper.len(), n);
142 assert_eq!(weights.len(), n);
143
144 if let Some((i, value)) = h
145 .iter()
146 .copied()
147 .enumerate()
148 .find(|(_, value)| !value.is_finite())
149 {
150 return Err(TransformationNormalError::NonFinite {
151 reason: format!(
152 "TransformationNormalFamily row_quantities: h[{i}] = {value} is not finite"
153 ),
154 }
155 .into());
156 }
157 if let Some((i, value)) = weights
158 .iter()
159 .copied()
160 .enumerate()
161 .find(|(_, value)| !value.is_finite())
162 {
163 return Err(TransformationNormalError::NonFinite {
164 reason: format!(
165 "TransformationNormalFamily row_quantities: weight[{i}] = {value} is not finite"
166 ),
167 }
168 .into());
169 }
170
171 use rayon::iter::{IntoParallelIterator, ParallelIterator};
181 let rows: Vec<(f64, LogNormalCdfDiffDerivatives)> = (0..n)
182 .into_par_iter()
183 .map(|i| -> Result<(f64, LogNormalCdfDiffDerivatives), String> {
184 let hp = h_prime[i];
185 let inv_h_prime = 1.0 / hp;
186 let inv_h_prime_sq = inv_h_prime * inv_h_prime;
187 let inv_h_prime_cu = inv_h_prime_sq * inv_h_prime;
188 let inv_h_prime_qu = inv_h_prime_sq * inv_h_prime_sq;
189 let w_i = weights[i];
190 let h_i = h[i];
191 let weighted_h = w_i * h_i;
192 let weighted_inv_h_prime = w_i * inv_h_prime;
193 let weighted_inv_h_prime_sq = w_i * inv_h_prime_sq;
194 let q = log_normal_cdf_diff_derivatives(h_upper[i], h_lower[i]).map_err(|e| {
195 format!("TransformationNormalFamily row_quantities: row {i} invalid endpoint normalizer: {e}")
196 })?;
197 let log_z = q.log_z;
198 let row_ll = w_i
204 * (-0.5 * h_i * h_i - 0.5 * (2.0 * std::f64::consts::PI).ln() + hp.ln() - log_z);
205 if !(inv_h_prime.is_finite()
209 && inv_h_prime_sq.is_finite()
210 && inv_h_prime_cu.is_finite()
211 && inv_h_prime_qu.is_finite()
212 && weighted_h.is_finite()
213 && weighted_inv_h_prime.is_finite()
214 && weighted_inv_h_prime_sq.is_finite()
215 && log_z.is_finite())
216 {
217 let derived_values = [
218 ("1/h'", inv_h_prime),
219 ("1/h'^2", inv_h_prime_sq),
220 ("1/h'^3", inv_h_prime_cu),
221 ("1/h'^4", inv_h_prime_qu),
222 ("w*h", weighted_h),
223 ("w/h'", weighted_inv_h_prime),
224 ("w/h'^2", weighted_inv_h_prime_sq),
225 ("log normalizer", log_z),
226 ];
227 for (name, value) in derived_values {
228 if !value.is_finite() {
229 return Err(TransformationNormalError::NonFinite { reason: format!(
230 "TransformationNormalFamily row_quantities: {name} at row {i} is not finite ({value}); h'={hp} is outside the finite exact-derivative range",
231 ) }.into());
232 }
233 }
234 return Err(TransformationNormalError::NonFinite { reason: format!(
235 "TransformationNormalFamily row_quantities: row {i} entered non-finite branch but no named field was non-finite; h'={hp}",
236 ) }.into());
237 }
238 Ok((row_ll, q))
239 })
240 .collect::<Result<Vec<_>, _>>()?;
241
242 let mut log_likelihood = 0.0;
248 let mut endpoint_q = Vec::with_capacity(n);
249 for (row_ll, q) in rows {
250 log_likelihood += row_ll;
251 endpoint_q.push(q);
252 }
253 if !log_likelihood.is_finite() {
254 return Err(TransformationNormalError::NonFinite { reason: format!(
255 "TransformationNormalFamily row_quantities: log-likelihood is not finite ({log_likelihood})"
256 ) }.into());
257 }
258
259 Ok(TransformationNormalRowDerived {
260 log_likelihood,
261 endpoint_q,
262 })
263}
264
265impl TransformationNormalFamily {
266 pub fn new(
277 response: &Array1<f64>,
278 weights: &Array1<f64>,
279 offset: &Array1<f64>,
280 covariate_design: DesignMatrix,
281 covariate_penalties: Vec<PenaltyMatrix>,
282 config: &TransformationNormalConfig,
283 warm_start: Option<&TransformationWarmStart>,
284 ) -> Result<Self, String> {
285 let n = response.len();
286 if covariate_design.nrows() != n {
287 return Err(TransformationNormalError::InvalidInput {
288 reason: format!(
289 "response length {} != covariate design rows {}",
290 n,
291 covariate_design.nrows()
292 ),
293 }
294 .into());
295 }
296 let p_cov = covariate_design.ncols();
297 if p_cov == 0 {
298 return Err(TransformationNormalError::DesignDegenerate {
299 reason: "covariate design has zero columns".to_string(),
300 }
301 .into());
302 }
303 if weights.len() != n {
304 return Err(TransformationNormalError::InvalidInput {
305 reason: format!("response length {} != weights length {}", n, weights.len()),
306 }
307 .into());
308 }
309 if offset.len() != n {
310 return Err(TransformationNormalError::InvalidInput {
311 reason: format!("response length {} != offset length {}", n, offset.len()),
312 }
313 .into());
314 }
315 for (i, &weight) in weights.iter().enumerate() {
316 if !weight.is_finite() {
317 return Err(TransformationNormalError::NonFinite {
318 reason: format!("weights[{i}] is not finite: {weight}"),
319 }
320 .into());
321 }
322 if weight < 0.0 {
323 return Err(TransformationNormalError::InvalidInput {
324 reason: format!("weights[{i}] must be non-negative: {weight}"),
325 }
326 .into());
327 }
328 }
329 for (i, &value) in offset.iter().enumerate() {
330 if !value.is_finite() {
331 return Err(TransformationNormalError::NonFinite {
332 reason: format!("offset[{i}] is not finite: {value}"),
333 }
334 .into());
335 }
336 }
337 for (i, sp) in covariate_penalties.iter().enumerate() {
338 let (r, c) = sp.shape();
339 if r != p_cov || c != p_cov {
340 return Err(TransformationNormalError::InvalidInput {
341 reason: format!(
342 "covariate penalty {} has shape ({r}, {c}), expected ({p_cov}, {p_cov})",
343 i,
344 ),
345 }
346 .into());
347 }
348 }
349
350 let (resp_val, resp_deriv, resp_penalties, resp_knots, resp_transform) =
352 build_response_basis(response, config)?;
353 let p_resp = resp_val.ncols();
354 let (response_lower_basis, response_upper_basis) =
355 response_endpoint_value_bases(&resp_transform);
356
357 let x_val_kron = KroneckerDesign::new_khatri_rao(&resp_val, covariate_design.clone())?;
359 let x_deriv_kron = KroneckerDesign::new_khatri_rao(&resp_deriv, covariate_design.clone())?;
360 let p_total = p_resp * p_cov;
361 assert_eq!(x_val_kron.ncols(), p_total);
362 assert_eq!(x_deriv_kron.ncols(), p_total);
363
364 let initial_beta = compute_warm_start(
366 response,
367 weights,
368 offset,
369 &x_val_kron,
370 &x_deriv_kron,
371 &covariate_design,
372 &covariate_penalties,
373 p_resp,
374 p_cov,
375 warm_start,
376 )?;
377
378 let tensor_penalties = build_tensor_penalties_kronecker(
380 &resp_penalties,
381 covariate_penalties,
382 p_resp,
383 p_cov,
384 config,
385 )?;
386 let policy = ResourcePolicy::default_library();
387 let x_val_weighted_gram = x_val_kron.weighted_gram(weights, &policy);
388
389 let initial_log_lambdas =
391 ctn_penalty_scale_log_lambdas(&tensor_penalties, &x_val_weighted_gram);
392
393 let mut sorted_resp = response.to_vec();
395 sorted_resp.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
396 let resp_median = if sorted_resp.len() % 2 == 1 {
397 sorted_resp[sorted_resp.len() / 2]
398 } else {
399 0.5 * (sorted_resp[sorted_resp.len() / 2 - 1] + sorted_resp[sorted_resp.len() / 2])
400 };
401 let (response_floor_offset, response_lower_floor_offset, response_upper_floor_offset) =
402 response_floor_offsets(response, &resp_knots, resp_median);
403
404 Ok(Self {
405 x_val_kron,
406 x_deriv_kron,
407 response_val_basis: resp_val,
408 response_lower_basis,
409 response_upper_basis,
410 response_deriv_basis: resp_deriv,
411 covariate_design,
412 weights: Arc::new(weights.clone()),
413 offset: Arc::new(offset.clone()),
414 tensor_penalties,
415 initial_beta,
416 initial_log_lambdas,
417 block_name: "transformation".to_string(),
418 response_knots: resp_knots,
419 response_transform: resp_transform,
420 response_degree: config.response_degree,
421 response_median: resp_median,
422 response_floor_offset: Arc::new(response_floor_offset),
423 response_lower_floor_offset,
424 response_upper_floor_offset,
425 covariate_dense_cache: Arc::new(Mutex::new(None)),
426 row_quantity_cache: Arc::new(Mutex::new(None)),
427 outer_subsample_weights: None,
428 })
429 }
430
431 pub fn from_prebuilt_response_basis(
436 response: &Array1<f64>,
437 response_val_basis: Array2<f64>,
438 response_deriv_basis: Array2<f64>,
439 response_penalties: Vec<Array2<f64>>,
440 response_knots: Array1<f64>,
441 response_degree: usize,
442 response_transform: Array2<f64>,
443 weights: &Array1<f64>,
444 offset: &Array1<f64>,
445 covariate_design: DesignMatrix,
446 covariate_penalties: Vec<PenaltyMatrix>,
447 config: &TransformationNormalConfig,
448 warm_start: Option<&TransformationWarmStart>,
449 ) -> Result<Self, String> {
450 let n = response_val_basis.nrows();
451 if n == 0 {
452 return Err(TransformationNormalError::InvalidInput {
453 reason: "response basis has zero rows".to_string(),
454 }
455 .into());
456 }
457 if response.len() != n {
458 return Err(TransformationNormalError::InvalidInput {
459 reason: format!(
460 "response length {} != response basis rows {}",
461 response.len(),
462 n
463 ),
464 }
465 .into());
466 }
467 if covariate_design.nrows() != n {
468 return Err(TransformationNormalError::InvalidInput {
469 reason: format!(
470 "response basis rows {} != covariate design rows {}",
471 n,
472 covariate_design.nrows()
473 ),
474 }
475 .into());
476 }
477 let p_cov = covariate_design.ncols();
478 if p_cov == 0 {
479 return Err(TransformationNormalError::DesignDegenerate {
480 reason: "covariate design has zero columns".to_string(),
481 }
482 .into());
483 }
484 if weights.len() != n {
485 return Err(TransformationNormalError::InvalidInput {
486 reason: format!(
487 "response basis rows {} != weights length {}",
488 n,
489 weights.len()
490 ),
491 }
492 .into());
493 }
494 if offset.len() != n {
495 return Err(TransformationNormalError::InvalidInput {
496 reason: format!(
497 "response basis rows {} != offset length {}",
498 n,
499 offset.len()
500 ),
501 }
502 .into());
503 }
504 for (i, &weight) in weights.iter().enumerate() {
505 if !weight.is_finite() {
506 return Err(TransformationNormalError::NonFinite {
507 reason: format!("weights[{i}] is not finite: {weight}"),
508 }
509 .into());
510 }
511 if weight < 0.0 {
512 return Err(TransformationNormalError::InvalidInput {
513 reason: format!("weights[{i}] must be non-negative: {weight}"),
514 }
515 .into());
516 }
517 }
518 for (i, &value) in offset.iter().enumerate() {
519 if !value.is_finite() {
520 return Err(TransformationNormalError::NonFinite {
521 reason: format!("offset[{i}] is not finite: {value}"),
522 }
523 .into());
524 }
525 }
526 for (i, sp) in covariate_penalties.iter().enumerate() {
527 let (r, c) = sp.shape();
528 if r != p_cov || c != p_cov {
529 return Err(TransformationNormalError::InvalidInput {
530 reason: format!(
531 "covariate penalty {} has shape ({r}, {c}), expected ({p_cov}, {p_cov})",
532 i,
533 ),
534 }
535 .into());
536 }
537 }
538
539 let p_resp = response_val_basis.ncols();
540 if response_transform.ncols() + 1 != p_resp {
541 return Err(TransformationNormalError::InvalidInput { reason: format!(
542 "response transform columns {} imply p_resp {}, but response value basis has {} columns",
543 response_transform.ncols(),
544 response_transform.ncols() + 1,
545 p_resp
546 ) }.into());
547 }
548 let (response_lower_basis, response_upper_basis) =
549 response_endpoint_value_bases(&response_transform);
550
551 let x_val_kron =
553 KroneckerDesign::new_khatri_rao(&response_val_basis, covariate_design.clone())?;
554 let x_deriv_kron =
555 KroneckerDesign::new_khatri_rao(&response_deriv_basis, covariate_design.clone())?;
556 let p_total = p_resp * p_cov;
557 assert_eq!(x_val_kron.ncols(), p_total);
558 assert_eq!(x_deriv_kron.ncols(), p_total);
559
560 let initial_beta = compute_warm_start(
561 response,
562 weights,
563 offset,
564 &x_val_kron,
565 &x_deriv_kron,
566 &covariate_design,
567 &covariate_penalties,
568 p_resp,
569 p_cov,
570 warm_start,
571 )?;
572
573 let tensor_penalties = build_tensor_penalties_kronecker(
575 &response_penalties,
576 covariate_penalties,
577 p_resp,
578 p_cov,
579 config,
580 )?;
581 let policy = ResourcePolicy::default_library();
582 let x_val_weighted_gram = x_val_kron.weighted_gram(weights, &policy);
583
584 let initial_log_lambdas =
585 ctn_penalty_scale_log_lambdas(&tensor_penalties, &x_val_weighted_gram);
586
587 let mut sorted_resp = response.to_vec();
589 sorted_resp.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
590 let resp_median = if sorted_resp.len() % 2 == 1 {
591 sorted_resp[sorted_resp.len() / 2]
592 } else {
593 0.5 * (sorted_resp[sorted_resp.len() / 2 - 1] + sorted_resp[sorted_resp.len() / 2])
594 };
595 let (response_floor_offset, response_lower_floor_offset, response_upper_floor_offset) =
596 response_floor_offsets(response, &response_knots, resp_median);
597
598 Ok(Self {
599 x_val_kron,
600 x_deriv_kron,
601 response_val_basis,
602 response_lower_basis,
603 response_upper_basis,
604 response_deriv_basis,
605 covariate_design,
606 weights: Arc::new(weights.clone()),
607 offset: Arc::new(offset.clone()),
608 tensor_penalties,
609 initial_beta,
610 initial_log_lambdas,
611 block_name: "transformation".to_string(),
612 response_knots: response_knots.clone(),
613 response_transform: response_transform.clone(),
614 response_degree,
615 response_median: resp_median,
616 response_floor_offset: Arc::new(response_floor_offset),
617 response_lower_floor_offset,
618 response_upper_floor_offset,
619 covariate_dense_cache: Arc::new(Mutex::new(None)),
620 row_quantity_cache: Arc::new(Mutex::new(None)),
621 outer_subsample_weights: None,
622 })
623 }
624
625 pub fn response_knots(&self) -> &Array1<f64> {
627 &self.response_knots
628 }
629 pub fn response_transform(&self) -> &Array2<f64> {
630 &self.response_transform
631 }
632 pub fn response_degree(&self) -> usize {
633 self.response_degree
634 }
635 pub fn response_median(&self) -> f64 {
636 self.response_median
637 }
638
639 pub fn block_spec(&self) -> ParameterBlockSpec {
641 let offset = self.offset.as_ref() + self.response_floor_offset.as_ref();
642 ParameterBlockSpec {
643 name: self.block_name.clone(),
644 design: DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(self.x_val_kron.clone()))),
645 offset,
646 penalties: self.tensor_penalties.clone(),
647 nullspace_dims: vec![],
648 initial_log_lambdas: self.initial_log_lambdas.clone(),
649 initial_beta: Some(self.initial_beta.clone()),
650 gauge_priority: 100,
651 jacobian_callback: None,
652 stacked_design: None,
653 stacked_offset: None,
654 }
655 }
656
657 pub fn p_total(&self) -> usize {
659 self.x_val_kron.ncols()
660 }
661
662 pub fn n_obs(&self) -> usize {
664 self.x_val_kron.nrows()
665 }
666
667 pub(crate) fn p_resp(&self) -> usize {
669 self.response_val_basis.ncols()
670 }
671
672 pub(crate) fn p_cov(&self) -> usize {
674 self.covariate_design.ncols()
675 }
676
677 pub(crate) fn response_lower_basis(&self) -> &Array1<f64> {
680 &self.response_lower_basis
681 }
682
683 pub(crate) fn response_upper_basis(&self) -> &Array1<f64> {
686 &self.response_upper_basis
687 }
688
689 pub(crate) fn response_lower_floor_offset(&self) -> f64 {
692 self.response_lower_floor_offset
693 }
694
695 pub(crate) fn response_upper_floor_offset(&self) -> f64 {
698 self.response_upper_floor_offset
699 }
700
701 #[inline]
714 pub(crate) fn effective_weights(&self) -> &Array1<f64> {
715 match self.outer_subsample_weights.as_ref() {
716 Some(w) => w.as_ref(),
717 None => self.weights.as_ref(),
718 }
719 }
720
721 pub(crate) fn evaluate_response_value_basis(
731 &self,
732 response: ArrayView1<'_, f64>,
733 ) -> Result<Array2<f64>, String> {
734 let n = response.len();
735 for (i, &v) in response.iter().enumerate() {
736 if !v.is_finite() {
737 return Err(TransformationNormalError::NonFinite {
738 reason: format!(
739 "evaluate_response_value_basis: response[{i}] is not finite: {v}"
740 ),
741 }
742 .into());
743 }
744 }
745 let (i_val_basis, _) = create_basis::<Dense>(
746 response,
747 KnotSource::Provided(self.response_knots.view()),
748 self.response_degree,
749 BasisOptions::i_spline(),
750 )
751 .map_err(|e| format!("evaluate_response_value_basis: I-spline build failed: {e}"))?;
752 let shape_val = i_val_basis.as_ref();
753 let p_shape = shape_val.ncols();
754 let p_resp = self.response_val_basis.ncols();
755 if p_shape + 1 != p_resp {
756 return Err(TransformationNormalError::InvalidInput {
757 reason: format!(
758 "evaluate_response_value_basis: rebuilt shape columns {p_shape} imply p_resp {}, \
759 but fitted basis has {p_resp} columns",
760 p_shape + 1
761 ),
762 }
763 .into());
764 }
765 let mut resp_val = Array2::<f64>::zeros((n, p_resp));
766 resp_val.column_mut(0).fill(1.0);
767 resp_val.slice_mut(s![.., 1..]).assign(shape_val);
768 Ok(resp_val)
769 }
770
771 pub(crate) fn with_outer_subsample(
782 &self,
783 mask: &Array1<f64>,
784 ) -> Result<Self, TransformationNormalError> {
785 let n = self.weights.len();
786 if mask.len() != n {
787 bail_invalid_tnorm!(
788 "outer-score subsample mask length {} != n={}",
789 mask.len(),
790 n
791 );
792 }
793 let mut effective = Array1::<f64>::zeros(n);
794 for i in 0..n {
795 let m = mask[i];
796 if !m.is_finite() || m < 0.0 {
797 bail_invalid_tnorm!(
798 "outer-score subsample mask[{i}] = {m} is invalid (must be finite and >= 0)"
799 );
800 }
801 effective[i] = self.weights[i] * m;
802 }
803 Ok(Self {
804 x_val_kron: self.x_val_kron.clone(),
806 x_deriv_kron: self.x_deriv_kron.clone(),
807 response_val_basis: self.response_val_basis.clone(),
808 response_lower_basis: self.response_lower_basis.clone(),
809 response_upper_basis: self.response_upper_basis.clone(),
810 response_deriv_basis: self.response_deriv_basis.clone(),
811 covariate_design: self.covariate_design.clone(),
812 covariate_dense_cache: Arc::clone(&self.covariate_dense_cache),
813 weights: Arc::clone(&self.weights),
814 offset: Arc::clone(&self.offset),
815 tensor_penalties: self.tensor_penalties.clone(),
816 initial_beta: self.initial_beta.clone(),
817 initial_log_lambdas: self.initial_log_lambdas.clone(),
818 block_name: self.block_name.clone(),
819 response_knots: self.response_knots.clone(),
820 response_transform: self.response_transform.clone(),
821 response_degree: self.response_degree,
822 response_median: self.response_median,
823 response_floor_offset: Arc::clone(&self.response_floor_offset),
824 response_lower_floor_offset: self.response_lower_floor_offset,
825 response_upper_floor_offset: self.response_upper_floor_offset,
826 row_quantity_cache: Arc::new(Mutex::new(None)),
830 outer_subsample_weights: Some(Arc::new(effective)),
831 })
832 }
833
834 pub(crate) fn maybe_with_outer_subsample_from_options(
837 &self,
838 options: &BlockwiseFitOptions,
839 ) -> Result<Option<Self>, TransformationNormalError> {
840 let Some(sub) = options.outer_score_subsample.as_ref() else {
841 return Ok(None);
842 };
843 let n = self.weights.len();
844 let mut mask = Array1::<f64>::zeros(n);
845 for row in sub.rows.iter() {
846 if row.index < n {
847 mask[row.index] = row.weight;
848 }
849 }
850 Ok(Some(self.with_outer_subsample(&mask)?))
851 }
852
853 pub(crate) fn covariate_dense_arc(&self) -> Result<Arc<Array2<f64>>, String> {
856 let mut cache = self
857 .covariate_dense_cache
858 .lock()
859 .expect("CTN covariate dense cache mutex poisoned");
860 if let Some(cached) = cache.as_ref() {
861 return Ok(cached.clone());
862 }
863 let dense = Arc::new(
864 self.covariate_design
865 .try_row_chunk(0..self.response_val_basis.nrows())
866 .map_err(|e| format!("SCOP covariate dense materialization failed: {e}"))?,
867 );
868 *cache = Some(dense.clone());
869 Ok(dense)
870 }
871
872 pub(crate) fn row_quantities(
873 &self,
874 beta: &Array1<f64>,
875 ) -> Result<TransformationNormalRowQuantityCache, String> {
876 {
877 let cache = self
878 .row_quantity_cache
879 .lock()
880 .expect("CTN row quantity cache mutex poisoned");
881 if let Some(cached) = cache.as_ref().filter(|cached| cached.matches_beta(beta)) {
882 return Ok(cached.clone());
883 }
884 }
885
886 let p_resp = self.response_val_basis.ncols();
887 let p_cov = self.covariate_design.ncols();
888 let beta_mat = beta
889 .view()
890 .into_shape_with_order((p_resp, p_cov))
891 .map_err(|e| format!("SCOP endpoint beta reshape failed: {e}"))?;
892 let cov = self.covariate_dense_arc()?;
893
894 let gamma = fast_abt(cov.as_ref(), &beta_mat);
904 let n = gamma.nrows();
905 let mut h = Array1::<f64>::zeros(n);
906 let mut h_prime = Array1::<f64>::zeros(n);
907 let mut h_lower = Array1::<f64>::zeros(n);
908 let mut h_upper = Array1::<f64>::zeros(n);
909 ndarray::Zip::indexed(&mut h)
914 .and(&mut h_prime)
915 .and(&mut h_lower)
916 .and(&mut h_upper)
917 .par_for_each(|i, h_i, hp_i, lower_i, upper_i| {
918 let gamma_row = gamma.row(i);
919 let val_row = self.response_val_basis.row(i);
920 let deriv_row = self.response_deriv_basis.row(i);
921 let g0 = gamma_row[0];
922 let offset_i = self.offset[i];
923 let mut h_acc = val_row[0] * g0 + offset_i + self.response_floor_offset[i];
924 let mut hp_acc = deriv_row[0] * g0 + TRANSFORMATION_MONOTONICITY_EPS;
925 let mut lower_acc =
926 self.response_lower_basis[0] * g0 + offset_i + self.response_lower_floor_offset;
927 let mut upper_acc =
928 self.response_upper_basis[0] * g0 + offset_i + self.response_upper_floor_offset;
929 for k in 1..p_resp {
930 let g_sq = gamma_row[k] * gamma_row[k];
931 h_acc += val_row[k] * g_sq;
932 hp_acc += deriv_row[k] * g_sq;
933 lower_acc += self.response_lower_basis[k] * g_sq;
934 upper_acc += self.response_upper_basis[k] * g_sq;
935 }
936 *h_i = h_acc;
937 *hp_i = hp_acc;
938 *lower_i = lower_acc;
939 *upper_i = upper_acc;
940 });
941 for (i, &value) in h.iter().enumerate() {
942 if !value.is_finite() {
943 return Err(TransformationNormalError::NonFinite {
944 reason: format!(
945 "TransformationNormalFamily row_quantities: h[{i}] = {value} is not finite"
946 ),
947 }
948 .into());
949 }
950 if value.abs() > TRANSFORMATION_NORMAL_H_ABS_MAX {
951 return Err(TransformationNormalError::InvalidInput { reason: format!(
952 "TransformationNormalFamily row_quantities: h[{i}] = {value:.6e} exceeds the standard-normal domain bound ±{TRANSFORMATION_NORMAL_H_ABS_MAX}"
953 ) }.into());
954 }
955 }
956 let mut min_hp = f64::INFINITY;
968 let mut nonfinite_idx: Option<usize> = None;
969 for (i, &hp) in h_prime.iter().enumerate() {
970 if !hp.is_finite() {
971 nonfinite_idx = Some(i);
972 break;
973 }
974 if hp < min_hp {
975 min_hp = hp;
976 }
977 }
978 if let Some(i) = nonfinite_idx {
979 return Err(TransformationNormalError::NonFinite {
980 reason: format!(
981 "TransformationNormalFamily row_quantities: h'[{i}] = {} is not finite",
982 h_prime[i]
983 ),
984 }
985 .into());
986 }
987 if min_hp <= 0.0 {
988 return Err(TransformationNormalError::MonotonicityViolated { reason: format!(
989 "TransformationNormalFamily row_quantities: h' has non-positive values (min = {min_hp:.6e}). \
990 Monotonicity constraint may be violated."
991 ) }.into());
992 }
993 let derived = build_transformation_row_derived(
998 &h,
999 &h_prime,
1000 &h_lower,
1001 &h_upper,
1002 self.effective_weights(),
1003 )?;
1004 let row_quantities = TransformationNormalRowQuantityCache {
1005 beta: Arc::new(beta.clone()),
1006 gamma: Arc::new(gamma),
1007 h: Arc::new(h),
1008 h_prime: Arc::new(h_prime),
1009 h_lower: Arc::new(h_lower),
1010 h_upper: Arc::new(h_upper),
1011 endpoint_q: Arc::new(derived.endpoint_q),
1012 log_likelihood: derived.log_likelihood,
1013 };
1014
1015 let mut cache = self
1016 .row_quantity_cache
1017 .lock()
1018 .expect("CTN row quantity cache mutex poisoned");
1019 *cache = Some(row_quantities.clone());
1020 Ok(row_quantities)
1021 }
1022}