1use crate::bms::{
2 BernoulliMarginalSlopeSavedAloReplay, BernoulliMarginalSlopeSavedAloReplayInput,
3 EmpiricalZGrid, LatentMeasureKind, LatentZConditionalCalibration, LatentZRankIntCalibration,
4 bernoulli_marginal_link_map, empirical_intercept_from_marginal,
5 replay_saved_bernoulli_marginal_slope_alo,
6};
7use crate::inference::model::{SavedCompiledFlexBlock, SavedLatentZNormalization};
8use crate::marginal_slope_shared::{
9 ObservedDenestedCellPartials, eval_coeff4_at,
10 probit_frailty_scale as marginal_slope_probit_frailty_scale, scale_coeff4,
11};
12use crate::survival::lognormal_kernel::{FrailtyScale, FrailtySpec};
13use gam_linalg::matrix::DesignMatrix;
14use gam_math::probability::{normal_cdf, normal_pdf};
15use gam_problem::types::{InverseLink, LikelihoodSpec};
16use gam_runtime::resource::prediction_chunk_rows;
17use gam_solve::estimate::{EstimationError, UnifiedFitResult};
18use ndarray::{Array1, Array2, ArrayView1};
19use rayon::iter::{IntoParallelIterator, ParallelIterator};
20use std::sync::Arc;
21
22pub struct PredictResult {
23 pub eta: Array1<f64>,
24 pub mean: Array1<f64>,
25}
26
27pub struct PredictInput {
30 pub design: DesignMatrix,
32 pub offset: Array1<f64>,
34 pub design_noise: Option<DesignMatrix>,
36 pub offset_noise: Option<Array1<f64>>,
38 pub auxiliary_scalar: Option<Array1<f64>>,
40 pub auxiliary_matrix: Option<Array2<f64>>,
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum LatentConditioningSpan {
64 PrimaryDesign,
66 PrimaryDesignTail { ncols: usize },
69}
70
71pub struct BernoulliMarginalSlopePredictor {
72 pub beta_marginal: Array1<f64>,
73 pub beta_logslope: Array1<f64>,
74 pub beta_score_warp: Option<Array1<f64>>,
75 pub beta_link_dev: Option<Array1<f64>>,
76 pub base_link: InverseLink,
77 pub z_column: String,
78 pub latent_z_normalization: SavedLatentZNormalization,
79 pub latent_measure: LatentMeasureKind,
80 pub baseline_marginal: f64,
81 pub baseline_logslope: f64,
82 pub covariance: Option<Array2<f64>>,
83 pub score_warp_runtime: Option<SavedCompiledFlexBlock>,
84 pub link_deviation_runtime: Option<SavedCompiledFlexBlock>,
85 pub gaussian_frailty_sd: Option<f64>,
86 pub latent_z_calibration: Option<LatentZRankIntCalibration>,
87 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
88 pub latent_conditioning_span: LatentConditioningSpan,
92}
93
94struct BernoulliMarginalSlopeSavedAloAffineState {
97 marginal_eta: Array1<f64>,
98 slope: Array1<f64>,
99 latent_z: Array1<f64>,
100}
101
102#[derive(Default)]
121struct BmsAnchorCorrections {
122 score_warp_anchor_rows: Option<Array2<f64>>,
125 link_dev_anchor_rows: Option<Array2<f64>>,
131 score_warp: Option<Array2<f64>>,
132 link_dev: Option<Array2<f64>>,
133}
134
135impl BmsAnchorCorrections {
136 fn score_warp_row(&self, row: usize) -> Option<ndarray::ArrayView1<'_, f64>> {
137 self.score_warp.as_ref().map(|m| m.row(row))
138 }
139
140 fn link_dev_row(&self, row: usize) -> Option<ndarray::ArrayView1<'_, f64>> {
141 self.link_dev.as_ref().map(|m| m.row(row))
142 }
143
144 fn score_warp_anchor_rows_view(&self) -> Option<ndarray::ArrayView2<'_, f64>> {
145 self.score_warp_anchor_rows.as_ref().map(|m| m.view())
146 }
147
148 fn link_dev_anchor_rows_view(&self) -> Option<ndarray::ArrayView2<'_, f64>> {
149 self.link_dev_anchor_rows.as_ref().map(|m| m.view())
150 }
151}
152
153impl BernoulliMarginalSlopePredictor {
154 fn build_anchor_correction_matrices(
163 &self,
164 input: &PredictInput,
165 design_logslope: &DesignMatrix,
166 z: &Array1<f64>,
167 ) -> Result<BmsAnchorCorrections, EstimationError> {
168 use crate::inference::model::SavedAnchorKind;
169 let needs_score = self
170 .score_warp_runtime
171 .as_ref()
172 .is_some_and(|r| r.anchor_correction.is_some());
173 let needs_link = self
174 .link_deviation_runtime
175 .as_ref()
176 .is_some_and(|r| r.anchor_correction.is_some());
177 if !needs_score && !needs_link {
178 return Ok(BmsAnchorCorrections::default());
179 }
180 let marginal_dense = input
185 .design
186 .try_to_dense_arc(
187 "bernoulli marginal-slope predict-time marginal anchor materialisation",
188 )
189 .map_err(EstimationError::InvalidInput)?;
190 let logslope_dense = design_logslope
191 .try_to_dense_arc(
192 "bernoulli marginal-slope predict-time logslope anchor materialisation",
193 )
194 .map_err(EstimationError::InvalidInput)?;
195 let n_rows = marginal_dense.nrows();
196 if logslope_dense.nrows() != n_rows {
197 return Err(EstimationError::InvalidInput(format!(
198 "bernoulli marginal-slope predict anchor materialisation row mismatch: marginal {} vs logslope {}",
199 n_rows,
200 logslope_dense.nrows()
201 )));
202 }
203 if z.len() != n_rows {
204 return Err(EstimationError::InvalidInput(format!(
205 "bernoulli marginal-slope predict anchor materialisation: z has {} entries, expected {}",
206 z.len(),
207 n_rows
208 )));
209 }
210 let p_marginal = marginal_dense.ncols();
211 let p_logslope = logslope_dense.ncols();
212 let d_parametric = p_marginal + p_logslope;
213 let mut parametric_rows = Array2::<f64>::zeros((n_rows, d_parametric));
214 parametric_rows
215 .slice_mut(ndarray::s![.., 0..p_marginal])
216 .assign(&marginal_dense.view());
217 parametric_rows
218 .slice_mut(ndarray::s![.., p_marginal..d_parametric])
219 .assign(&logslope_dense.view());
220
221 let score_warp = if needs_score {
224 let runtime = self
225 .score_warp_runtime
226 .as_ref()
227 .expect("needs_score is derived from this runtime being present");
228 self.validate_runtime_anchor_layout_parametric_only(runtime, "score_warp")?;
229 runtime
230 .anchor_correction_matrix(parametric_rows.view())
231 .map_err(EstimationError::from)?
232 } else {
233 None
234 };
235
236 let (link_dev_anchor_rows, link_dev) = if needs_link {
241 let runtime = self
242 .link_deviation_runtime
243 .as_ref()
244 .expect("needs_link is derived from this runtime being present");
245 let mut saw_flex_tail = false;
250 let mut flex_tail_ncols: usize = 0;
251 for (idx, component) in runtime.anchor_components.iter().enumerate() {
252 match &component.kind {
253 SavedAnchorKind::Parametric { .. } => {
254 if saw_flex_tail {
255 return Err(EstimationError::InvalidInput(format!(
256 "bernoulli marginal-slope link-deviation saved anchor components \
257 are out of order: parametric component at index {idx} follows \
258 a FlexEvaluation tail",
259 )));
260 }
261 }
262 SavedAnchorKind::FlexEvaluation { ncols } => {
263 if saw_flex_tail {
264 return Err(EstimationError::InvalidInput(
265 "bernoulli marginal-slope link-deviation saved anchor components \
266 carry more than one FlexEvaluation tail; fit-time stacking emits \
267 at most one (score-warp)"
268 .to_string(),
269 ));
270 }
271 saw_flex_tail = true;
272 flex_tail_ncols = *ncols;
273 }
274 }
275 }
276 let rows = if saw_flex_tail {
277 let score_runtime = self.score_warp_runtime.as_ref().ok_or_else(|| {
278 EstimationError::InvalidInput(
279 "bernoulli marginal-slope link-deviation saved anchor includes a \
280 FlexEvaluation tail but the saved score-warp runtime is missing"
281 .to_string(),
282 )
283 })?;
284 let score_basis = if score_runtime.anchor_correction.is_some() {
290 score_runtime
291 .design_with_anchor_rows(z, parametric_rows.view())
292 .map_err(EstimationError::from)?
293 } else {
294 score_runtime.design(z).map_err(EstimationError::from)?
295 };
296 if score_basis.ncols() != flex_tail_ncols {
297 return Err(EstimationError::InvalidInput(format!(
298 "bernoulli marginal-slope link-deviation FlexEvaluation tail expects \
299 {} score-warp basis columns at predict rows, got {}",
300 flex_tail_ncols,
301 score_basis.ncols()
302 )));
303 }
304 let mut combined = Array2::<f64>::zeros((n_rows, d_parametric + flex_tail_ncols));
305 combined
306 .slice_mut(ndarray::s![.., 0..d_parametric])
307 .assign(¶metric_rows.view());
308 combined
309 .slice_mut(ndarray::s![.., d_parametric..])
310 .assign(&score_basis.view());
311 combined
312 } else {
313 parametric_rows.clone()
314 };
315 let corr = runtime
316 .anchor_correction_matrix(rows.view())
317 .map_err(EstimationError::from)?;
318 (Some(rows), corr)
319 } else {
320 (None, None)
321 };
322
323 Ok(BmsAnchorCorrections {
324 score_warp_anchor_rows: Some(parametric_rows),
325 link_dev_anchor_rows,
326 score_warp,
327 link_dev,
328 })
329 }
330
331 fn validate_runtime_anchor_layout_parametric_only(
335 &self,
336 runtime: &SavedCompiledFlexBlock,
337 runtime_label: &str,
338 ) -> Result<(), EstimationError> {
339 use crate::inference::model::SavedAnchorKind;
340 for (idx, component) in runtime.anchor_components.iter().enumerate() {
341 match &component.kind {
342 SavedAnchorKind::Parametric { .. } => {}
343 SavedAnchorKind::FlexEvaluation { .. } => {
344 return Err(EstimationError::InvalidInput(format!(
345 "bernoulli marginal-slope {runtime_label} saved anchor component at \
346 index {idx} is FlexEvaluation; only Parametric components are \
347 expected for this runtime",
348 )));
349 }
350 }
351 }
352 Ok(())
353 }
354
355 pub fn likelihood_family(&self) -> LikelihoodSpec {
356 LikelihoodSpec::binomial_probit()
357 }
358
359 pub fn mean_from_eta(&self, eta: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
360 Ok(eta.mapv(normal_cdf))
361 }
362
363 pub fn mean_derivative_from_eta(
364 &self,
365 eta: &Array1<f64>,
366 ) -> Result<Array1<f64>, EstimationError> {
367 Ok(eta.mapv(normal_pdf))
368 }
369
370 pub(crate) fn probit_frailty_scale(&self) -> f64 {
371 marginal_slope_probit_frailty_scale(self.gaussian_frailty_sd)
372 }
373
374 fn saved_alo_affine_state(
377 &self,
378 input: &PredictInput,
379 ) -> Result<BernoulliMarginalSlopeSavedAloAffineState, EstimationError> {
380 let latent_z_raw = input.auxiliary_scalar.as_ref().ok_or_else(|| {
381 EstimationError::InvalidInput(format!(
382 "saved marginal-slope ALO requires auxiliary z column '{}'",
383 self.z_column
384 ))
385 })?;
386 let secondary_design = input.design_noise.as_ref().ok_or_else(|| {
387 EstimationError::InvalidInput(
388 "saved marginal-slope ALO requires the fitted slope design".to_string(),
389 )
390 })?;
391 let n = input.design.nrows();
392 if latent_z_raw.len() != n
393 || secondary_design.nrows() != n
394 || input.offset.len() != n
395 || input
396 .offset_noise
397 .as_ref()
398 .is_some_and(|offset| offset.len() != n)
399 {
400 return Err(EstimationError::InvalidInput(format!(
401 "saved marginal-slope ALO row mismatch: primary={n}, slope={}, z={}, primary_offset={}, slope_offset={}",
402 secondary_design.nrows(),
403 latent_z_raw.len(),
404 input.offset.len(),
405 input.offset_noise.as_ref().map_or(n, Array1::len),
406 )));
407 }
408 if input.design.ncols() != self.beta_marginal.len()
409 || secondary_design.ncols() != self.beta_logslope.len()
410 {
411 return Err(EstimationError::InvalidInput(format!(
412 "saved marginal-slope ALO coefficient mismatch: marginal design/beta={}/{}, slope design/beta={}/{}",
413 input.design.ncols(),
414 self.beta_marginal.len(),
415 secondary_design.ncols(),
416 self.beta_logslope.len(),
417 )));
418 }
419
420 let normalized = self
421 .latent_z_normalization
422 .apply(latent_z_raw, "saved marginal-slope ALO")
423 .map_err(EstimationError::from)?;
424 let calibrated = self.apply_latent_z_calibration(&normalized);
425 let latent_z = self.apply_latent_z_conditional_calibration(&calibrated, input)?;
426 let marginal_eta = input
427 .design
428 .dot(&self.beta_marginal)
429 .mapv(|value| value + self.baseline_marginal)
430 + &input.offset;
431 let slope_offset = input
432 .offset_noise
433 .as_ref()
434 .map_or_else(|| Array1::zeros(n), Clone::clone);
435 let slope = secondary_design
436 .dot(&self.beta_logslope)
437 .mapv(|value| value + self.baseline_logslope)
438 + &slope_offset;
439 Ok(BernoulliMarginalSlopeSavedAloAffineState {
440 marginal_eta,
441 slope,
442 latent_z,
443 })
444 }
445
446 fn saved_alo_latent_measure(
447 &self,
448 input: &PredictInput,
449 n_rows: usize,
450 ) -> Result<LatentMeasureKind, EstimationError> {
451 match &self.latent_measure {
452 LatentMeasureKind::StandardNormal => Ok(LatentMeasureKind::StandardNormal),
453 LatentMeasureKind::GlobalEmpirical { grid } => {
454 Ok(LatentMeasureKind::GlobalEmpirical { grid: grid.clone() })
455 }
456 LatentMeasureKind::LocalEmpirical {
457 feature_cols,
458 input_scales,
459 centers,
460 grids,
461 top_k,
462 bandwidth,
463 ..
464 } => {
465 let conditioning = input.auxiliary_matrix.as_ref().ok_or_else(|| {
466 EstimationError::InvalidInput(
467 "saved BMS ALO with a local empirical latent measure requires the persisted conditioning matrix"
468 .to_string(),
469 )
470 })?;
471 let expected_dimension = centers.first().map_or(0, Vec::len);
472 if conditioning.dim() != (n_rows, expected_dimension) {
473 return Err(EstimationError::InvalidInput(format!(
474 "saved BMS ALO local empirical conditioning is {}x{}; expected {n_rows}x{expected_dimension}",
475 conditioning.nrows(),
476 conditioning.ncols(),
477 )));
478 }
479 let mixtures = conditioning
480 .rows()
481 .into_iter()
482 .map(|row| {
483 let point = row.iter().copied().collect::<Vec<_>>();
484 Self::local_empirical_mixture_for_point(&point, centers, *top_k, *bandwidth)
485 })
486 .collect::<Result<Vec<_>, _>>()?;
487 Ok(LatentMeasureKind::LocalEmpirical {
488 feature_cols: feature_cols.clone(),
489 input_scales: input_scales.clone(),
490 centers: centers.clone(),
491 grids: grids.clone(),
492 top_k: *top_k,
493 bandwidth: *bandwidth,
494 train_row_mixtures: Arc::new(mixtures),
495 })
496 }
497 }
498 }
499
500 pub fn saved_alo_replay(
504 &self,
505 input: &PredictInput,
506 response: &Array1<f64>,
507 prior_weights: &Array1<f64>,
508 ) -> Result<BernoulliMarginalSlopeSavedAloReplay, EstimationError> {
509 let affine = self.saved_alo_affine_state(input)?;
510 let logslope_design = input.design_noise.as_ref().ok_or_else(|| {
511 EstimationError::InvalidInput(
512 "saved BMS ALO requires the persisted slope design".to_string(),
513 )
514 })?;
515 let anchor_corrections =
516 self.build_anchor_correction_matrices(input, logslope_design, &affine.latent_z)?;
517 let latent_measure = self.saved_alo_latent_measure(input, response.len())?;
518 replay_saved_bernoulli_marginal_slope_alo(BernoulliMarginalSlopeSavedAloReplayInput {
519 base_link: &self.base_link,
520 marginal_design: &input.design,
521 logslope_design,
522 marginal_beta: &self.beta_marginal,
523 logslope_beta: &self.beta_logslope,
524 score_warp_beta: self.beta_score_warp.as_ref(),
525 link_deviation_beta: self.beta_link_dev.as_ref(),
526 marginal_eta: &affine.marginal_eta,
527 slope: &affine.slope,
528 latent_z: &affine.latent_z,
529 response,
530 prior_weights,
531 latent_measure,
532 gaussian_frailty_sd: self.gaussian_frailty_sd,
533 score_warp_runtime: self.score_warp_runtime.as_ref(),
534 link_deviation_runtime: self.link_deviation_runtime.as_ref(),
535 score_warp_anchor_rows: anchor_corrections.score_warp_anchor_rows.as_ref(),
536 link_deviation_anchor_rows: anchor_corrections.link_dev_anchor_rows.as_ref(),
537 })
538 .map_err(EstimationError::InvalidInput)
539 }
540
541 fn apply_latent_z_calibration(&self, z: &Array1<f64>) -> Array1<f64> {
559 match &self.latent_z_calibration {
560 Some(cal) => Array1::from_iter(z.iter().map(|&zi| cal.apply_at_predict(zi))),
561 None => z.clone(),
562 }
563 }
564
565 pub(crate) fn apply_latent_z_conditional_calibration(
576 &self,
577 z: &Array1<f64>,
578 input: &PredictInput,
579 ) -> Result<Array1<f64>, EstimationError> {
580 let Some(cal) = self.latent_z_conditional_calibration.as_ref() else {
581 return Ok(z.clone());
582 };
583 let design = input.design.to_dense();
584 let a_block = match self.latent_conditioning_span {
585 LatentConditioningSpan::PrimaryDesign => design.view(),
586 LatentConditioningSpan::PrimaryDesignTail { ncols } => {
587 let width = design.ncols();
588 if ncols > width {
589 return Err(EstimationError::InvalidInput(format!(
590 "conditional latent calibration names the trailing {ncols} columns of the \
591 primary design as its conditioning span, but that design has only \
592 {width} columns"
593 )));
594 }
595 design.slice(ndarray::s![.., width - ncols..])
596 }
597 };
598 cal.apply(z.view(), a_block)
599 .map_err(EstimationError::InvalidInput)
600 }
601
602 fn rigid_intercept_from_marginal(&self, marginal_eta: f64, slope: f64) -> f64 {
603 let probit_scale = self.probit_frailty_scale();
604 marginal_eta * (1.0 + (probit_scale * slope).powi(2)).sqrt() / probit_scale
605 }
606
607 fn empirical_rigid_intercept_and_gradient(
608 &self,
609 marginal_eta: f64,
610 slope: f64,
611 nodes: &[f64],
612 weights: &[f64],
613 ) -> Result<(f64, f64, f64), EstimationError> {
614 let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
615 .map_err(EstimationError::InvalidInput)?;
616 let scale = self.probit_frailty_scale();
617 let intercept = empirical_intercept_from_marginal(
618 marginal.mu,
619 marginal.q,
620 slope,
621 scale,
622 nodes,
623 weights,
624 None,
625 )
626 .map_err(EstimationError::InvalidInput)?;
627 let observed_slope = scale * slope;
628 let mut f_a = 0.0;
629 let mut f_b = 0.0;
630 for (&node, &weight) in nodes.iter().zip(weights.iter()) {
631 let eta = intercept + observed_slope * node;
632 let pdf = normal_pdf(eta);
633 f_a += weight * pdf;
634 f_b += weight * pdf * scale * node;
635 }
636 if !(f_a.is_finite() && f_a > 0.0 && f_b.is_finite()) {
637 return Err(EstimationError::InvalidInput(format!(
638 "empirical latent prediction calibration derivative is invalid: F_a={f_a}, F_b={f_b}"
639 )));
640 }
641 let a_marginal_eta = marginal.mu1 / f_a;
642 let a_slope = -f_b / f_a;
643 Ok((intercept, a_marginal_eta, a_slope))
644 }
645
646 fn local_empirical_mixture_for_point(
647 point: &[f64],
648 centers: &[Vec<f64>],
649 top_k: usize,
650 bandwidth: f64,
651 ) -> Result<Vec<(usize, f64)>, EstimationError> {
652 if centers.is_empty() {
653 return Err(EstimationError::InvalidInput(
654 "local empirical latent prediction has no centers".to_string(),
655 ));
656 }
657 if top_k == 0 {
658 return Err(EstimationError::InvalidInput(
659 "local empirical latent prediction top_k must be positive".to_string(),
660 ));
661 }
662 if !(bandwidth.is_finite() && bandwidth > 0.0) {
663 return Err(EstimationError::InvalidInput(format!(
664 "local empirical latent prediction bandwidth must be finite and positive, got {bandwidth}"
665 )));
666 }
667 let bw2 = bandwidth * bandwidth;
668 let mut distances = Vec::<(usize, f64)>::with_capacity(centers.len());
669 for (idx, center) in centers.iter().enumerate() {
670 if center.len() != point.len() {
671 return Err(EstimationError::InvalidInput(format!(
672 "local empirical latent prediction center {idx} dimension mismatch: center={}, point={}",
673 center.len(),
674 point.len()
675 )));
676 }
677 let d2 = center
678 .iter()
679 .zip(point.iter())
680 .map(|(&c, &x)| {
681 let delta = x - c;
682 delta * delta
683 })
684 .sum::<f64>();
685 if !d2.is_finite() {
686 return Err(EstimationError::InvalidInput(
687 "local empirical latent prediction distance is non-finite".to_string(),
688 ));
689 }
690 distances.push((idx, d2));
691 }
692 distances.sort_by(|left, right| {
693 left.1
694 .partial_cmp(&right.1)
695 .expect("validated local empirical distances are finite")
696 });
697 let k = top_k.min(distances.len());
698 let mut mixture = Vec::with_capacity(k);
699 let mut total = 0.0;
700 for &(idx, d2) in distances.iter().take(k) {
701 let weight = (-0.5 * d2 / bw2).exp().max(1e-300);
702 mixture.push((idx, weight));
703 total += weight;
704 }
705 if !(total.is_finite() && total > 0.0) {
706 return Err(EstimationError::InvalidInput(
707 "local empirical latent prediction mixture has non-positive total weight"
708 .to_string(),
709 ));
710 }
711 for (_, weight) in &mut mixture {
712 *weight /= total;
713 }
714 Ok(mixture)
715 }
716
717 fn combine_empirical_grids(
718 grids: &[EmpiricalZGrid],
719 mixture: &[(usize, f64)],
720 ) -> Result<EmpiricalZGrid, EstimationError> {
721 let total_len = mixture
722 .iter()
723 .map(|&(idx, _)| grids.get(idx).map_or(0, |grid| grid.nodes.len()))
724 .sum::<usize>();
725 let mut nodes = Vec::with_capacity(total_len);
726 let mut weights = Vec::with_capacity(total_len);
727 let mut total_weight = 0.0;
728 for &(grid_idx, grid_weight) in mixture {
729 if !(grid_weight.is_finite() && grid_weight >= 0.0) {
730 return Err(EstimationError::InvalidInput(format!(
731 "local empirical latent prediction mixture weight must be finite and non-negative, got {grid_weight}"
732 )));
733 }
734 let grid = grids.get(grid_idx).ok_or_else(|| {
735 EstimationError::InvalidInput(format!(
736 "local empirical latent prediction grid index {grid_idx} is out of bounds for {} grids",
737 grids.len()
738 ))
739 })?;
740 if grid.nodes.len() != grid.weights.len() || grid.nodes.is_empty() {
741 return Err(EstimationError::InvalidInput(format!(
742 "local empirical latent prediction grid {grid_idx} is invalid: nodes={}, weights={}",
743 grid.nodes.len(),
744 grid.weights.len()
745 )));
746 }
747 for (node, weight) in grid.pairs() {
748 let combined_weight = grid_weight * weight;
749 if !(node.is_finite() && combined_weight.is_finite() && combined_weight >= 0.0) {
750 return Err(EstimationError::InvalidInput(
751 "local empirical latent prediction grid contains invalid node/weight"
752 .to_string(),
753 ));
754 }
755 nodes.push(node);
756 weights.push(combined_weight);
757 total_weight += combined_weight;
758 }
759 }
760 if !(total_weight.is_finite() && total_weight > 0.0) {
761 return Err(EstimationError::InvalidInput(
762 "local empirical latent prediction combined grid has non-positive total weight"
763 .to_string(),
764 ));
765 }
766 for weight in &mut weights {
767 *weight /= total_weight;
768 }
769 Ok(EmpiricalZGrid { nodes, weights })
770 }
771
772 fn empirical_grid_for_prediction_row(
773 &self,
774 input: &PredictInput,
775 row: usize,
776 ) -> Result<Option<EmpiricalZGrid>, EstimationError> {
777 match &self.latent_measure {
778 LatentMeasureKind::StandardNormal => Ok(None),
779 LatentMeasureKind::GlobalEmpirical { grid } => Ok(Some(grid.clone())),
780 LatentMeasureKind::LocalEmpirical {
781 centers,
782 grids,
783 top_k,
784 bandwidth,
785 ..
786 } => {
787 let conditioning = input.auxiliary_matrix.as_ref().ok_or_else(|| {
788 EstimationError::InvalidInput(
789 "bernoulli marginal-slope local empirical prediction requires auxiliary conditioning matrix"
790 .to_string(),
791 )
792 })?;
793 if row >= conditioning.nrows() {
794 return Err(EstimationError::InvalidInput(format!(
795 "local empirical latent prediction row {row} is out of bounds for {} conditioning rows",
796 conditioning.nrows()
797 )));
798 }
799 let expected_dim = centers.first().map_or(0, Vec::len);
800 if conditioning.ncols() != expected_dim {
801 return Err(EstimationError::InvalidInput(format!(
802 "local empirical latent prediction conditioning dimension mismatch: got {}, expected {expected_dim}",
803 conditioning.ncols()
804 )));
805 }
806 let point = conditioning.row(row).to_vec();
807 let mixture =
808 Self::local_empirical_mixture_for_point(&point, centers, *top_k, *bandwidth)?;
809 Self::combine_empirical_grids(grids, &mixture).map(Some)
810 }
811 }
812 }
813
814 fn transform_internal_eta_to_base_scale(
815 &self,
816 internal_eta: Array1<f64>,
817 internal_grad: Option<Array2<f64>>,
818 ) -> Result<(Array1<f64>, Option<Array2<f64>>), EstimationError> {
819 Ok((internal_eta, internal_grad))
820 }
821
822 fn link_terms_value_d1(
823 &self,
824 eta0: &Array1<f64>,
825 beta_link_dev: Option<&Array1<f64>>,
826 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
827 ) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
828 if let (Some(runtime), Some(beta)) = (&self.link_deviation_runtime, beta_link_dev) {
829 let basis = runtime
837 .design_uncorrected(eta0)
838 .map_err(EstimationError::from)?;
839 let mut value = &basis.dot(beta) + eta0;
840 if let Some(corr) = link_dev_correction_for_row {
841 let offset = corr.dot(beta);
842 for v in value.iter_mut() {
843 *v -= offset;
844 }
845 } else if runtime.anchor_correction.is_some() {
846 return Err(EstimationError::InvalidInput(
847 "bernoulli marginal-slope link-deviation runtime has an anchor residual but \
848 no per-row correction was supplied to link_terms_value_d1"
849 .to_string(),
850 ));
851 }
852 let d1 = runtime
853 .first_derivative_design(eta0)
854 .map_err(EstimationError::from)?;
855 Ok((value, d1.dot(beta) + 1.0))
856 } else {
857 Ok((eta0.clone(), Array1::ones(eta0.len())))
858 }
859 }
860
861 fn denested_partition_cells(
862 &self,
863 a: f64,
864 b: f64,
865 beta_score_warp: Option<&Array1<f64>>,
866 beta_link_dev: Option<&Array1<f64>>,
867 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
868 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
869 ) -> Result<Vec<crate::cubic_cell_kernel::DenestedPartitionCell>, EstimationError> {
870 let score_breaks = if let Some(runtime) = self.score_warp_runtime.as_ref() {
871 runtime.breakpoints().map_err(EstimationError::from)?
872 } else {
873 Vec::new()
874 };
875 let link_breaks = if let Some(runtime) = self.link_deviation_runtime.as_ref() {
876 runtime.breakpoints().map_err(EstimationError::from)?
877 } else {
878 Vec::new()
879 };
880 let mut cells = crate::cubic_cell_kernel::build_denested_partition_cells_with_tails(
881 a,
882 b,
883 &score_breaks,
884 &link_breaks,
885 |z| {
886 if let (Some(runtime), Some(beta)) =
887 (self.score_warp_runtime.as_ref(), beta_score_warp)
888 {
889 let mut span = runtime.local_cubic_at(beta.view(), z)?;
890 if let Some(corr) = score_warp_correction_for_row {
897 span.c0 -= corr.dot(beta);
898 }
899 Ok(span)
900 } else {
901 Ok(crate::cubic_cell_kernel::LocalSpanCubic {
902 left: 0.0,
903 right: 1.0,
904 c0: 0.0,
905 c1: 0.0,
906 c2: 0.0,
907 c3: 0.0,
908 })
909 }
910 },
911 |u| {
912 if let (Some(runtime), Some(beta)) =
913 (self.link_deviation_runtime.as_ref(), beta_link_dev)
914 {
915 let mut span = runtime.local_cubic_at(beta.view(), u)?;
916 if let Some(corr) = link_dev_correction_for_row {
917 span.c0 -= corr.dot(beta);
918 }
919 Ok(span)
920 } else {
921 Ok(crate::cubic_cell_kernel::LocalSpanCubic {
922 left: 0.0,
923 right: 1.0,
924 c0: 0.0,
925 c1: 0.0,
926 c2: 0.0,
927 c3: 0.0,
928 })
929 }
930 },
931 )
932 .map_err(EstimationError::InvalidInput)?;
933 let scale = self.probit_frailty_scale();
934 if scale != 1.0 {
935 for partition_cell in &mut cells {
936 partition_cell.cell.c0 *= scale;
937 partition_cell.cell.c1 *= scale;
938 partition_cell.cell.c2 *= scale;
939 partition_cell.cell.c3 *= scale;
940 }
941 }
942 Ok(cells)
943 }
944
945 fn evaluate_denested_calibration(
946 &self,
947 a: f64,
948 marginal_eta: f64,
949 slope: f64,
950 beta_score_warp: Option<&Array1<f64>>,
951 beta_link_dev: Option<&Array1<f64>>,
952 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
953 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
954 ) -> Result<(f64, f64, f64), EstimationError> {
955 let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
956 .map_err(EstimationError::InvalidInput)?;
957 let cells = self.denested_partition_cells(
958 a,
959 slope,
960 beta_score_warp,
961 beta_link_dev,
962 score_warp_correction_for_row,
963 link_dev_correction_for_row,
964 )?;
965 let scale = self.probit_frailty_scale();
966 let mut f = -marginal.mu;
967 let mut f_a = 0.0;
968 let mut f_aa = 0.0;
969 for partition_cell in cells {
970 let cell = partition_cell.cell;
971 let (dc_da_raw, _) = crate::cubic_cell_kernel::denested_cell_coefficient_partials(
972 partition_cell.score_span,
973 partition_cell.link_span,
974 a,
975 slope,
976 );
977 let (d2c_da2_raw, _, _) = crate::cubic_cell_kernel::denested_cell_second_partials(
978 partition_cell.score_span,
979 partition_cell.link_span,
980 a,
981 slope,
982 );
983 let dc_da = scale_coeff4(dc_da_raw, scale);
984 let d2c_da2 = scale_coeff4(d2c_da2_raw, scale);
985 let max_degree = crate::cubic_cell_kernel::cell_second_derivative_required_max_degree(
991 &dc_da, &dc_da, &d2c_da2,
992 );
993 let state = crate::cubic_cell_kernel::evaluate_cell_moments(cell, max_degree)
994 .map_err(EstimationError::InvalidInput)?;
995 f += state.value;
996 f_a += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
997 &dc_da,
998 &state.moments,
999 )
1000 .map_err(EstimationError::InvalidInput)?;
1001 f_aa += crate::cubic_cell_kernel::cell_second_derivative_from_moments(
1002 cell,
1003 &dc_da,
1004 &dc_da,
1005 &d2c_da2,
1006 &state.moments,
1007 )
1008 .map_err(EstimationError::InvalidInput)?;
1009 }
1010 Ok((f, f_a, f_aa))
1011 }
1012
1013 fn observed_denested_cell_partials_at_z(
1014 &self,
1015 z_value: f64,
1016 a: f64,
1017 b: f64,
1018 beta_score_warp: Option<&Array1<f64>>,
1019 beta_link_dev: Option<&Array1<f64>>,
1020 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1021 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1022 ) -> Result<ObservedDenestedCellPartials, EstimationError> {
1023 use crate::cubic_cell_kernel as exact;
1024
1025 let zero_span = exact::LocalSpanCubic {
1026 left: 0.0,
1027 right: 1.0,
1028 c0: 0.0,
1029 c1: 0.0,
1030 c2: 0.0,
1031 c3: 0.0,
1032 };
1033 let u_value = a + b * z_value;
1034 let score_span = if let (Some(runtime), Some(beta)) =
1035 (self.score_warp_runtime.as_ref(), beta_score_warp)
1036 {
1037 let mut span = runtime
1038 .local_cubic_at(beta.view(), z_value)
1039 .map_err(EstimationError::from)?;
1040 if let Some(corr) = score_warp_correction_for_row {
1041 span.c0 -= corr.dot(beta);
1042 }
1043 span
1044 } else {
1045 zero_span
1046 };
1047 let link_span = if let (Some(runtime), Some(beta)) =
1048 (self.link_deviation_runtime.as_ref(), beta_link_dev)
1049 {
1050 let mut span = runtime
1051 .local_cubic_at(beta.view(), u_value)
1052 .map_err(EstimationError::from)?;
1053 if let Some(corr) = link_dev_correction_for_row {
1054 span.c0 -= corr.dot(beta);
1055 }
1056 span
1057 } else {
1058 zero_span
1059 };
1060 let scale = self.probit_frailty_scale();
1061 let coeff = scale_coeff4(
1062 exact::denested_cell_coefficients(score_span, link_span, a, b),
1063 scale,
1064 );
1065 let (dc_da_raw, dc_db_raw) =
1066 exact::denested_cell_coefficient_partials(score_span, link_span, a, b);
1067 let (dc_daa_raw, dc_dab_raw, dc_dbb_raw) =
1068 exact::denested_cell_second_partials(score_span, link_span, a, b);
1069 let (dc_daaa, dc_daab, dc_dabb, dc_dbbb) = exact::denested_cell_third_partials(link_span);
1070 Ok(ObservedDenestedCellPartials {
1071 coeff,
1072 dc_da: scale_coeff4(dc_da_raw, scale),
1073 dc_db: scale_coeff4(dc_db_raw, scale),
1074 dc_daa: scale_coeff4(dc_daa_raw, scale),
1075 dc_dab: scale_coeff4(dc_dab_raw, scale),
1076 dc_dbb: scale_coeff4(dc_dbb_raw, scale),
1077 dc_daaa: scale_coeff4(dc_daaa, scale),
1078 dc_daab: scale_coeff4(dc_daab, scale),
1079 dc_dabb: scale_coeff4(dc_dabb, scale),
1080 dc_dbbb: scale_coeff4(dc_dbbb, scale),
1081 })
1082 }
1083
1084 fn evaluate_empirical_denested_calibration(
1085 &self,
1086 a: f64,
1087 marginal_eta: f64,
1088 slope: f64,
1089 beta_score_warp: Option<&Array1<f64>>,
1090 beta_link_dev: Option<&Array1<f64>>,
1091 grid: &EmpiricalZGrid,
1092 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1093 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1094 ) -> Result<(f64, f64, f64), EstimationError> {
1095 let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
1096 .map_err(EstimationError::InvalidInput)?;
1097 let mut f = -marginal.mu;
1098 let mut f_a = 0.0;
1099 let mut f_aa = 0.0;
1100 for (node, weight) in grid.pairs() {
1101 let obs = self.observed_denested_cell_partials_at_z(
1102 node,
1103 a,
1104 slope,
1105 beta_score_warp,
1106 beta_link_dev,
1107 score_warp_correction_for_row,
1108 link_dev_correction_for_row,
1109 )?;
1110 let eta = eval_coeff4_at(&obs.coeff, node);
1111 let eta_a = eval_coeff4_at(&obs.dc_da, node);
1112 let eta_aa = eval_coeff4_at(&obs.dc_daa, node);
1113 let pdf = normal_pdf(eta);
1114 f += weight * normal_cdf(eta);
1115 f_a += weight * pdf * eta_a;
1116 f_aa += weight * pdf * (eta_aa - eta * eta_a * eta_a);
1117 }
1118 Ok((f, f_a, f_aa))
1119 }
1120
1121 fn evaluate_prediction_calibration(
1122 &self,
1123 a: f64,
1124 marginal_eta: f64,
1125 slope: f64,
1126 beta_score_warp: Option<&Array1<f64>>,
1127 beta_link_dev: Option<&Array1<f64>>,
1128 empirical_grid: Option<&EmpiricalZGrid>,
1129 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1130 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1131 ) -> Result<(f64, f64, f64), EstimationError> {
1132 if let Some(grid) = empirical_grid {
1133 self.evaluate_empirical_denested_calibration(
1134 a,
1135 marginal_eta,
1136 slope,
1137 beta_score_warp,
1138 beta_link_dev,
1139 grid,
1140 score_warp_correction_for_row,
1141 link_dev_correction_for_row,
1142 )
1143 } else {
1144 self.evaluate_denested_calibration(
1145 a,
1146 marginal_eta,
1147 slope,
1148 beta_score_warp,
1149 beta_link_dev,
1150 score_warp_correction_for_row,
1151 link_dev_correction_for_row,
1152 )
1153 }
1154 }
1155
1156 pub fn from_unified(
1157 unified: &UnifiedFitResult,
1158 z_column: String,
1159 latent_z_normalization: SavedLatentZNormalization,
1160 latent_measure: LatentMeasureKind,
1161 baseline_marginal: f64,
1162 baseline_logslope: f64,
1163 base_link: InverseLink,
1164 frailty: FrailtySpec,
1165 score_warp_runtime: Option<SavedCompiledFlexBlock>,
1166 link_deviation_runtime: Option<SavedCompiledFlexBlock>,
1167 latent_z_calibration: Option<crate::bms::LatentZRankIntCalibration>,
1168 latent_z_conditional_calibration: Option<crate::bms::LatentZConditionalCalibration>,
1169 latent_conditioning_span: LatentConditioningSpan,
1170 ) -> Result<Self, String> {
1171 let gaussian_frailty_sd = match frailty {
1172 FrailtySpec::None => None,
1173 FrailtySpec::GaussianShift {
1174 scale: FrailtyScale::Fixed { sigma },
1175 } => Some(sigma),
1176 FrailtySpec::GaussianShift {
1177 scale: FrailtyScale::Learned { .. },
1178 } => {
1179 return Err(
1180 "bernoulli marginal-slope predictor requires a fixed GaussianShift sigma"
1181 .to_string(),
1182 );
1183 }
1184 FrailtySpec::HazardMultiplier { .. } => {
1185 return Err(
1186 "bernoulli marginal-slope predictor does not support HazardMultiplier frailty"
1187 .to_string(),
1188 );
1189 }
1190 };
1191 if !matches!(
1192 base_link,
1193 InverseLink::Standard(gam_problem::types::StandardLink::Probit)
1194 ) {
1195 return Err(
1196 "bernoulli marginal-slope predictor requires a saved probit link".to_string(),
1197 );
1198 }
1199 if let Some(runtime) = score_warp_runtime.as_ref() {
1200 runtime.validate_exact_replay_contract().map_err(|e| {
1201 format!("bernoulli marginal-slope score-warp runtime is invalid: {e}")
1202 })?;
1203 }
1204 if let Some(runtime) = link_deviation_runtime.as_ref() {
1205 runtime.validate_exact_replay_contract().map_err(|e| {
1206 format!("bernoulli marginal-slope link-deviation runtime is invalid: {e}")
1207 })?;
1208 }
1209 latent_z_normalization
1213 .validate("bernoulli marginal-slope predictor")
1214 .map_err(|e| {
1215 format!("bernoulli marginal-slope predictor latent z normalization is invalid: {e}")
1216 })?;
1217 latent_measure
1218 .validate("bernoulli marginal-slope predictor latent measure")
1219 .map_err(|e| {
1220 format!("bernoulli marginal-slope predictor latent measure is invalid: {e}")
1221 })?;
1222 let blocks = &unified.blocks;
1223 let expected_blocks = 2
1224 + usize::from(score_warp_runtime.is_some())
1225 + usize::from(link_deviation_runtime.is_some());
1226 if blocks.len() != expected_blocks {
1227 return Err(format!(
1228 "bernoulli marginal-slope predictor requires exactly {expected_blocks} coefficient blocks under the current exact de-nested semantics, got {}",
1229 blocks.len()
1230 ));
1231 }
1232 let mut cursor = 2usize;
1233 let beta_score_warp = if score_warp_runtime.is_some() {
1234 let beta = blocks
1235 .get(cursor)
1236 .ok_or_else(|| "missing score-warp coefficient block".to_string())?
1237 .beta
1238 .clone();
1239 cursor += 1;
1240 Some(beta)
1241 } else {
1242 None
1243 };
1244 let beta_link_dev = if link_deviation_runtime.is_some() {
1245 Some(
1246 blocks
1247 .get(cursor)
1248 .ok_or_else(|| "missing link-deviation coefficient block".to_string())?
1249 .beta
1250 .clone(),
1251 )
1252 } else {
1253 None
1254 };
1255 Ok(Self {
1256 beta_marginal: blocks[0].beta.clone(),
1257 beta_logslope: blocks[1].beta.clone(),
1258 beta_score_warp,
1259 beta_link_dev,
1260 base_link,
1261 z_column,
1262 latent_z_normalization,
1263 latent_measure,
1264 baseline_marginal,
1265 baseline_logslope,
1266 covariance: unified.beta_covariance().cloned(),
1267 score_warp_runtime,
1268 link_deviation_runtime,
1269 gaussian_frailty_sd,
1270 latent_z_calibration,
1271 latent_z_conditional_calibration,
1272 latent_conditioning_span,
1273 })
1274 }
1275
1276 pub fn theta(&self) -> Array1<f64> {
1277 let total = self.beta_marginal.len()
1278 + self.beta_logslope.len()
1279 + self.beta_score_warp.as_ref().map_or(0, |b| b.len())
1280 + self.beta_link_dev.as_ref().map_or(0, |b| b.len());
1281 let mut theta = Array1::<f64>::zeros(total);
1282 let mut cursor = 0usize;
1283 theta
1284 .slice_mut(ndarray::s![cursor..cursor + self.beta_marginal.len()])
1285 .assign(&self.beta_marginal);
1286 cursor += self.beta_marginal.len();
1287 theta
1288 .slice_mut(ndarray::s![cursor..cursor + self.beta_logslope.len()])
1289 .assign(&self.beta_logslope);
1290 cursor += self.beta_logslope.len();
1291 if let Some(beta) = self.beta_score_warp.as_ref() {
1292 theta
1293 .slice_mut(ndarray::s![cursor..cursor + beta.len()])
1294 .assign(beta);
1295 cursor += beta.len();
1296 }
1297 if let Some(beta) = self.beta_link_dev.as_ref() {
1298 theta
1299 .slice_mut(ndarray::s![cursor..cursor + beta.len()])
1300 .assign(beta);
1301 }
1302 theta
1303 }
1304
1305 fn split_theta<'a>(
1306 &'a self,
1307 theta: &'a Array1<f64>,
1308 ) -> Result<
1309 (
1310 ArrayView1<'a, f64>,
1311 ArrayView1<'a, f64>,
1312 Option<ArrayView1<'a, f64>>,
1313 Option<ArrayView1<'a, f64>>,
1314 ),
1315 EstimationError,
1316 > {
1317 let expected = self.theta().len();
1318 if theta.len() != expected {
1319 return Err(EstimationError::InvalidInput(format!(
1320 "bernoulli marginal-slope theta length mismatch: expected {expected}, got {}",
1321 theta.len()
1322 )));
1323 }
1324 let mut cursor = 0usize;
1325 let marginal = theta.slice(ndarray::s![cursor..cursor + self.beta_marginal.len()]);
1326 cursor += self.beta_marginal.len();
1327 let logslope = theta.slice(ndarray::s![cursor..cursor + self.beta_logslope.len()]);
1328 cursor += self.beta_logslope.len();
1329 let score_warp = self.beta_score_warp.as_ref().map(|beta| {
1330 let view = theta.slice(ndarray::s![cursor..cursor + beta.len()]);
1331 cursor += beta.len();
1332 view
1333 });
1334 let link_dev = self
1335 .beta_link_dev
1336 .as_ref()
1337 .map(|beta| theta.slice(ndarray::s![cursor..cursor + beta.len()]));
1338 Ok((marginal, logslope, score_warp, link_dev))
1339 }
1340
1341 fn solve_intercept_scalar(
1345 &self,
1346 marginal_eta: f64,
1347 slope: f64,
1348 link_dev_beta: Option<&Array1<f64>>,
1349 score_warp_beta: Option<&Array1<f64>>,
1350 empirical_grid: Option<&EmpiricalZGrid>,
1351 warm_start_buf: &mut Array1<f64>,
1352 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1353 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1354 ) -> Result<f64, EstimationError> {
1355 let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
1356 .map_err(EstimationError::InvalidInput)?;
1357 let eval = |a: f64| -> Result<(f64, f64, f64), String> {
1358 self.evaluate_prediction_calibration(
1359 a,
1360 marginal_eta,
1361 slope,
1362 score_warp_beta,
1363 link_dev_beta,
1364 empirical_grid,
1365 score_warp_correction_for_row,
1366 link_dev_correction_for_row,
1367 )
1368 .map_err(|err| err.to_string())
1369 };
1370
1371 let probit_scale = self.probit_frailty_scale();
1372 let a_rigid = self.rigid_intercept_from_marginal(marginal.q, slope);
1373 let mut intercept = a_rigid;
1374 if let (Some(_), Some(beta)) = (self.link_deviation_runtime.as_ref(), link_dev_beta) {
1375 warm_start_buf[0] = a_rigid;
1376 let one_pt = warm_start_buf.slice(ndarray::s![0..1]).to_owned();
1377 let (l_val, l_d1) =
1378 self.link_terms_value_d1(&one_pt, Some(beta), link_dev_correction_for_row)?;
1379 let ell1 = l_d1[0];
1380 if ell1 > 1e-8 {
1381 let ell0 = l_val[0] - ell1 * a_rigid;
1382 let observed_logslope = probit_scale * ell1 * slope;
1383 intercept = (marginal.q * (1.0 + observed_logslope * observed_logslope).sqrt()
1384 / probit_scale
1385 - ell0)
1386 / ell1;
1387 }
1388 }
1389
1390 let target = marginal.mu;
1393 let abs_tol = 1e-8_f64.max(1e-4 * target.abs());
1394
1395 let (root, _, f_best) = crate::monotone_root::solve_monotone_root(
1396 eval,
1397 intercept,
1398 "saved bernoulli intercept",
1399 abs_tol,
1400 64,
1401 48,
1402 )?;
1403
1404 if f_best.abs() > abs_tol {
1405 return Err(EstimationError::InvalidInput(format!(
1406 "saved bernoulli marginal-slope intercept solve failed: residual={f_best:.3e} at a={root:.6}, target mu={target:.6}"
1407 )));
1408 }
1409 Ok(root)
1410 }
1411
1412 pub fn final_eta_and_gradient_from_theta(
1413 &self,
1414 input: &PredictInput,
1415 theta: &Array1<f64>,
1416 need_gradient: bool,
1417 ) -> Result<(Array1<f64>, Option<Array2<f64>>), EstimationError> {
1418 let z_raw = input.auxiliary_scalar.as_ref().ok_or_else(|| {
1419 EstimationError::InvalidInput(format!(
1420 "bernoulli marginal-slope prediction requires auxiliary z column '{}'",
1421 self.z_column
1422 ))
1423 })?;
1424 let z_normalized = self
1425 .latent_z_normalization
1426 .apply(z_raw, "bernoulli marginal-slope prediction")
1427 .map_err(EstimationError::from)?;
1428 let z = self.apply_latent_z_calibration(&z_normalized);
1438 let z = self.apply_latent_z_conditional_calibration(&z, input)?;
1442 let design_logslope = input.design_noise.as_ref().ok_or_else(|| {
1443 EstimationError::InvalidInput(
1444 "bernoulli marginal-slope prediction requires logslope design".to_string(),
1445 )
1446 })?;
1447 let (beta_marginal, beta_logslope, beta_score_warp, beta_link_dev) =
1448 self.split_theta(theta)?;
1449 if self.score_warp_runtime.is_some() != beta_score_warp.is_some() {
1450 return Err(EstimationError::InvalidInput(
1451 "bernoulli marginal-slope saved score-warp runtime/coefficients are inconsistent"
1452 .to_string(),
1453 ));
1454 }
1455 if self.link_deviation_runtime.is_some() != beta_link_dev.is_some() {
1456 return Err(EstimationError::InvalidInput(
1457 "bernoulli marginal-slope saved link-deviation runtime/coefficients are inconsistent"
1458 .to_string(),
1459 ));
1460 }
1461 let n = z.len();
1462 if input.offset.len() != n {
1463 return Err(EstimationError::InvalidInput(format!(
1464 "bernoulli marginal-slope prediction primary offset length mismatch: rows={n}, offset={}",
1465 input.offset.len()
1466 )));
1467 }
1468 let logslope_offset = input
1469 .offset_noise
1470 .as_ref()
1471 .map_or_else(|| Array1::zeros(n), Clone::clone);
1472 if logslope_offset.len() != n {
1473 return Err(EstimationError::InvalidInput(format!(
1474 "bernoulli marginal-slope prediction logslope offset length mismatch: rows={n}, offset_noise={}",
1475 logslope_offset.len()
1476 )));
1477 }
1478 let marginal_eta = input
1479 .design
1480 .dot(&beta_marginal.to_owned())
1481 .mapv(|v| v + self.baseline_marginal)
1482 + &input.offset;
1483 let logslope_eta = design_logslope
1484 .dot(&beta_logslope.to_owned())
1485 .mapv(|v| v + self.baseline_logslope)
1486 + &logslope_offset;
1487 let flex_active =
1488 self.score_warp_runtime.is_some() || self.link_deviation_runtime.is_some();
1489 let marginal_dim = self.beta_marginal.len();
1490 let logslope_dim = self.beta_logslope.len();
1491 let score_warp_dim = self.beta_score_warp.as_ref().map_or(0, Array1::len);
1492 let link_dev_dim = self.beta_link_dev.as_ref().map_or(0, Array1::len);
1493 let logslope_offset = marginal_dim;
1494 let score_warp_offset = logslope_offset + logslope_dim;
1495 let link_dev_offset = score_warp_offset + score_warp_dim;
1496 let chunk_size = prediction_chunk_rows(theta.len(), 1, n);
1497 let num_chunks = n.div_ceil(chunk_size);
1498 let scale = self.probit_frailty_scale();
1499 let anchor_corrections =
1506 self.build_anchor_correction_matrices(input, design_logslope, &z)?;
1507 let marginal_map = marginal_eta
1508 .iter()
1509 .map(|&eta| {
1510 bernoulli_marginal_link_map(&self.base_link, eta)
1511 .map_err(EstimationError::InvalidInput)
1512 })
1513 .collect::<Result<Vec<_>, _>>()?;
1514
1515 if !flex_active {
1516 let (final_eta_internal, marginal_scales, logslope_scales) = match &self.latent_measure
1517 {
1518 LatentMeasureKind::StandardNormal => {
1519 let sb_vec = logslope_eta.mapv(|b| scale * b);
1520 let c_vec = sb_vec.mapv(|sb| (1.0 + sb * sb).sqrt());
1521 let final_eta_internal = Array1::from_iter(
1522 (0..n).map(|i| c_vec[i] * marginal_eta[i] + sb_vec[i] * z[i]),
1523 );
1524 let marginal_scales = c_vec;
1525 let logslope_scales = Array1::from_iter((0..n).map(|i| {
1526 marginal_eta[i] * (scale * scale) * logslope_eta[i] / marginal_scales[i]
1527 + scale * z[i]
1528 }));
1529 (final_eta_internal, marginal_scales, logslope_scales)
1530 }
1531 LatentMeasureKind::GlobalEmpirical { grid } => {
1532 let mut final_eta = Array1::<f64>::zeros(n);
1533 let mut marginal_scales = Array1::<f64>::zeros(n);
1534 let mut logslope_scales = Array1::<f64>::zeros(n);
1535 for i in 0..n {
1536 let (intercept, a_marginal, a_slope) = self
1537 .empirical_rigid_intercept_and_gradient(
1538 marginal_eta[i],
1539 logslope_eta[i],
1540 &grid.nodes,
1541 &grid.weights,
1542 )?;
1543 final_eta[i] = intercept + scale * logslope_eta[i] * z[i];
1544 marginal_scales[i] = a_marginal;
1545 logslope_scales[i] = a_slope + scale * z[i];
1546 }
1547 (final_eta, marginal_scales, logslope_scales)
1548 }
1549 LatentMeasureKind::LocalEmpirical { .. } => {
1550 let mut final_eta = Array1::<f64>::zeros(n);
1551 let mut marginal_scales = Array1::<f64>::zeros(n);
1552 let mut logslope_scales = Array1::<f64>::zeros(n);
1553 for i in 0..n {
1554 let grid = self
1555 .empirical_grid_for_prediction_row(input, i)?
1556 .ok_or_else(|| {
1557 EstimationError::InvalidInput(
1558 "local empirical latent prediction did not produce a row grid"
1559 .to_string(),
1560 )
1561 })?;
1562 let (intercept, a_marginal, a_slope) = self
1563 .empirical_rigid_intercept_and_gradient(
1564 marginal_eta[i],
1565 logslope_eta[i],
1566 &grid.nodes,
1567 &grid.weights,
1568 )?;
1569 final_eta[i] = intercept + scale * logslope_eta[i] * z[i];
1570 marginal_scales[i] = a_marginal;
1571 logslope_scales[i] = a_slope + scale * z[i];
1572 }
1573 (final_eta, marginal_scales, logslope_scales)
1574 }
1575 };
1576
1577 if !need_gradient {
1578 return self.transform_internal_eta_to_base_scale(final_eta_internal, None);
1579 }
1580
1581 let mut grad_internal = Array2::<f64>::zeros((n, theta.len()));
1583 let mut start = 0usize;
1584 while start < n {
1585 let end = (start + chunk_size).min(n);
1586 let mc = input
1587 .design
1588 .try_row_chunk(start..end)
1589 .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
1590 let lc = design_logslope
1591 .try_row_chunk(start..end)
1592 .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
1593
1594 for li in 0..(end - start) {
1595 let i = start + li;
1596 let c = marginal_scales[i];
1597 let g_scale = logslope_scales[i];
1598 let mut row = grad_internal.row_mut(i);
1599 for j in 0..marginal_dim {
1600 row[j] = c * mc[[li, j]];
1601 }
1602 for j in 0..logslope_dim {
1603 row[logslope_offset + j] = g_scale * lc[[li, j]];
1604 }
1605 }
1606
1607 start = end;
1608 }
1609 return self
1610 .transform_internal_eta_to_base_scale(final_eta_internal, Some(grad_internal));
1611 }
1612
1613 let score_warp_obs_design = self
1615 .score_warp_runtime
1616 .as_ref()
1617 .map(|runtime| {
1618 if runtime.anchor_correction.is_some() {
1619 let anchor_rows = anchor_corrections
1620 .score_warp_anchor_rows_view()
1621 .ok_or_else(|| {
1622 EstimationError::InvalidInput(
1623 "bernoulli marginal-slope score-warp anchor residual present but \
1624 anchor_corrections bundle is missing the parametric anchor rows"
1625 .to_string(),
1626 )
1627 })?;
1628 runtime
1629 .design_with_anchor_rows(&z, anchor_rows)
1630 .map_err(EstimationError::from)
1631 } else {
1632 runtime.design(&z).map_err(EstimationError::from)
1633 }
1634 })
1635 .transpose()?;
1636 let score_dev_obs =
1637 if let (Some(design), Some(beta)) = (score_warp_obs_design.as_ref(), beta_score_warp) {
1638 design.dot(&beta.to_owned())
1639 } else {
1640 Array1::zeros(n)
1641 };
1642
1643 let score_warp_beta_owned = beta_score_warp.as_ref().map(|v| v.to_owned());
1648 let link_dev_beta_owned = beta_link_dev.as_ref().map(|v| v.to_owned());
1649 let mut intercepts = Array1::<f64>::zeros(n);
1650 let mut a_q_vec = need_gradient.then(|| Array1::<f64>::zeros(n));
1651 let mut a_b_vec = need_gradient.then(|| Array1::<f64>::zeros(n));
1652 let mut a_h_rows = if need_gradient && score_warp_dim > 0 {
1653 Some(Array2::<f64>::zeros((n, score_warp_dim)))
1654 } else {
1655 None
1656 };
1657 let mut a_w_rows = if need_gradient && link_dev_dim > 0 {
1658 Some(Array2::<f64>::zeros((n, link_dev_dim)))
1659 } else {
1660 None
1661 };
1662 let solve_result: Result<(), EstimationError> = {
1663 use ndarray::Axis;
1664 use rayon::iter::IndexedParallelIterator;
1665 let intercepts_chunks: Vec<ndarray::ArrayViewMut1<f64>> = intercepts
1666 .axis_chunks_iter_mut(Axis(0), chunk_size)
1667 .collect();
1668 let a_q_chunks: Option<Vec<ndarray::ArrayViewMut1<f64>>> = a_q_vec
1669 .as_mut()
1670 .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1671 let a_b_chunks: Option<Vec<ndarray::ArrayViewMut1<f64>>> = a_b_vec
1672 .as_mut()
1673 .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1674 let a_h_chunks: Option<Vec<ndarray::ArrayViewMut2<f64>>> = a_h_rows
1675 .as_mut()
1676 .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1677 let a_w_chunks: Option<Vec<ndarray::ArrayViewMut2<f64>>> = a_w_rows
1678 .as_mut()
1679 .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1680
1681 struct FlexSolveSink<'a> {
1684 intercepts: ndarray::ArrayViewMut1<'a, f64>,
1685 a_q: Option<ndarray::ArrayViewMut1<'a, f64>>,
1686 a_b: Option<ndarray::ArrayViewMut1<'a, f64>>,
1687 a_h: Option<ndarray::ArrayViewMut2<'a, f64>>,
1688 a_w: Option<ndarray::ArrayViewMut2<'a, f64>>,
1689 }
1690 let mut sinks: Vec<FlexSolveSink<'_>> = Vec::with_capacity(num_chunks);
1691 let mut intercepts_iter = intercepts_chunks.into_iter();
1693 let mut a_q_iter = a_q_chunks.map(|v| v.into_iter());
1694 let mut a_b_iter = a_b_chunks.map(|v| v.into_iter());
1695 let mut a_h_iter = a_h_chunks.map(|v| v.into_iter());
1696 let mut a_w_iter = a_w_chunks.map(|v| v.into_iter());
1697 for _ in 0..num_chunks {
1698 sinks.push(FlexSolveSink {
1699 intercepts: intercepts_iter.next().expect("chunk count matches"),
1700 a_q: a_q_iter
1701 .as_mut()
1702 .map(|it| it.next().expect("chunk count matches")),
1703 a_b: a_b_iter
1704 .as_mut()
1705 .map(|it| it.next().expect("chunk count matches")),
1706 a_h: a_h_iter
1707 .as_mut()
1708 .map(|it| it.next().expect("chunk count matches")),
1709 a_w: a_w_iter
1710 .as_mut()
1711 .map(|it| it.next().expect("chunk count matches")),
1712 });
1713 }
1714
1715 let global_score_basis_table: Option<
1726 Vec<Vec<crate::cubic_cell_kernel::LocalSpanCubic>>,
1727 > = if let (LatentMeasureKind::GlobalEmpirical { grid }, Some(runtime)) =
1728 (&self.latent_measure, self.score_warp_runtime.as_ref())
1729 {
1730 let mut table = Vec::with_capacity(score_warp_dim);
1731 for j in 0..score_warp_dim {
1732 let mut row = Vec::with_capacity(grid.nodes.len());
1733 for &node in &grid.nodes {
1734 row.push(
1735 runtime
1736 .basis_cubic_at(j, node)
1737 .map_err(EstimationError::from)?,
1738 );
1739 }
1740 table.push(row);
1741 }
1742 Some(table)
1743 } else {
1744 None
1745 };
1746 let global_score_basis_table = global_score_basis_table.as_ref();
1747
1748 sinks
1749 .into_par_iter()
1750 .enumerate()
1751 .try_for_each(|(chunk_idx, mut sink)| -> Result<(), EstimationError> {
1752 let start = chunk_idx * chunk_size;
1753 let end = (start + chunk_size).min(n);
1754 let rows = end - start;
1755 let intercepts_view = &mut sink.intercepts;
1759 let mut a_q = sink.a_q.as_mut();
1760 let mut a_b = sink.a_b.as_mut();
1761 let mut a_h = sink.a_h.as_mut();
1762 let mut a_w = sink.a_w.as_mut();
1763 let mut warm_start_buf = Array1::<f64>::zeros(1);
1764 let mut f_h_row = vec![0.0; score_warp_dim];
1765 let mut f_w_row = vec![0.0; link_dev_dim];
1766
1767 for local_row in 0..rows {
1768 let i = start + local_row;
1769 let slope = logslope_eta[i];
1770 let q = marginal_eta[i];
1771 let empirical_grid = self.empirical_grid_for_prediction_row(input, i)?;
1772 let score_corr_row = anchor_corrections.score_warp_row(i);
1773 let link_corr_row = anchor_corrections.link_dev_row(i);
1774 intercepts_view[local_row] = self.solve_intercept_scalar(
1775 q,
1776 slope,
1777 link_dev_beta_owned.as_ref(),
1778 score_warp_beta_owned.as_ref(),
1779 empirical_grid.as_ref(),
1780 &mut warm_start_buf,
1781 score_corr_row,
1782 link_corr_row,
1783 )?;
1784
1785 if !need_gradient {
1786 continue;
1787 }
1788
1789 let intercept = intercepts_view[local_row];
1790 let (_, m_a_raw, _) = self.evaluate_prediction_calibration(
1791 intercept,
1792 q,
1793 slope,
1794 score_warp_beta_owned.as_ref(),
1795 link_dev_beta_owned.as_ref(),
1796 empirical_grid.as_ref(),
1797 score_corr_row,
1798 link_corr_row,
1799 )?;
1800 let m_a = m_a_raw.max(1e-12);
1801 a_q.as_mut().expect("a_q allocated when need_gradient")[local_row] =
1802 marginal_map[i].mu1 / m_a;
1803 let mut f_b = 0.0;
1804 f_h_row.fill(0.0);
1805 f_w_row.fill(0.0);
1806 if let Some(grid) = empirical_grid.as_ref() {
1807 for (node_idx, (node, weight)) in grid.pairs().enumerate() {
1808 let obs = self.observed_denested_cell_partials_at_z(
1809 node,
1810 intercept,
1811 slope,
1812 score_warp_beta_owned.as_ref(),
1813 link_dev_beta_owned.as_ref(),
1814 score_corr_row,
1815 link_corr_row,
1816 )?;
1817 let eta = eval_coeff4_at(&obs.coeff, node);
1818 let pdf = normal_pdf(eta);
1819 f_b += weight * pdf * eval_coeff4_at(&obs.dc_db, node);
1820
1821 if let Some(runtime) = self.score_warp_runtime.as_ref() {
1822 for j in 0..score_warp_dim {
1823 let mut basis_span = if let Some(table) =
1831 global_score_basis_table
1832 {
1833 table[j][node_idx]
1834 } else {
1835 runtime
1836 .basis_cubic_at(j, node)
1837 .map_err(EstimationError::from)?
1838 };
1839 if let Some(corr) = score_corr_row {
1846 basis_span.c0 -= corr[j];
1847 }
1848 let coeffs = crate::cubic_cell_kernel::score_basis_cell_coefficients(
1849 basis_span,
1850 slope,
1851 );
1852 let coeffs = scale_coeff4(coeffs, scale);
1853 f_h_row[j] += weight * pdf * eval_coeff4_at(&coeffs, node);
1854 }
1855 }
1856
1857 if let Some(runtime) = self.link_deviation_runtime.as_ref() {
1858 for j in 0..link_dev_dim {
1859 let mut basis_span = runtime
1860 .basis_cubic_at(j, intercept + slope * node)
1861 .map_err(EstimationError::from)?;
1862 if let Some(corr) = link_corr_row {
1863 basis_span.c0 -= corr[j];
1864 }
1865 let coeffs = crate::cubic_cell_kernel::link_basis_cell_coefficients(
1866 basis_span,
1867 intercept,
1868 slope,
1869 );
1870 let coeffs = scale_coeff4(coeffs, scale);
1871 f_w_row[j] += weight * pdf * eval_coeff4_at(&coeffs, node);
1872 }
1873 }
1874 }
1875 } else {
1876 let cells = self.denested_partition_cells(
1877 intercept,
1878 slope,
1879 score_warp_beta_owned.as_ref(),
1880 link_dev_beta_owned.as_ref(),
1881 score_corr_row,
1882 link_corr_row,
1883 )?;
1884 for partition_cell in cells {
1885 let cell = partition_cell.cell;
1886 let state =
1887 crate::cubic_cell_kernel::evaluate_cell_moments(
1888 cell, 9,
1889 )
1890 .map_err(EstimationError::InvalidInput)?;
1891 let (_, dc_db_raw) = crate::cubic_cell_kernel::denested_cell_coefficient_partials(
1892 partition_cell.score_span,
1893 partition_cell.link_span,
1894 intercept,
1895 slope,
1896 );
1897 let dc_db = scale_coeff4(dc_db_raw, scale);
1901 f_b += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
1902 &dc_db,
1903 &state.moments,
1904 )
1905 .map_err(EstimationError::InvalidInput)?;
1906
1907 let mid = 0.5 * (cell.left + cell.right);
1908 if let Some(runtime) = self.score_warp_runtime.as_ref() {
1909 for j in 0..score_warp_dim {
1910 let mut basis_span = runtime
1911 .basis_cubic_at(j, mid)
1912 .map_err(EstimationError::from)?;
1913 if let Some(corr) = score_corr_row {
1914 basis_span.c0 -= corr[j];
1915 }
1916 let coeffs = crate::cubic_cell_kernel::score_basis_cell_coefficients(
1917 basis_span, slope,
1918 );
1919 let coeffs = scale_coeff4(coeffs, scale);
1920 f_h_row[j] += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
1921 &coeffs,
1922 &state.moments,
1923 )
1924 .map_err(EstimationError::InvalidInput)?;
1925 }
1926 }
1927
1928 if let Some(runtime) = self.link_deviation_runtime.as_ref() {
1929 for j in 0..link_dev_dim {
1930 let mut basis_span = runtime
1931 .basis_cubic_at(j, intercept + slope * mid)
1932 .map_err(EstimationError::from)?;
1933 if let Some(corr) = link_corr_row {
1934 basis_span.c0 -= corr[j];
1935 }
1936 let coeffs = crate::cubic_cell_kernel::link_basis_cell_coefficients(
1937 basis_span,
1938 intercept,
1939 slope,
1940 );
1941 let coeffs = scale_coeff4(coeffs, scale);
1942 f_w_row[j] += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
1943 &coeffs,
1944 &state.moments,
1945 )
1946 .map_err(EstimationError::InvalidInput)?;
1947 }
1948 }
1949 }
1950 }
1951 if let Some(a_h_view) = a_h.as_mut() {
1952 let factor = -1.0 / m_a;
1953 for j in 0..score_warp_dim {
1954 a_h_view[[local_row, j]] = factor * f_h_row[j];
1955 }
1956 }
1957 if let Some(a_w_view) = a_w.as_mut() {
1958 let factor = -1.0 / m_a;
1959 for j in 0..link_dev_dim {
1960 a_w_view[[local_row, j]] = factor * f_w_row[j];
1961 }
1962 }
1963 a_b.as_mut().expect("a_b allocated when need_gradient")[local_row] =
1964 -f_b / m_a;
1965 }
1966 Ok(())
1967 })
1968 };
1969 solve_result?;
1970
1971 let eta_base = &intercepts + &(&logslope_eta * &z);
1972
1973 let mut link_c_obs: Option<Array1<f64>> = None;
1974 let mut link_basis_obs: Option<Array2<f64>> = None;
1975 let link_dev_obs = if let (Some(runtime), Some(beta_owned)) = (
1976 self.link_deviation_runtime.as_ref(),
1977 link_dev_beta_owned.as_ref(),
1978 ) {
1979 let basis = if runtime.anchor_correction.is_some() {
1980 let anchor_rows =
1981 anchor_corrections
1982 .link_dev_anchor_rows_view()
1983 .ok_or_else(|| {
1984 EstimationError::InvalidInput(
1985 "bernoulli marginal-slope link-deviation anchor residual present but \
1986 anchor_corrections bundle is missing the parametric anchor rows"
1987 .to_string(),
1988 )
1989 })?;
1990 runtime
1991 .design_with_anchor_rows(&eta_base, anchor_rows)
1992 .map_err(EstimationError::from)?
1993 } else {
1994 runtime.design(&eta_base).map_err(EstimationError::from)?
1995 };
1996 let dev = basis.dot(beta_owned);
1997 if need_gradient {
1998 let d1 = runtime
1999 .first_derivative_design(&eta_base)
2000 .map_err(EstimationError::from)?;
2001 let mut c_obs = d1.dot(beta_owned);
2002 c_obs.mapv_inplace(|v| v + 1.0);
2003 link_c_obs = Some(c_obs);
2004 link_basis_obs = Some(basis);
2005 }
2006 dev
2007 } else {
2008 Array1::zeros(n)
2009 };
2010 let final_eta_internal =
2011 (&eta_base + &(&logslope_eta * &score_dev_obs) + &link_dev_obs).mapv(|v| scale * v);
2012
2013 if !need_gradient {
2014 return self.transform_internal_eta_to_base_scale(final_eta_internal, None);
2015 }
2016
2017 let allocated = "need_gradient is true past the early return, so this was allocated";
2020 let a_q_vec = a_q_vec.expect(allocated);
2021 let a_b_vec = a_b_vec.expect(allocated);
2022
2023 let mut grad = Array2::<f64>::zeros((n, theta.len()));
2027 {
2028 use ndarray::Axis;
2029 use rayon::iter::IndexedParallelIterator;
2030 let grad_result: Result<(), String> = grad
2031 .axis_chunks_iter_mut(Axis(0), chunk_size)
2032 .into_par_iter()
2033 .enumerate()
2034 .try_for_each(|(chunk_idx, mut grad_chunk)| -> Result<(), String> {
2035 let start = chunk_idx * chunk_size;
2036 let end = (start + chunk_size).min(n);
2037 let mc = input
2038 .design
2039 .try_row_chunk(start..end)
2040 .map_err(|e| e.to_string())?;
2041 let lc = design_logslope
2042 .try_row_chunk(start..end)
2043 .map_err(|e| e.to_string())?;
2044 let rows = end - start;
2045
2046 for li in 0..rows {
2047 let i = start + li;
2048 let mut row = grad_chunk.row_mut(li);
2049
2050 let a_q = a_q_vec[i];
2051 for j in 0..marginal_dim {
2052 row[j] = a_q * mc[[li, j]];
2053 }
2054
2055 let base_multiplier = link_c_obs.as_ref().map_or(1.0, |c| c[i]);
2056 let g_scale = base_multiplier * (a_b_vec[i] + z[i]) + score_dev_obs[i];
2057 for j in 0..logslope_dim {
2058 row[logslope_offset + j] = g_scale * lc[[li, j]];
2059 }
2060
2061 if let (Some(a_h_rows), Some(obs_design)) =
2062 (a_h_rows.as_ref(), score_warp_obs_design.as_ref())
2063 {
2064 let slope = logslope_eta[i];
2065 for j in 0..score_warp_dim {
2066 row[score_warp_offset + j] =
2067 base_multiplier * a_h_rows[[i, j]] + slope * obs_design[[i, j]];
2068 }
2069 }
2070
2071 if let Some(a_w_rows) = a_w_rows.as_ref() {
2072 for j in 0..link_dev_dim {
2073 row[link_dev_offset + j] = a_w_rows[[i, j]];
2074 }
2075 }
2076
2077 if let (Some(link_c), Some(link_basis)) =
2078 (link_c_obs.as_ref(), link_basis_obs.as_ref())
2079 {
2080 let c = link_c[i];
2081 for j in 0..marginal_dim {
2082 row[j] *= c;
2083 }
2084 for j in 0..link_dev_dim {
2085 row[link_dev_offset + j] =
2086 c * row[link_dev_offset + j] + link_basis[[i, j]];
2087 }
2088 }
2089 }
2090 Ok(())
2091 });
2092 grad_result.map_err(EstimationError::InvalidInput)?;
2093 }
2094 if scale != 1.0 {
2095 grad.mapv_inplace(|v| scale * v);
2096 }
2097 self.transform_internal_eta_to_base_scale(final_eta_internal, Some(grad))
2098 }
2099
2100 pub fn final_eta_from_theta(
2110 &self,
2111 input: &PredictInput,
2112 theta: &Array1<f64>,
2113 ) -> Result<Array1<f64>, EstimationError> {
2114 let (eta, _) = self.final_eta_and_gradient_from_theta(input, theta, false)?;
2115 Ok(eta)
2116 }
2117
2118 pub fn theta_len(&self) -> usize {
2123 self.beta_marginal.len()
2124 + self.beta_logslope.len()
2125 + self.beta_score_warp.as_ref().map_or(0, Array1::len)
2126 + self.beta_link_dev.as_ref().map_or(0, Array1::len)
2127 }
2128
2129 pub fn predict_eta_and_q_chain(
2146 &self,
2147 input: &PredictInput,
2148 ) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
2149 let z_raw = input.auxiliary_scalar.as_ref().ok_or_else(|| {
2150 EstimationError::InvalidInput(format!(
2151 "bernoulli marginal-slope prediction requires auxiliary z column '{}'",
2152 self.z_column
2153 ))
2154 })?;
2155 let z_normalized = self
2156 .latent_z_normalization
2157 .apply(z_raw, "bernoulli marginal-slope prediction")
2158 .map_err(EstimationError::from)?;
2159 let z = self.apply_latent_z_calibration(&z_normalized);
2165 let z = self.apply_latent_z_conditional_calibration(&z, input)?;
2169 let design_logslope = input.design_noise.as_ref().ok_or_else(|| {
2170 EstimationError::InvalidInput(
2171 "bernoulli marginal-slope prediction requires logslope design".to_string(),
2172 )
2173 })?;
2174 let n = z.len();
2175 if input.offset.len() != n {
2176 return Err(EstimationError::InvalidInput(format!(
2177 "bernoulli marginal-slope prediction primary offset length mismatch: rows={n}, offset={}",
2178 input.offset.len()
2179 )));
2180 }
2181 let logslope_offset = input
2182 .offset_noise
2183 .as_ref()
2184 .map_or_else(|| Array1::zeros(n), Clone::clone);
2185 if logslope_offset.len() != n {
2186 return Err(EstimationError::InvalidInput(format!(
2187 "bernoulli marginal-slope prediction logslope offset length mismatch: rows={n}, offset_noise={}",
2188 logslope_offset.len()
2189 )));
2190 }
2191 let marginal_eta = input
2192 .design
2193 .dot(&self.beta_marginal)
2194 .mapv(|v| v + self.baseline_marginal)
2195 + &input.offset;
2196 let logslope_eta = design_logslope
2197 .dot(&self.beta_logslope)
2198 .mapv(|v| v + self.baseline_logslope)
2199 + &logslope_offset;
2200 let scale = self.probit_frailty_scale();
2201 let flex_active =
2202 self.score_warp_runtime.is_some() || self.link_deviation_runtime.is_some();
2203
2204 if !flex_active {
2207 match &self.latent_measure {
2208 LatentMeasureKind::StandardNormal => {
2209 let sb = logslope_eta.mapv(|x| scale * x);
2212 let deta_dq = sb.mapv(|s| (1.0 + s * s).sqrt());
2213 let eta = &deta_dq * marginal_eta + &sb * z;
2214 return Ok((eta, deta_dq));
2215 }
2216 _ => {
2217 let mut eta = Array1::<f64>::zeros(n);
2218 let mut deta_dq = Array1::<f64>::zeros(n);
2219 for i in 0..n {
2220 let grid = self
2221 .empirical_grid_for_prediction_row(input, i)?
2222 .ok_or_else(|| {
2223 EstimationError::InvalidInput(
2224 "empirical latent prediction did not produce a row grid"
2225 .to_string(),
2226 )
2227 })?;
2228 let (intercept, a_marginal, _) = self
2229 .empirical_rigid_intercept_and_gradient(
2230 marginal_eta[i],
2231 logslope_eta[i],
2232 &grid.nodes,
2233 &grid.weights,
2234 )?;
2235 eta[i] = intercept + scale * logslope_eta[i] * z[i];
2236 deta_dq[i] = a_marginal;
2237 }
2238 return Ok((eta, deta_dq));
2239 }
2240 }
2241 }
2242
2243 let marginal_map = marginal_eta
2249 .iter()
2250 .map(|&eta_marg| {
2251 bernoulli_marginal_link_map(&self.base_link, eta_marg)
2252 .map_err(EstimationError::InvalidInput)
2253 })
2254 .collect::<Result<Vec<_>, _>>()?;
2255 let anchor_corrections =
2258 self.build_anchor_correction_matrices(input, design_logslope, &z)?;
2259 use rayon::iter::{IntoParallelIterator, ParallelIterator};
2263 let pairs: Result<Vec<(f64, f64)>, EstimationError> = (0..n)
2264 .into_par_iter()
2265 .map_init(
2266 || Array1::<f64>::zeros(1),
2267 |warm_start_buf, i| {
2268 let q = marginal_eta[i];
2269 let slope = logslope_eta[i];
2270 let empirical_grid = self.empirical_grid_for_prediction_row(input, i)?;
2271 let score_corr_row = anchor_corrections.score_warp_row(i);
2272 let link_corr_row = anchor_corrections.link_dev_row(i);
2273 let intercept = self.solve_intercept_scalar(
2274 q,
2275 slope,
2276 self.beta_link_dev.as_ref(),
2277 self.beta_score_warp.as_ref(),
2278 empirical_grid.as_ref(),
2279 warm_start_buf,
2280 score_corr_row,
2281 link_corr_row,
2282 )?;
2283 let (_, m_a_raw, _) = self.evaluate_prediction_calibration(
2284 intercept,
2285 q,
2286 slope,
2287 self.beta_score_warp.as_ref(),
2288 self.beta_link_dev.as_ref(),
2289 empirical_grid.as_ref(),
2290 score_corr_row,
2291 link_corr_row,
2292 )?;
2293 let m_a = m_a_raw.max(1e-12);
2294 Ok((intercept, marginal_map[i].mu1 / m_a))
2295 },
2296 )
2297 .collect();
2298 let pairs = pairs?;
2299 let mut intercepts = Array1::<f64>::zeros(n);
2300 let mut a_q = Array1::<f64>::zeros(n);
2301 for (i, (intercept, a)) in pairs.into_iter().enumerate() {
2302 intercepts[i] = intercept;
2303 a_q[i] = a;
2304 }
2305
2306 let score_dev_obs = if let (Some(runtime), Some(beta)) = (
2307 self.score_warp_runtime.as_ref(),
2308 self.beta_score_warp.as_ref(),
2309 ) {
2310 let design = if runtime.anchor_correction.is_some() {
2311 let anchor_rows = anchor_corrections
2312 .score_warp_anchor_rows_view()
2313 .ok_or_else(|| {
2314 EstimationError::InvalidInput(
2315 "bernoulli marginal-slope score-warp anchor residual present but \
2316 anchor_corrections bundle is missing the parametric anchor rows"
2317 .to_string(),
2318 )
2319 })?;
2320 runtime
2321 .design_with_anchor_rows(&z, anchor_rows)
2322 .map_err(EstimationError::from)?
2323 } else {
2324 runtime.design(&z).map_err(EstimationError::from)?
2325 };
2326 design.dot(beta)
2327 } else {
2328 Array1::zeros(n)
2329 };
2330 let eta_base = &intercepts + &(&logslope_eta * &z);
2331 let (link_dev_obs, link_c_obs) = if let (Some(runtime), Some(beta)) = (
2332 self.link_deviation_runtime.as_ref(),
2333 self.beta_link_dev.as_ref(),
2334 ) {
2335 let basis = if runtime.anchor_correction.is_some() {
2336 let anchor_rows =
2337 anchor_corrections
2338 .link_dev_anchor_rows_view()
2339 .ok_or_else(|| {
2340 EstimationError::InvalidInput(
2341 "bernoulli marginal-slope link-deviation anchor residual present but \
2342 anchor_corrections bundle is missing the parametric anchor rows"
2343 .to_string(),
2344 )
2345 })?;
2346 runtime
2347 .design_with_anchor_rows(&eta_base, anchor_rows)
2348 .map_err(EstimationError::from)?
2349 } else {
2350 runtime.design(&eta_base).map_err(EstimationError::from)?
2351 };
2352 let dev = basis.dot(beta);
2353 let d1 = runtime
2354 .first_derivative_design(&eta_base)
2355 .map_err(EstimationError::from)?;
2356 let mut c_obs = d1.dot(beta);
2357 c_obs.mapv_inplace(|v| v + 1.0);
2358 (dev, c_obs)
2359 } else {
2360 (Array1::zeros(n), Array1::ones(n))
2361 };
2362 let final_eta_internal =
2363 (&eta_base + &(&logslope_eta * &score_dev_obs) + &link_dev_obs).mapv(|v| scale * v);
2364 let deta_dq = (&link_c_obs * &a_q).mapv(|v| scale * v);
2365 Ok((final_eta_internal, deta_dq))
2366 }
2367}