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