1use crate::bms::{
2 EmpiricalZGrid, LatentMeasureKind, LatentZConditionalCalibration, LatentZRankIntCalibration,
3 bernoulli_marginal_link_map, empirical_intercept_from_marginal,
4};
5use crate::inference::model::{SavedCompiledFlexBlock, SavedLatentZNormalization};
6use crate::marginal_slope_shared::{
7 ObservedDenestedCellPartials, eval_coeff4_at,
8 probit_frailty_scale as marginal_slope_probit_frailty_scale, scale_coeff4,
9};
10use crate::survival::lognormal_kernel::FrailtySpec;
11use gam_linalg::matrix::DesignMatrix;
12use gam_math::probability::{normal_cdf, normal_pdf};
13use gam_problem::types::{InverseLink, LikelihoodSpec};
14use gam_runtime::resource::prediction_chunk_rows;
15use gam_solve::estimate::{EstimationError, UnifiedFitResult};
16use ndarray::{Array1, Array2, ArrayView1};
17use rayon::iter::{IntoParallelIterator, ParallelIterator};
18
19pub struct PredictResult {
20 pub eta: Array1<f64>,
21 pub mean: Array1<f64>,
22}
23
24pub struct PredictInput {
27 pub design: DesignMatrix,
29 pub offset: Array1<f64>,
31 pub design_noise: Option<DesignMatrix>,
33 pub offset_noise: Option<Array1<f64>>,
35 pub auxiliary_scalar: Option<Array1<f64>>,
37 pub auxiliary_matrix: Option<Array2<f64>>,
39}
40
41pub struct BernoulliMarginalSlopePredictor {
42 pub beta_marginal: Array1<f64>,
43 pub beta_logslope: Array1<f64>,
44 pub beta_score_warp: Option<Array1<f64>>,
45 pub beta_link_dev: Option<Array1<f64>>,
46 pub base_link: InverseLink,
47 pub z_column: String,
48 pub latent_z_normalization: SavedLatentZNormalization,
49 pub latent_measure: LatentMeasureKind,
50 pub baseline_marginal: f64,
51 pub baseline_logslope: f64,
52 pub covariance: Option<Array2<f64>>,
53 pub score_warp_runtime: Option<SavedCompiledFlexBlock>,
54 pub link_deviation_runtime: Option<SavedCompiledFlexBlock>,
55 pub gaussian_frailty_sd: Option<f64>,
56 pub latent_z_calibration: Option<LatentZRankIntCalibration>,
57 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
58}
59
60#[derive(Default)]
79struct BmsAnchorCorrections {
80 score_warp_anchor_rows: Option<Array2<f64>>,
83 link_dev_anchor_rows: Option<Array2<f64>>,
89 score_warp: Option<Array2<f64>>,
90 link_dev: Option<Array2<f64>>,
91}
92
93impl BmsAnchorCorrections {
94 fn score_warp_row(&self, row: usize) -> Option<ndarray::ArrayView1<'_, f64>> {
95 self.score_warp.as_ref().map(|m| m.row(row))
96 }
97
98 fn link_dev_row(&self, row: usize) -> Option<ndarray::ArrayView1<'_, f64>> {
99 self.link_dev.as_ref().map(|m| m.row(row))
100 }
101
102 fn score_warp_anchor_rows_view(&self) -> Option<ndarray::ArrayView2<'_, f64>> {
103 self.score_warp_anchor_rows.as_ref().map(|m| m.view())
104 }
105
106 fn link_dev_anchor_rows_view(&self) -> Option<ndarray::ArrayView2<'_, f64>> {
107 self.link_dev_anchor_rows.as_ref().map(|m| m.view())
108 }
109}
110
111impl BernoulliMarginalSlopePredictor {
112 fn build_anchor_correction_matrices(
121 &self,
122 input: &PredictInput,
123 design_logslope: &DesignMatrix,
124 z: &Array1<f64>,
125 ) -> Result<BmsAnchorCorrections, EstimationError> {
126 use crate::inference::model::SavedAnchorKind;
127 let needs_score = self
128 .score_warp_runtime
129 .as_ref()
130 .is_some_and(|r| r.anchor_correction.is_some());
131 let needs_link = self
132 .link_deviation_runtime
133 .as_ref()
134 .is_some_and(|r| r.anchor_correction.is_some());
135 if !needs_score && !needs_link {
136 return Ok(BmsAnchorCorrections::default());
137 }
138 let marginal_dense = input
143 .design
144 .try_to_dense_arc(
145 "bernoulli marginal-slope predict-time marginal anchor materialisation",
146 )
147 .map_err(EstimationError::InvalidInput)?;
148 let logslope_dense = design_logslope
149 .try_to_dense_arc(
150 "bernoulli marginal-slope predict-time logslope anchor materialisation",
151 )
152 .map_err(EstimationError::InvalidInput)?;
153 let n_rows = marginal_dense.nrows();
154 if logslope_dense.nrows() != n_rows {
155 return Err(EstimationError::InvalidInput(format!(
156 "bernoulli marginal-slope predict anchor materialisation row mismatch: marginal {} vs logslope {}",
157 n_rows,
158 logslope_dense.nrows()
159 )));
160 }
161 if z.len() != n_rows {
162 return Err(EstimationError::InvalidInput(format!(
163 "bernoulli marginal-slope predict anchor materialisation: z has {} entries, expected {}",
164 z.len(),
165 n_rows
166 )));
167 }
168 let p_marginal = marginal_dense.ncols();
169 let p_logslope = logslope_dense.ncols();
170 let d_parametric = p_marginal + p_logslope;
171 let mut parametric_rows = Array2::<f64>::zeros((n_rows, d_parametric));
172 parametric_rows
173 .slice_mut(ndarray::s![.., 0..p_marginal])
174 .assign(&marginal_dense.view());
175 parametric_rows
176 .slice_mut(ndarray::s![.., p_marginal..d_parametric])
177 .assign(&logslope_dense.view());
178
179 let score_warp = if needs_score {
182 let runtime = self.score_warp_runtime.as_ref().unwrap();
183 self.validate_runtime_anchor_layout_parametric_only(runtime, "score_warp")?;
184 runtime
185 .anchor_correction_matrix(parametric_rows.view())
186 .map_err(EstimationError::from)?
187 } else {
188 None
189 };
190
191 let (link_dev_anchor_rows, link_dev) = if needs_link {
196 let runtime = self.link_deviation_runtime.as_ref().unwrap();
197 let mut saw_flex_tail = false;
202 let mut flex_tail_ncols: usize = 0;
203 for (idx, component) in runtime.anchor_components.iter().enumerate() {
204 match &component.kind {
205 SavedAnchorKind::Parametric { .. } => {
206 if saw_flex_tail {
207 return Err(EstimationError::InvalidInput(format!(
208 "bernoulli marginal-slope link-deviation saved anchor components \
209 are out of order: parametric component at index {idx} follows \
210 a FlexEvaluation tail",
211 )));
212 }
213 }
214 SavedAnchorKind::FlexEvaluation { ncols } => {
215 if saw_flex_tail {
216 return Err(EstimationError::InvalidInput(
217 "bernoulli marginal-slope link-deviation saved anchor components \
218 carry more than one FlexEvaluation tail; fit-time stacking emits \
219 at most one (score-warp)"
220 .to_string(),
221 ));
222 }
223 saw_flex_tail = true;
224 flex_tail_ncols = *ncols;
225 }
226 }
227 }
228 let rows = if saw_flex_tail {
229 let score_runtime = self.score_warp_runtime.as_ref().ok_or_else(|| {
230 EstimationError::InvalidInput(
231 "bernoulli marginal-slope link-deviation saved anchor includes a \
232 FlexEvaluation tail but the saved score-warp runtime is missing"
233 .to_string(),
234 )
235 })?;
236 let score_basis = if score_runtime.anchor_correction.is_some() {
242 score_runtime
243 .design_with_anchor_rows(z, parametric_rows.view())
244 .map_err(EstimationError::from)?
245 } else {
246 score_runtime.design(z).map_err(EstimationError::from)?
247 };
248 if score_basis.ncols() != flex_tail_ncols {
249 return Err(EstimationError::InvalidInput(format!(
250 "bernoulli marginal-slope link-deviation FlexEvaluation tail expects \
251 {} score-warp basis columns at predict rows, got {}",
252 flex_tail_ncols,
253 score_basis.ncols()
254 )));
255 }
256 let mut combined = Array2::<f64>::zeros((n_rows, d_parametric + flex_tail_ncols));
257 combined
258 .slice_mut(ndarray::s![.., 0..d_parametric])
259 .assign(¶metric_rows.view());
260 combined
261 .slice_mut(ndarray::s![.., d_parametric..])
262 .assign(&score_basis.view());
263 combined
264 } else {
265 parametric_rows.clone()
266 };
267 let corr = runtime
268 .anchor_correction_matrix(rows.view())
269 .map_err(EstimationError::from)?;
270 (Some(rows), corr)
271 } else {
272 (None, None)
273 };
274
275 Ok(BmsAnchorCorrections {
276 score_warp_anchor_rows: Some(parametric_rows),
277 link_dev_anchor_rows,
278 score_warp,
279 link_dev,
280 })
281 }
282
283 fn validate_runtime_anchor_layout_parametric_only(
287 &self,
288 runtime: &SavedCompiledFlexBlock,
289 runtime_label: &str,
290 ) -> Result<(), EstimationError> {
291 use crate::inference::model::SavedAnchorKind;
292 for (idx, component) in runtime.anchor_components.iter().enumerate() {
293 match &component.kind {
294 SavedAnchorKind::Parametric { .. } => {}
295 SavedAnchorKind::FlexEvaluation { .. } => {
296 return Err(EstimationError::InvalidInput(format!(
297 "bernoulli marginal-slope {runtime_label} saved anchor component at \
298 index {idx} is FlexEvaluation; only Parametric components are \
299 expected for this runtime",
300 )));
301 }
302 }
303 }
304 Ok(())
305 }
306
307 pub fn likelihood_family(&self) -> LikelihoodSpec {
308 LikelihoodSpec::binomial_probit()
309 }
310
311 pub fn mean_from_eta(&self, eta: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
312 Ok(eta.mapv(normal_cdf))
313 }
314
315 pub fn mean_derivative_from_eta(
316 &self,
317 eta: &Array1<f64>,
318 ) -> Result<Array1<f64>, EstimationError> {
319 Ok(eta.mapv(normal_pdf))
320 }
321
322 pub(crate) fn probit_frailty_scale(&self) -> f64 {
323 marginal_slope_probit_frailty_scale(self.gaussian_frailty_sd)
324 }
325
326 fn apply_latent_z_calibration(&self, z: &Array1<f64>) -> Array1<f64> {
344 match &self.latent_z_calibration {
345 Some(cal) => Array1::from_iter(z.iter().map(|&zi| cal.apply_at_predict(zi))),
346 None => z.clone(),
347 }
348 }
349
350 fn apply_latent_z_conditional_calibration(
361 &self,
362 z: &Array1<f64>,
363 input: &PredictInput,
364 ) -> Result<Array1<f64>, EstimationError> {
365 let Some(cal) = self.latent_z_conditional_calibration.as_ref() else {
366 return Ok(z.clone());
367 };
368 let a_block = input.design.to_dense();
369 cal.apply(z.view(), a_block.view())
370 .map_err(EstimationError::InvalidInput)
371 }
372
373 fn rigid_intercept_from_marginal(&self, marginal_eta: f64, slope: f64) -> f64 {
374 let probit_scale = self.probit_frailty_scale();
375 marginal_eta * (1.0 + (probit_scale * slope).powi(2)).sqrt() / probit_scale
376 }
377
378 fn empirical_rigid_intercept_and_gradient(
379 &self,
380 marginal_eta: f64,
381 slope: f64,
382 nodes: &[f64],
383 weights: &[f64],
384 ) -> Result<(f64, f64, f64), EstimationError> {
385 let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
386 .map_err(EstimationError::InvalidInput)?;
387 let scale = self.probit_frailty_scale();
388 let intercept = empirical_intercept_from_marginal(
389 marginal.mu,
390 marginal.q,
391 slope,
392 scale,
393 nodes,
394 weights,
395 None,
396 )
397 .map_err(EstimationError::InvalidInput)?;
398 let observed_slope = scale * slope;
399 let mut f_a = 0.0;
400 let mut f_b = 0.0;
401 for (&node, &weight) in nodes.iter().zip(weights.iter()) {
402 let eta = intercept + observed_slope * node;
403 let pdf = normal_pdf(eta);
404 f_a += weight * pdf;
405 f_b += weight * pdf * scale * node;
406 }
407 if !(f_a.is_finite() && f_a > 0.0 && f_b.is_finite()) {
408 return Err(EstimationError::InvalidInput(format!(
409 "empirical latent prediction calibration derivative is invalid: F_a={f_a}, F_b={f_b}"
410 )));
411 }
412 let a_marginal_eta = marginal.mu1 / f_a;
413 let a_slope = -f_b / f_a;
414 Ok((intercept, a_marginal_eta, a_slope))
415 }
416
417 fn local_empirical_mixture_for_point(
418 point: &[f64],
419 centers: &[Vec<f64>],
420 top_k: usize,
421 bandwidth: f64,
422 ) -> Result<Vec<(usize, f64)>, EstimationError> {
423 if centers.is_empty() {
424 return Err(EstimationError::InvalidInput(
425 "local empirical latent prediction has no centers".to_string(),
426 ));
427 }
428 if top_k == 0 {
429 return Err(EstimationError::InvalidInput(
430 "local empirical latent prediction top_k must be positive".to_string(),
431 ));
432 }
433 if !(bandwidth.is_finite() && bandwidth > 0.0) {
434 return Err(EstimationError::InvalidInput(format!(
435 "local empirical latent prediction bandwidth must be finite and positive, got {bandwidth}"
436 )));
437 }
438 let bw2 = bandwidth * bandwidth;
439 let mut distances = Vec::<(usize, f64)>::with_capacity(centers.len());
440 for (idx, center) in centers.iter().enumerate() {
441 if center.len() != point.len() {
442 return Err(EstimationError::InvalidInput(format!(
443 "local empirical latent prediction center {idx} dimension mismatch: center={}, point={}",
444 center.len(),
445 point.len()
446 )));
447 }
448 let d2 = center
449 .iter()
450 .zip(point.iter())
451 .map(|(&c, &x)| {
452 let delta = x - c;
453 delta * delta
454 })
455 .sum::<f64>();
456 if !d2.is_finite() {
457 return Err(EstimationError::InvalidInput(
458 "local empirical latent prediction distance is non-finite".to_string(),
459 ));
460 }
461 distances.push((idx, d2));
462 }
463 distances.sort_by(|left, right| {
464 left.1
465 .partial_cmp(&right.1)
466 .expect("validated local empirical distances are finite")
467 });
468 let k = top_k.min(distances.len());
469 let mut mixture = Vec::with_capacity(k);
470 let mut total = 0.0;
471 for &(idx, d2) in distances.iter().take(k) {
472 let weight = (-0.5 * d2 / bw2).exp().max(1e-300);
473 mixture.push((idx, weight));
474 total += weight;
475 }
476 if !(total.is_finite() && total > 0.0) {
477 return Err(EstimationError::InvalidInput(
478 "local empirical latent prediction mixture has non-positive total weight"
479 .to_string(),
480 ));
481 }
482 for (_, weight) in &mut mixture {
483 *weight /= total;
484 }
485 Ok(mixture)
486 }
487
488 fn combine_empirical_grids(
489 grids: &[EmpiricalZGrid],
490 mixture: &[(usize, f64)],
491 ) -> Result<EmpiricalZGrid, EstimationError> {
492 let total_len = mixture
493 .iter()
494 .map(|&(idx, _)| grids.get(idx).map_or(0, |grid| grid.nodes.len()))
495 .sum::<usize>();
496 let mut nodes = Vec::with_capacity(total_len);
497 let mut weights = Vec::with_capacity(total_len);
498 let mut total_weight = 0.0;
499 for &(grid_idx, grid_weight) in mixture {
500 if !(grid_weight.is_finite() && grid_weight >= 0.0) {
501 return Err(EstimationError::InvalidInput(format!(
502 "local empirical latent prediction mixture weight must be finite and non-negative, got {grid_weight}"
503 )));
504 }
505 let grid = grids.get(grid_idx).ok_or_else(|| {
506 EstimationError::InvalidInput(format!(
507 "local empirical latent prediction grid index {grid_idx} is out of bounds for {} grids",
508 grids.len()
509 ))
510 })?;
511 if grid.nodes.len() != grid.weights.len() || grid.nodes.is_empty() {
512 return Err(EstimationError::InvalidInput(format!(
513 "local empirical latent prediction grid {grid_idx} is invalid: nodes={}, weights={}",
514 grid.nodes.len(),
515 grid.weights.len()
516 )));
517 }
518 for (node, weight) in grid.pairs() {
519 let combined_weight = grid_weight * weight;
520 if !(node.is_finite() && combined_weight.is_finite() && combined_weight >= 0.0) {
521 return Err(EstimationError::InvalidInput(
522 "local empirical latent prediction grid contains invalid node/weight"
523 .to_string(),
524 ));
525 }
526 nodes.push(node);
527 weights.push(combined_weight);
528 total_weight += combined_weight;
529 }
530 }
531 if !(total_weight.is_finite() && total_weight > 0.0) {
532 return Err(EstimationError::InvalidInput(
533 "local empirical latent prediction combined grid has non-positive total weight"
534 .to_string(),
535 ));
536 }
537 for weight in &mut weights {
538 *weight /= total_weight;
539 }
540 Ok(EmpiricalZGrid { nodes, weights })
541 }
542
543 fn empirical_grid_for_prediction_row(
544 &self,
545 input: &PredictInput,
546 row: usize,
547 ) -> Result<Option<EmpiricalZGrid>, EstimationError> {
548 match &self.latent_measure {
549 LatentMeasureKind::StandardNormal => Ok(None),
550 LatentMeasureKind::GlobalEmpirical { grid } => Ok(Some(grid.clone())),
551 LatentMeasureKind::LocalEmpirical {
552 centers,
553 grids,
554 top_k,
555 bandwidth,
556 ..
557 } => {
558 let conditioning = input.auxiliary_matrix.as_ref().ok_or_else(|| {
559 EstimationError::InvalidInput(
560 "bernoulli marginal-slope local empirical prediction requires auxiliary conditioning matrix"
561 .to_string(),
562 )
563 })?;
564 if row >= conditioning.nrows() {
565 return Err(EstimationError::InvalidInput(format!(
566 "local empirical latent prediction row {row} is out of bounds for {} conditioning rows",
567 conditioning.nrows()
568 )));
569 }
570 let expected_dim = centers.first().map_or(0, Vec::len);
571 if conditioning.ncols() != expected_dim {
572 return Err(EstimationError::InvalidInput(format!(
573 "local empirical latent prediction conditioning dimension mismatch: got {}, expected {expected_dim}",
574 conditioning.ncols()
575 )));
576 }
577 let point = conditioning.row(row).to_vec();
578 let mixture =
579 Self::local_empirical_mixture_for_point(&point, centers, *top_k, *bandwidth)?;
580 Self::combine_empirical_grids(grids, &mixture).map(Some)
581 }
582 }
583 }
584
585 fn transform_internal_eta_to_base_scale(
586 &self,
587 internal_eta: Array1<f64>,
588 internal_grad: Option<Array2<f64>>,
589 ) -> Result<(Array1<f64>, Option<Array2<f64>>), EstimationError> {
590 Ok((internal_eta, internal_grad))
591 }
592
593 fn link_terms_value_d1(
594 &self,
595 eta0: &Array1<f64>,
596 beta_link_dev: Option<&Array1<f64>>,
597 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
598 ) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
599 if let (Some(runtime), Some(beta)) = (&self.link_deviation_runtime, beta_link_dev) {
600 let basis = runtime
608 .design_uncorrected(eta0)
609 .map_err(EstimationError::from)?;
610 let mut value = &basis.dot(beta) + eta0;
611 if let Some(corr) = link_dev_correction_for_row {
612 let offset = corr.dot(beta);
613 for v in value.iter_mut() {
614 *v -= offset;
615 }
616 } else if runtime.anchor_correction.is_some() {
617 return Err(EstimationError::InvalidInput(
618 "bernoulli marginal-slope link-deviation runtime has an anchor residual but \
619 no per-row correction was supplied to link_terms_value_d1"
620 .to_string(),
621 ));
622 }
623 let d1 = runtime
624 .first_derivative_design(eta0)
625 .map_err(EstimationError::from)?;
626 Ok((value, d1.dot(beta) + 1.0))
627 } else {
628 Ok((eta0.clone(), Array1::ones(eta0.len())))
629 }
630 }
631
632 fn denested_partition_cells(
633 &self,
634 a: f64,
635 b: f64,
636 beta_score_warp: Option<&Array1<f64>>,
637 beta_link_dev: Option<&Array1<f64>>,
638 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
639 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
640 ) -> Result<Vec<crate::cubic_cell_kernel::DenestedPartitionCell>, EstimationError> {
641 let score_breaks = if let Some(runtime) = self.score_warp_runtime.as_ref() {
642 runtime.breakpoints().map_err(EstimationError::from)?
643 } else {
644 Vec::new()
645 };
646 let link_breaks = if let Some(runtime) = self.link_deviation_runtime.as_ref() {
647 runtime.breakpoints().map_err(EstimationError::from)?
648 } else {
649 Vec::new()
650 };
651 let mut cells = crate::cubic_cell_kernel::build_denested_partition_cells_with_tails(
652 a,
653 b,
654 &score_breaks,
655 &link_breaks,
656 |z| {
657 if let (Some(runtime), Some(beta)) =
658 (self.score_warp_runtime.as_ref(), beta_score_warp)
659 {
660 let mut span = runtime.local_cubic_at(beta, z)?;
661 if let Some(corr) = score_warp_correction_for_row {
668 span.c0 -= corr.dot(beta);
669 }
670 Ok(span)
671 } else {
672 Ok(crate::cubic_cell_kernel::LocalSpanCubic {
673 left: 0.0,
674 right: 1.0,
675 c0: 0.0,
676 c1: 0.0,
677 c2: 0.0,
678 c3: 0.0,
679 })
680 }
681 },
682 |u| {
683 if let (Some(runtime), Some(beta)) =
684 (self.link_deviation_runtime.as_ref(), beta_link_dev)
685 {
686 let mut span = runtime.local_cubic_at(beta, u)?;
687 if let Some(corr) = link_dev_correction_for_row {
688 span.c0 -= corr.dot(beta);
689 }
690 Ok(span)
691 } else {
692 Ok(crate::cubic_cell_kernel::LocalSpanCubic {
693 left: 0.0,
694 right: 1.0,
695 c0: 0.0,
696 c1: 0.0,
697 c2: 0.0,
698 c3: 0.0,
699 })
700 }
701 },
702 )
703 .map_err(EstimationError::InvalidInput)?;
704 let scale = self.probit_frailty_scale();
705 if scale != 1.0 {
706 for partition_cell in &mut cells {
707 partition_cell.cell.c0 *= scale;
708 partition_cell.cell.c1 *= scale;
709 partition_cell.cell.c2 *= scale;
710 partition_cell.cell.c3 *= scale;
711 }
712 }
713 Ok(cells)
714 }
715
716 fn evaluate_denested_calibration(
717 &self,
718 a: f64,
719 marginal_eta: f64,
720 slope: f64,
721 beta_score_warp: Option<&Array1<f64>>,
722 beta_link_dev: Option<&Array1<f64>>,
723 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
724 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
725 ) -> Result<(f64, f64, f64), EstimationError> {
726 let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
727 .map_err(EstimationError::InvalidInput)?;
728 let cells = self.denested_partition_cells(
729 a,
730 slope,
731 beta_score_warp,
732 beta_link_dev,
733 score_warp_correction_for_row,
734 link_dev_correction_for_row,
735 )?;
736 let scale = self.probit_frailty_scale();
737 let mut f = -marginal.mu;
738 let mut f_a = 0.0;
739 let mut f_aa = 0.0;
740 for partition_cell in cells {
741 let cell = partition_cell.cell;
742 let (dc_da_raw, _) = crate::cubic_cell_kernel::denested_cell_coefficient_partials(
743 partition_cell.score_span,
744 partition_cell.link_span,
745 a,
746 slope,
747 );
748 let (d2c_da2_raw, _, _) = crate::cubic_cell_kernel::denested_cell_second_partials(
749 partition_cell.score_span,
750 partition_cell.link_span,
751 a,
752 slope,
753 );
754 let dc_da = scale_coeff4(dc_da_raw, scale);
755 let d2c_da2 = scale_coeff4(d2c_da2_raw, scale);
756 let max_degree = crate::cubic_cell_kernel::cell_second_derivative_required_max_degree(
762 &dc_da, &dc_da, &d2c_da2,
763 );
764 let state = crate::cubic_cell_kernel::evaluate_cell_moments(cell, max_degree)
765 .map_err(EstimationError::InvalidInput)?;
766 f += state.value;
767 f_a += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
768 &dc_da,
769 &state.moments,
770 )
771 .map_err(EstimationError::InvalidInput)?;
772 f_aa += crate::cubic_cell_kernel::cell_second_derivative_from_moments(
773 cell,
774 &dc_da,
775 &dc_da,
776 &d2c_da2,
777 &state.moments,
778 )
779 .map_err(EstimationError::InvalidInput)?;
780 }
781 Ok((f, f_a, f_aa))
782 }
783
784 fn observed_denested_cell_partials_at_z(
785 &self,
786 z_value: f64,
787 a: f64,
788 b: f64,
789 beta_score_warp: Option<&Array1<f64>>,
790 beta_link_dev: Option<&Array1<f64>>,
791 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
792 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
793 ) -> Result<ObservedDenestedCellPartials, EstimationError> {
794 use crate::cubic_cell_kernel as exact;
795
796 let zero_span = exact::LocalSpanCubic {
797 left: 0.0,
798 right: 1.0,
799 c0: 0.0,
800 c1: 0.0,
801 c2: 0.0,
802 c3: 0.0,
803 };
804 let u_value = a + b * z_value;
805 let score_span = if let (Some(runtime), Some(beta)) =
806 (self.score_warp_runtime.as_ref(), beta_score_warp)
807 {
808 let mut span = runtime
809 .local_cubic_at(beta, z_value)
810 .map_err(EstimationError::from)?;
811 if let Some(corr) = score_warp_correction_for_row {
812 span.c0 -= corr.dot(beta);
813 }
814 span
815 } else {
816 zero_span
817 };
818 let link_span = if let (Some(runtime), Some(beta)) =
819 (self.link_deviation_runtime.as_ref(), beta_link_dev)
820 {
821 let mut span = runtime
822 .local_cubic_at(beta, u_value)
823 .map_err(EstimationError::from)?;
824 if let Some(corr) = link_dev_correction_for_row {
825 span.c0 -= corr.dot(beta);
826 }
827 span
828 } else {
829 zero_span
830 };
831 let scale = self.probit_frailty_scale();
832 let coeff = scale_coeff4(
833 exact::denested_cell_coefficients(score_span, link_span, a, b),
834 scale,
835 );
836 let (dc_da_raw, dc_db_raw) =
837 exact::denested_cell_coefficient_partials(score_span, link_span, a, b);
838 let (dc_daa_raw, dc_dab_raw, dc_dbb_raw) =
839 exact::denested_cell_second_partials(score_span, link_span, a, b);
840 let (dc_daaa, dc_daab, dc_dabb, dc_dbbb) = exact::denested_cell_third_partials(link_span);
841 Ok(ObservedDenestedCellPartials {
842 coeff,
843 dc_da: scale_coeff4(dc_da_raw, scale),
844 dc_db: scale_coeff4(dc_db_raw, scale),
845 dc_daa: scale_coeff4(dc_daa_raw, scale),
846 dc_dab: scale_coeff4(dc_dab_raw, scale),
847 dc_dbb: scale_coeff4(dc_dbb_raw, scale),
848 dc_daaa: scale_coeff4(dc_daaa, scale),
849 dc_daab: scale_coeff4(dc_daab, scale),
850 dc_dabb: scale_coeff4(dc_dabb, scale),
851 dc_dbbb: scale_coeff4(dc_dbbb, scale),
852 })
853 }
854
855 fn evaluate_empirical_denested_calibration(
856 &self,
857 a: f64,
858 marginal_eta: f64,
859 slope: f64,
860 beta_score_warp: Option<&Array1<f64>>,
861 beta_link_dev: Option<&Array1<f64>>,
862 grid: &EmpiricalZGrid,
863 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
864 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
865 ) -> Result<(f64, f64, f64), EstimationError> {
866 let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
867 .map_err(EstimationError::InvalidInput)?;
868 let mut f = -marginal.mu;
869 let mut f_a = 0.0;
870 let mut f_aa = 0.0;
871 for (node, weight) in grid.pairs() {
872 let obs = self.observed_denested_cell_partials_at_z(
873 node,
874 a,
875 slope,
876 beta_score_warp,
877 beta_link_dev,
878 score_warp_correction_for_row,
879 link_dev_correction_for_row,
880 )?;
881 let eta = eval_coeff4_at(&obs.coeff, node);
882 let eta_a = eval_coeff4_at(&obs.dc_da, node);
883 let eta_aa = eval_coeff4_at(&obs.dc_daa, node);
884 let pdf = normal_pdf(eta);
885 f += weight * normal_cdf(eta);
886 f_a += weight * pdf * eta_a;
887 f_aa += weight * pdf * (eta_aa - eta * eta_a * eta_a);
888 }
889 Ok((f, f_a, f_aa))
890 }
891
892 fn evaluate_prediction_calibration(
893 &self,
894 a: f64,
895 marginal_eta: f64,
896 slope: f64,
897 beta_score_warp: Option<&Array1<f64>>,
898 beta_link_dev: Option<&Array1<f64>>,
899 empirical_grid: Option<&EmpiricalZGrid>,
900 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
901 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
902 ) -> Result<(f64, f64, f64), EstimationError> {
903 if let Some(grid) = empirical_grid {
904 self.evaluate_empirical_denested_calibration(
905 a,
906 marginal_eta,
907 slope,
908 beta_score_warp,
909 beta_link_dev,
910 grid,
911 score_warp_correction_for_row,
912 link_dev_correction_for_row,
913 )
914 } else {
915 self.evaluate_denested_calibration(
916 a,
917 marginal_eta,
918 slope,
919 beta_score_warp,
920 beta_link_dev,
921 score_warp_correction_for_row,
922 link_dev_correction_for_row,
923 )
924 }
925 }
926
927 pub fn from_unified(
928 unified: &UnifiedFitResult,
929 z_column: String,
930 latent_z_normalization: SavedLatentZNormalization,
931 latent_measure: LatentMeasureKind,
932 baseline_marginal: f64,
933 baseline_logslope: f64,
934 base_link: InverseLink,
935 frailty: FrailtySpec,
936 score_warp_runtime: Option<SavedCompiledFlexBlock>,
937 link_deviation_runtime: Option<SavedCompiledFlexBlock>,
938 latent_z_calibration: Option<crate::bms::LatentZRankIntCalibration>,
939 latent_z_conditional_calibration: Option<crate::bms::LatentZConditionalCalibration>,
940 ) -> Result<Self, String> {
941 let gaussian_frailty_sd = match frailty {
942 FrailtySpec::None => None,
943 FrailtySpec::GaussianShift {
944 sigma_fixed: Some(sigma),
945 } => Some(sigma),
946 FrailtySpec::GaussianShift { sigma_fixed: None } => {
947 return Err(
948 "bernoulli marginal-slope predictor requires a fixed GaussianShift sigma"
949 .to_string(),
950 );
951 }
952 FrailtySpec::HazardMultiplier { .. } => {
953 return Err(
954 "bernoulli marginal-slope predictor does not support HazardMultiplier frailty"
955 .to_string(),
956 );
957 }
958 };
959 if !matches!(
960 base_link,
961 InverseLink::Standard(gam_problem::types::StandardLink::Probit)
962 ) {
963 return Err(
964 "bernoulli marginal-slope predictor requires link(type=probit); saved non-probit marginal-slope models must be refit"
965 .to_string(),
966 );
967 }
968 if let Some(runtime) = score_warp_runtime.as_ref() {
969 runtime.validate_exact_replay_contract().map_err(|e| {
970 format!("bernoulli marginal-slope score-warp runtime is invalid: {e}")
971 })?;
972 }
973 if let Some(runtime) = link_deviation_runtime.as_ref() {
974 runtime.validate_exact_replay_contract().map_err(|e| {
975 format!("bernoulli marginal-slope link-deviation runtime is invalid: {e}")
976 })?;
977 }
978 latent_z_normalization
982 .validate("bernoulli marginal-slope predictor")
983 .map_err(|e| {
984 format!("bernoulli marginal-slope predictor latent z normalization is invalid: {e}")
985 })?;
986 latent_measure
987 .validate("bernoulli marginal-slope predictor latent measure")
988 .map_err(|e| {
989 format!("bernoulli marginal-slope predictor latent measure is invalid: {e}")
990 })?;
991 let blocks = &unified.blocks;
992 let expected_blocks = 2
993 + usize::from(score_warp_runtime.is_some())
994 + usize::from(link_deviation_runtime.is_some());
995 if blocks.len() != expected_blocks {
996 return Err(format!(
997 "bernoulli marginal-slope predictor requires exactly {expected_blocks} coefficient blocks under the current exact de-nested semantics, got {}",
998 blocks.len()
999 ));
1000 }
1001 let mut cursor = 2usize;
1002 let beta_score_warp = if score_warp_runtime.is_some() {
1003 let beta = blocks
1004 .get(cursor)
1005 .ok_or_else(|| "missing score-warp coefficient block".to_string())?
1006 .beta
1007 .clone();
1008 cursor += 1;
1009 Some(beta)
1010 } else {
1011 None
1012 };
1013 let beta_link_dev = if link_deviation_runtime.is_some() {
1014 Some(
1015 blocks
1016 .get(cursor)
1017 .ok_or_else(|| "missing link-deviation coefficient block".to_string())?
1018 .beta
1019 .clone(),
1020 )
1021 } else {
1022 None
1023 };
1024 Ok(Self {
1025 beta_marginal: blocks[0].beta.clone(),
1026 beta_logslope: blocks[1].beta.clone(),
1027 beta_score_warp,
1028 beta_link_dev,
1029 base_link,
1030 z_column,
1031 latent_z_normalization,
1032 latent_measure,
1033 baseline_marginal,
1034 baseline_logslope,
1035 covariance: unified.beta_covariance().cloned(),
1036 score_warp_runtime,
1037 link_deviation_runtime,
1038 gaussian_frailty_sd,
1039 latent_z_calibration,
1040 latent_z_conditional_calibration,
1041 })
1042 }
1043
1044 pub fn theta(&self) -> Array1<f64> {
1045 let total = self.beta_marginal.len()
1046 + self.beta_logslope.len()
1047 + self.beta_score_warp.as_ref().map_or(0, |b| b.len())
1048 + self.beta_link_dev.as_ref().map_or(0, |b| b.len());
1049 let mut theta = Array1::<f64>::zeros(total);
1050 let mut cursor = 0usize;
1051 theta
1052 .slice_mut(ndarray::s![cursor..cursor + self.beta_marginal.len()])
1053 .assign(&self.beta_marginal);
1054 cursor += self.beta_marginal.len();
1055 theta
1056 .slice_mut(ndarray::s![cursor..cursor + self.beta_logslope.len()])
1057 .assign(&self.beta_logslope);
1058 cursor += self.beta_logslope.len();
1059 if let Some(beta) = self.beta_score_warp.as_ref() {
1060 theta
1061 .slice_mut(ndarray::s![cursor..cursor + beta.len()])
1062 .assign(beta);
1063 cursor += beta.len();
1064 }
1065 if let Some(beta) = self.beta_link_dev.as_ref() {
1066 theta
1067 .slice_mut(ndarray::s![cursor..cursor + beta.len()])
1068 .assign(beta);
1069 }
1070 theta
1071 }
1072
1073 fn split_theta<'a>(
1074 &'a self,
1075 theta: &'a Array1<f64>,
1076 ) -> Result<
1077 (
1078 ArrayView1<'a, f64>,
1079 ArrayView1<'a, f64>,
1080 Option<ArrayView1<'a, f64>>,
1081 Option<ArrayView1<'a, f64>>,
1082 ),
1083 EstimationError,
1084 > {
1085 let expected = self.theta().len();
1086 if theta.len() != expected {
1087 return Err(EstimationError::InvalidInput(format!(
1088 "bernoulli marginal-slope theta length mismatch: expected {expected}, got {}",
1089 theta.len()
1090 )));
1091 }
1092 let mut cursor = 0usize;
1093 let marginal = theta.slice(ndarray::s![cursor..cursor + self.beta_marginal.len()]);
1094 cursor += self.beta_marginal.len();
1095 let logslope = theta.slice(ndarray::s![cursor..cursor + self.beta_logslope.len()]);
1096 cursor += self.beta_logslope.len();
1097 let score_warp = self.beta_score_warp.as_ref().map(|beta| {
1098 let view = theta.slice(ndarray::s![cursor..cursor + beta.len()]);
1099 cursor += beta.len();
1100 view
1101 });
1102 let link_dev = self
1103 .beta_link_dev
1104 .as_ref()
1105 .map(|beta| theta.slice(ndarray::s![cursor..cursor + beta.len()]));
1106 Ok((marginal, logslope, score_warp, link_dev))
1107 }
1108
1109 fn solve_intercept_scalar(
1113 &self,
1114 marginal_eta: f64,
1115 slope: f64,
1116 link_dev_beta: Option<&Array1<f64>>,
1117 score_warp_beta: Option<&Array1<f64>>,
1118 empirical_grid: Option<&EmpiricalZGrid>,
1119 warm_start_buf: &mut Array1<f64>,
1120 score_warp_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1121 link_dev_correction_for_row: Option<ndarray::ArrayView1<'_, f64>>,
1122 ) -> Result<f64, EstimationError> {
1123 let marginal = bernoulli_marginal_link_map(&self.base_link, marginal_eta)
1124 .map_err(EstimationError::InvalidInput)?;
1125 let eval = |a: f64| -> Result<(f64, f64, f64), String> {
1126 self.evaluate_prediction_calibration(
1127 a,
1128 marginal_eta,
1129 slope,
1130 score_warp_beta,
1131 link_dev_beta,
1132 empirical_grid,
1133 score_warp_correction_for_row,
1134 link_dev_correction_for_row,
1135 )
1136 .map_err(|err| err.to_string())
1137 };
1138
1139 let probit_scale = self.probit_frailty_scale();
1140 let a_rigid = self.rigid_intercept_from_marginal(marginal.q, slope);
1141 let mut intercept = a_rigid;
1142 if let (Some(_), Some(beta)) = (self.link_deviation_runtime.as_ref(), link_dev_beta) {
1143 warm_start_buf[0] = a_rigid;
1144 let one_pt = warm_start_buf.slice(ndarray::s![0..1]).to_owned();
1145 let (l_val, l_d1) =
1146 self.link_terms_value_d1(&one_pt, Some(beta), link_dev_correction_for_row)?;
1147 let ell1 = l_d1[0];
1148 if ell1 > 1e-8 {
1149 let ell0 = l_val[0] - ell1 * a_rigid;
1150 let observed_logslope = probit_scale * ell1 * slope;
1151 intercept = (marginal.q * (1.0 + observed_logslope * observed_logslope).sqrt()
1152 / probit_scale
1153 - ell0)
1154 / ell1;
1155 }
1156 }
1157
1158 let target = marginal.mu;
1161 let abs_tol = 1e-8_f64.max(1e-4 * target.abs());
1162
1163 let (root, _, f_best) = crate::monotone_root::solve_monotone_root(
1164 eval,
1165 intercept,
1166 "saved bernoulli intercept",
1167 abs_tol,
1168 64,
1169 48,
1170 )?;
1171
1172 if f_best.abs() > abs_tol {
1173 return Err(EstimationError::InvalidInput(format!(
1174 "saved bernoulli marginal-slope intercept solve failed: residual={f_best:.3e} at a={root:.6}, target mu={target:.6}"
1175 )));
1176 }
1177 Ok(root)
1178 }
1179
1180 pub fn final_eta_and_gradient_from_theta(
1181 &self,
1182 input: &PredictInput,
1183 theta: &Array1<f64>,
1184 need_gradient: bool,
1185 ) -> Result<(Array1<f64>, Option<Array2<f64>>), EstimationError> {
1186 let z_raw = input.auxiliary_scalar.as_ref().ok_or_else(|| {
1187 EstimationError::InvalidInput(format!(
1188 "bernoulli marginal-slope prediction requires auxiliary z column '{}'",
1189 self.z_column
1190 ))
1191 })?;
1192 let z_normalized = self
1193 .latent_z_normalization
1194 .apply(z_raw, "bernoulli marginal-slope prediction")
1195 .map_err(EstimationError::from)?;
1196 let z = self.apply_latent_z_calibration(&z_normalized);
1206 let z = self.apply_latent_z_conditional_calibration(&z, input)?;
1210 let design_logslope = input.design_noise.as_ref().ok_or_else(|| {
1211 EstimationError::InvalidInput(
1212 "bernoulli marginal-slope prediction requires logslope design".to_string(),
1213 )
1214 })?;
1215 let (beta_marginal, beta_logslope, beta_score_warp, beta_link_dev) =
1216 self.split_theta(theta)?;
1217 if self.score_warp_runtime.is_some() != beta_score_warp.is_some() {
1218 return Err(EstimationError::InvalidInput(
1219 "bernoulli marginal-slope saved score-warp runtime/coefficients are inconsistent"
1220 .to_string(),
1221 ));
1222 }
1223 if self.link_deviation_runtime.is_some() != beta_link_dev.is_some() {
1224 return Err(EstimationError::InvalidInput(
1225 "bernoulli marginal-slope saved link-deviation runtime/coefficients are inconsistent"
1226 .to_string(),
1227 ));
1228 }
1229 let n = z.len();
1230 if input.offset.len() != n {
1231 return Err(EstimationError::InvalidInput(format!(
1232 "bernoulli marginal-slope prediction primary offset length mismatch: rows={n}, offset={}",
1233 input.offset.len()
1234 )));
1235 }
1236 let logslope_offset = input
1237 .offset_noise
1238 .as_ref()
1239 .map_or_else(|| Array1::zeros(n), Clone::clone);
1240 if logslope_offset.len() != n {
1241 return Err(EstimationError::InvalidInput(format!(
1242 "bernoulli marginal-slope prediction logslope offset length mismatch: rows={n}, offset_noise={}",
1243 logslope_offset.len()
1244 )));
1245 }
1246 let marginal_eta = input
1247 .design
1248 .dot(&beta_marginal.to_owned())
1249 .mapv(|v| v + self.baseline_marginal)
1250 + &input.offset;
1251 let logslope_eta = design_logslope
1252 .dot(&beta_logslope.to_owned())
1253 .mapv(|v| v + self.baseline_logslope)
1254 + &logslope_offset;
1255 let flex_active =
1256 self.score_warp_runtime.is_some() || self.link_deviation_runtime.is_some();
1257 let marginal_dim = self.beta_marginal.len();
1258 let logslope_dim = self.beta_logslope.len();
1259 let score_warp_dim = self.beta_score_warp.as_ref().map_or(0, Array1::len);
1260 let link_dev_dim = self.beta_link_dev.as_ref().map_or(0, Array1::len);
1261 let logslope_offset = marginal_dim;
1262 let score_warp_offset = logslope_offset + logslope_dim;
1263 let link_dev_offset = score_warp_offset + score_warp_dim;
1264 let chunk_size = prediction_chunk_rows(theta.len(), 1, n);
1265 let num_chunks = n.div_ceil(chunk_size);
1266 let scale = self.probit_frailty_scale();
1267 let anchor_corrections =
1274 self.build_anchor_correction_matrices(input, design_logslope, &z)?;
1275 let marginal_map = marginal_eta
1276 .iter()
1277 .map(|&eta| {
1278 bernoulli_marginal_link_map(&self.base_link, eta)
1279 .map_err(EstimationError::InvalidInput)
1280 })
1281 .collect::<Result<Vec<_>, _>>()?;
1282
1283 if !flex_active {
1284 let (final_eta_internal, marginal_scales, logslope_scales) = match &self.latent_measure
1285 {
1286 LatentMeasureKind::StandardNormal => {
1287 let sb_vec = logslope_eta.mapv(|b| scale * b);
1288 let c_vec = sb_vec.mapv(|sb| (1.0 + sb * sb).sqrt());
1289 let final_eta_internal = Array1::from_iter(
1290 (0..n).map(|i| c_vec[i] * marginal_eta[i] + sb_vec[i] * z[i]),
1291 );
1292 let marginal_scales = c_vec;
1293 let logslope_scales = Array1::from_iter((0..n).map(|i| {
1294 marginal_eta[i] * (scale * scale) * logslope_eta[i] / marginal_scales[i]
1295 + scale * z[i]
1296 }));
1297 (final_eta_internal, marginal_scales, logslope_scales)
1298 }
1299 LatentMeasureKind::GlobalEmpirical { grid } => {
1300 let mut final_eta = Array1::<f64>::zeros(n);
1301 let mut marginal_scales = Array1::<f64>::zeros(n);
1302 let mut logslope_scales = Array1::<f64>::zeros(n);
1303 for i in 0..n {
1304 let (intercept, a_marginal, a_slope) = self
1305 .empirical_rigid_intercept_and_gradient(
1306 marginal_eta[i],
1307 logslope_eta[i],
1308 &grid.nodes,
1309 &grid.weights,
1310 )?;
1311 final_eta[i] = intercept + scale * logslope_eta[i] * z[i];
1312 marginal_scales[i] = a_marginal;
1313 logslope_scales[i] = a_slope + scale * z[i];
1314 }
1315 (final_eta, marginal_scales, logslope_scales)
1316 }
1317 LatentMeasureKind::LocalEmpirical { .. } => {
1318 let mut final_eta = Array1::<f64>::zeros(n);
1319 let mut marginal_scales = Array1::<f64>::zeros(n);
1320 let mut logslope_scales = Array1::<f64>::zeros(n);
1321 for i in 0..n {
1322 let grid = self
1323 .empirical_grid_for_prediction_row(input, i)?
1324 .ok_or_else(|| {
1325 EstimationError::InvalidInput(
1326 "local empirical latent prediction did not produce a row grid"
1327 .to_string(),
1328 )
1329 })?;
1330 let (intercept, a_marginal, a_slope) = self
1331 .empirical_rigid_intercept_and_gradient(
1332 marginal_eta[i],
1333 logslope_eta[i],
1334 &grid.nodes,
1335 &grid.weights,
1336 )?;
1337 final_eta[i] = intercept + scale * logslope_eta[i] * z[i];
1338 marginal_scales[i] = a_marginal;
1339 logslope_scales[i] = a_slope + scale * z[i];
1340 }
1341 (final_eta, marginal_scales, logslope_scales)
1342 }
1343 };
1344
1345 if !need_gradient {
1346 return self.transform_internal_eta_to_base_scale(final_eta_internal, None);
1347 }
1348
1349 let mut grad_internal = Array2::<f64>::zeros((n, theta.len()));
1351 let mut start = 0usize;
1352 while start < n {
1353 let end = (start + chunk_size).min(n);
1354 let mc = input
1355 .design
1356 .try_row_chunk(start..end)
1357 .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
1358 let lc = design_logslope
1359 .try_row_chunk(start..end)
1360 .map_err(|e| EstimationError::InvalidInput(e.to_string()))?;
1361
1362 for li in 0..(end - start) {
1363 let i = start + li;
1364 let c = marginal_scales[i];
1365 let g_scale = logslope_scales[i];
1366 let mut row = grad_internal.row_mut(i);
1367 for j in 0..marginal_dim {
1368 row[j] = c * mc[[li, j]];
1369 }
1370 for j in 0..logslope_dim {
1371 row[logslope_offset + j] = g_scale * lc[[li, j]];
1372 }
1373 }
1374
1375 start = end;
1376 }
1377 return self
1378 .transform_internal_eta_to_base_scale(final_eta_internal, Some(grad_internal));
1379 }
1380
1381 let score_warp_obs_design = self
1383 .score_warp_runtime
1384 .as_ref()
1385 .map(|runtime| {
1386 if runtime.anchor_correction.is_some() {
1387 let anchor_rows = anchor_corrections
1388 .score_warp_anchor_rows_view()
1389 .ok_or_else(|| {
1390 EstimationError::InvalidInput(
1391 "bernoulli marginal-slope score-warp anchor residual present but \
1392 anchor_corrections bundle is missing the parametric anchor rows"
1393 .to_string(),
1394 )
1395 })?;
1396 runtime
1397 .design_with_anchor_rows(&z, anchor_rows)
1398 .map_err(EstimationError::from)
1399 } else {
1400 runtime.design(&z).map_err(EstimationError::from)
1401 }
1402 })
1403 .transpose()?;
1404 let score_dev_obs =
1405 if let (Some(design), Some(beta)) = (score_warp_obs_design.as_ref(), beta_score_warp) {
1406 design.dot(&beta.to_owned())
1407 } else {
1408 Array1::zeros(n)
1409 };
1410
1411 let score_warp_beta_owned = beta_score_warp.as_ref().map(|v| v.to_owned());
1416 let link_dev_beta_owned = beta_link_dev.as_ref().map(|v| v.to_owned());
1417 let mut intercepts = Array1::<f64>::zeros(n);
1418 let mut a_q_vec = need_gradient.then(|| Array1::<f64>::zeros(n));
1419 let mut a_b_vec = need_gradient.then(|| Array1::<f64>::zeros(n));
1420 let mut a_h_rows = if need_gradient && score_warp_dim > 0 {
1421 Some(Array2::<f64>::zeros((n, score_warp_dim)))
1422 } else {
1423 None
1424 };
1425 let mut a_w_rows = if need_gradient && link_dev_dim > 0 {
1426 Some(Array2::<f64>::zeros((n, link_dev_dim)))
1427 } else {
1428 None
1429 };
1430 let solve_result: Result<(), EstimationError> = {
1431 use ndarray::Axis;
1432 use rayon::iter::IndexedParallelIterator;
1433 let intercepts_chunks: Vec<ndarray::ArrayViewMut1<f64>> = intercepts
1434 .axis_chunks_iter_mut(Axis(0), chunk_size)
1435 .collect();
1436 let a_q_chunks: Option<Vec<ndarray::ArrayViewMut1<f64>>> = a_q_vec
1437 .as_mut()
1438 .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1439 let a_b_chunks: Option<Vec<ndarray::ArrayViewMut1<f64>>> = a_b_vec
1440 .as_mut()
1441 .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1442 let a_h_chunks: Option<Vec<ndarray::ArrayViewMut2<f64>>> = a_h_rows
1443 .as_mut()
1444 .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1445 let a_w_chunks: Option<Vec<ndarray::ArrayViewMut2<f64>>> = a_w_rows
1446 .as_mut()
1447 .map(|a| a.axis_chunks_iter_mut(Axis(0), chunk_size).collect());
1448
1449 struct FlexSolveSink<'a> {
1452 intercepts: ndarray::ArrayViewMut1<'a, f64>,
1453 a_q: Option<ndarray::ArrayViewMut1<'a, f64>>,
1454 a_b: Option<ndarray::ArrayViewMut1<'a, f64>>,
1455 a_h: Option<ndarray::ArrayViewMut2<'a, f64>>,
1456 a_w: Option<ndarray::ArrayViewMut2<'a, f64>>,
1457 }
1458 let mut sinks: Vec<FlexSolveSink<'_>> = Vec::with_capacity(num_chunks);
1459 let mut intercepts_iter = intercepts_chunks.into_iter();
1461 let mut a_q_iter = a_q_chunks.map(|v| v.into_iter());
1462 let mut a_b_iter = a_b_chunks.map(|v| v.into_iter());
1463 let mut a_h_iter = a_h_chunks.map(|v| v.into_iter());
1464 let mut a_w_iter = a_w_chunks.map(|v| v.into_iter());
1465 for _ in 0..num_chunks {
1466 sinks.push(FlexSolveSink {
1467 intercepts: intercepts_iter.next().expect("chunk count matches"),
1468 a_q: a_q_iter
1469 .as_mut()
1470 .map(|it| it.next().expect("chunk count matches")),
1471 a_b: a_b_iter
1472 .as_mut()
1473 .map(|it| it.next().expect("chunk count matches")),
1474 a_h: a_h_iter
1475 .as_mut()
1476 .map(|it| it.next().expect("chunk count matches")),
1477 a_w: a_w_iter
1478 .as_mut()
1479 .map(|it| it.next().expect("chunk count matches")),
1480 });
1481 }
1482
1483 let global_score_basis_table: Option<
1494 Vec<Vec<crate::cubic_cell_kernel::LocalSpanCubic>>,
1495 > = if let (LatentMeasureKind::GlobalEmpirical { grid }, Some(runtime)) =
1496 (&self.latent_measure, self.score_warp_runtime.as_ref())
1497 {
1498 let mut table = Vec::with_capacity(score_warp_dim);
1499 for j in 0..score_warp_dim {
1500 let mut row = Vec::with_capacity(grid.nodes.len());
1501 for &node in &grid.nodes {
1502 row.push(
1503 runtime
1504 .basis_cubic_at(j, node)
1505 .map_err(EstimationError::from)?,
1506 );
1507 }
1508 table.push(row);
1509 }
1510 Some(table)
1511 } else {
1512 None
1513 };
1514 let global_score_basis_table = global_score_basis_table.as_ref();
1515
1516 sinks
1517 .into_par_iter()
1518 .enumerate()
1519 .try_for_each(|(chunk_idx, mut sink)| -> Result<(), EstimationError> {
1520 let start = chunk_idx * chunk_size;
1521 let end = (start + chunk_size).min(n);
1522 let rows = end - start;
1523 let intercepts_view = &mut sink.intercepts;
1527 let mut a_q = sink.a_q.as_mut();
1528 let mut a_b = sink.a_b.as_mut();
1529 let mut a_h = sink.a_h.as_mut();
1530 let mut a_w = sink.a_w.as_mut();
1531 let mut warm_start_buf = Array1::<f64>::zeros(1);
1532 let mut f_h_row = vec![0.0; score_warp_dim];
1533 let mut f_w_row = vec![0.0; link_dev_dim];
1534
1535 for local_row in 0..rows {
1536 let i = start + local_row;
1537 let slope = logslope_eta[i];
1538 let q = marginal_eta[i];
1539 let empirical_grid = self.empirical_grid_for_prediction_row(input, i)?;
1540 let score_corr_row = anchor_corrections.score_warp_row(i);
1541 let link_corr_row = anchor_corrections.link_dev_row(i);
1542 intercepts_view[local_row] = self.solve_intercept_scalar(
1543 q,
1544 slope,
1545 link_dev_beta_owned.as_ref(),
1546 score_warp_beta_owned.as_ref(),
1547 empirical_grid.as_ref(),
1548 &mut warm_start_buf,
1549 score_corr_row,
1550 link_corr_row,
1551 )?;
1552
1553 if !need_gradient {
1554 continue;
1555 }
1556
1557 let intercept = intercepts_view[local_row];
1558 let (_, m_a_raw, _) = self.evaluate_prediction_calibration(
1559 intercept,
1560 q,
1561 slope,
1562 score_warp_beta_owned.as_ref(),
1563 link_dev_beta_owned.as_ref(),
1564 empirical_grid.as_ref(),
1565 score_corr_row,
1566 link_corr_row,
1567 )?;
1568 let m_a = m_a_raw.max(1e-12);
1569 a_q.as_mut().expect("a_q allocated when need_gradient")[local_row] =
1570 marginal_map[i].mu1 / m_a;
1571 let mut f_b = 0.0;
1572 f_h_row.fill(0.0);
1573 f_w_row.fill(0.0);
1574 if let Some(grid) = empirical_grid.as_ref() {
1575 for (node_idx, (node, weight)) in grid.pairs().enumerate() {
1576 let obs = self.observed_denested_cell_partials_at_z(
1577 node,
1578 intercept,
1579 slope,
1580 score_warp_beta_owned.as_ref(),
1581 link_dev_beta_owned.as_ref(),
1582 score_corr_row,
1583 link_corr_row,
1584 )?;
1585 let eta = eval_coeff4_at(&obs.coeff, node);
1586 let pdf = normal_pdf(eta);
1587 f_b += weight * pdf * eval_coeff4_at(&obs.dc_db, node);
1588
1589 if let Some(runtime) = self.score_warp_runtime.as_ref() {
1590 for j in 0..score_warp_dim {
1591 let mut basis_span = if let Some(table) =
1599 global_score_basis_table
1600 {
1601 table[j][node_idx]
1602 } else {
1603 runtime
1604 .basis_cubic_at(j, node)
1605 .map_err(EstimationError::from)?
1606 };
1607 if let Some(corr) = score_corr_row {
1614 basis_span.c0 -= corr[j];
1615 }
1616 let coeffs = crate::cubic_cell_kernel::score_basis_cell_coefficients(
1617 basis_span,
1618 slope,
1619 );
1620 let coeffs = scale_coeff4(coeffs, scale);
1621 f_h_row[j] += weight * pdf * eval_coeff4_at(&coeffs, node);
1622 }
1623 }
1624
1625 if let Some(runtime) = self.link_deviation_runtime.as_ref() {
1626 for j in 0..link_dev_dim {
1627 let mut basis_span = runtime
1628 .basis_cubic_at(j, intercept + slope * node)
1629 .map_err(EstimationError::from)?;
1630 if let Some(corr) = link_corr_row {
1631 basis_span.c0 -= corr[j];
1632 }
1633 let coeffs = crate::cubic_cell_kernel::link_basis_cell_coefficients(
1634 basis_span,
1635 intercept,
1636 slope,
1637 );
1638 let coeffs = scale_coeff4(coeffs, scale);
1639 f_w_row[j] += weight * pdf * eval_coeff4_at(&coeffs, node);
1640 }
1641 }
1642 }
1643 } else {
1644 let cells = self.denested_partition_cells(
1645 intercept,
1646 slope,
1647 score_warp_beta_owned.as_ref(),
1648 link_dev_beta_owned.as_ref(),
1649 score_corr_row,
1650 link_corr_row,
1651 )?;
1652 for partition_cell in cells {
1653 let cell = partition_cell.cell;
1654 let state =
1655 crate::cubic_cell_kernel::evaluate_cell_moments(
1656 cell, 9,
1657 )
1658 .map_err(EstimationError::InvalidInput)?;
1659 let (_, dc_db_raw) = crate::cubic_cell_kernel::denested_cell_coefficient_partials(
1660 partition_cell.score_span,
1661 partition_cell.link_span,
1662 intercept,
1663 slope,
1664 );
1665 let dc_db = scale_coeff4(dc_db_raw, scale);
1669 f_b += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
1670 &dc_db,
1671 &state.moments,
1672 )
1673 .map_err(EstimationError::InvalidInput)?;
1674
1675 let mid = 0.5 * (cell.left + cell.right);
1676 if let Some(runtime) = self.score_warp_runtime.as_ref() {
1677 for j in 0..score_warp_dim {
1678 let mut basis_span = runtime
1679 .basis_cubic_at(j, mid)
1680 .map_err(EstimationError::from)?;
1681 if let Some(corr) = score_corr_row {
1682 basis_span.c0 -= corr[j];
1683 }
1684 let coeffs = crate::cubic_cell_kernel::score_basis_cell_coefficients(
1685 basis_span, slope,
1686 );
1687 let coeffs = scale_coeff4(coeffs, scale);
1688 f_h_row[j] += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
1689 &coeffs,
1690 &state.moments,
1691 )
1692 .map_err(EstimationError::InvalidInput)?;
1693 }
1694 }
1695
1696 if let Some(runtime) = self.link_deviation_runtime.as_ref() {
1697 for j in 0..link_dev_dim {
1698 let mut basis_span = runtime
1699 .basis_cubic_at(j, intercept + slope * mid)
1700 .map_err(EstimationError::from)?;
1701 if let Some(corr) = link_corr_row {
1702 basis_span.c0 -= corr[j];
1703 }
1704 let coeffs = crate::cubic_cell_kernel::link_basis_cell_coefficients(
1705 basis_span,
1706 intercept,
1707 slope,
1708 );
1709 let coeffs = scale_coeff4(coeffs, scale);
1710 f_w_row[j] += crate::cubic_cell_kernel::cell_first_derivative_from_moments(
1711 &coeffs,
1712 &state.moments,
1713 )
1714 .map_err(EstimationError::InvalidInput)?;
1715 }
1716 }
1717 }
1718 }
1719 if let Some(a_h_view) = a_h.as_mut() {
1720 let factor = -1.0 / m_a;
1721 for j in 0..score_warp_dim {
1722 a_h_view[[local_row, j]] = factor * f_h_row[j];
1723 }
1724 }
1725 if let Some(a_w_view) = a_w.as_mut() {
1726 let factor = -1.0 / m_a;
1727 for j in 0..link_dev_dim {
1728 a_w_view[[local_row, j]] = factor * f_w_row[j];
1729 }
1730 }
1731 a_b.as_mut().expect("a_b allocated when need_gradient")[local_row] =
1732 -f_b / m_a;
1733 }
1734 Ok(())
1735 })
1736 };
1737 solve_result?;
1738
1739 let eta_base = &intercepts + &(&logslope_eta * &z);
1740
1741 let mut link_c_obs: Option<Array1<f64>> = None;
1742 let mut link_basis_obs: Option<Array2<f64>> = None;
1743 let link_dev_obs = if let (Some(runtime), Some(beta_owned)) = (
1744 self.link_deviation_runtime.as_ref(),
1745 link_dev_beta_owned.as_ref(),
1746 ) {
1747 let basis = if runtime.anchor_correction.is_some() {
1748 let anchor_rows =
1749 anchor_corrections
1750 .link_dev_anchor_rows_view()
1751 .ok_or_else(|| {
1752 EstimationError::InvalidInput(
1753 "bernoulli marginal-slope link-deviation anchor residual present but \
1754 anchor_corrections bundle is missing the parametric anchor rows"
1755 .to_string(),
1756 )
1757 })?;
1758 runtime
1759 .design_with_anchor_rows(&eta_base, anchor_rows)
1760 .map_err(EstimationError::from)?
1761 } else {
1762 runtime.design(&eta_base).map_err(EstimationError::from)?
1763 };
1764 let dev = basis.dot(beta_owned);
1765 if need_gradient {
1766 let d1 = runtime
1767 .first_derivative_design(&eta_base)
1768 .map_err(EstimationError::from)?;
1769 let mut c_obs = d1.dot(beta_owned);
1770 c_obs.mapv_inplace(|v| v + 1.0);
1771 link_c_obs = Some(c_obs);
1772 link_basis_obs = Some(basis);
1773 }
1774 dev
1775 } else {
1776 Array1::zeros(n)
1777 };
1778 let final_eta_internal =
1779 (&eta_base + &(&logslope_eta * &score_dev_obs) + &link_dev_obs).mapv(|v| scale * v);
1780
1781 if !need_gradient {
1782 return self.transform_internal_eta_to_base_scale(final_eta_internal, None);
1783 }
1784
1785 let a_q_vec = a_q_vec.unwrap();
1786 let a_b_vec = a_b_vec.unwrap();
1787
1788 let mut grad = Array2::<f64>::zeros((n, theta.len()));
1792 {
1793 use ndarray::Axis;
1794 use rayon::iter::IndexedParallelIterator;
1795 let grad_result: Result<(), String> = grad
1796 .axis_chunks_iter_mut(Axis(0), chunk_size)
1797 .into_par_iter()
1798 .enumerate()
1799 .try_for_each(|(chunk_idx, mut grad_chunk)| -> Result<(), String> {
1800 let start = chunk_idx * chunk_size;
1801 let end = (start + chunk_size).min(n);
1802 let mc = input
1803 .design
1804 .try_row_chunk(start..end)
1805 .map_err(|e| e.to_string())?;
1806 let lc = design_logslope
1807 .try_row_chunk(start..end)
1808 .map_err(|e| e.to_string())?;
1809 let rows = end - start;
1810
1811 for li in 0..rows {
1812 let i = start + li;
1813 let mut row = grad_chunk.row_mut(li);
1814
1815 let a_q = a_q_vec[i];
1816 for j in 0..marginal_dim {
1817 row[j] = a_q * mc[[li, j]];
1818 }
1819
1820 let base_multiplier = link_c_obs.as_ref().map_or(1.0, |c| c[i]);
1821 let g_scale = base_multiplier * (a_b_vec[i] + z[i]) + score_dev_obs[i];
1822 for j in 0..logslope_dim {
1823 row[logslope_offset + j] = g_scale * lc[[li, j]];
1824 }
1825
1826 if let (Some(a_h_rows), Some(obs_design)) =
1827 (a_h_rows.as_ref(), score_warp_obs_design.as_ref())
1828 {
1829 let slope = logslope_eta[i];
1830 for j in 0..score_warp_dim {
1831 row[score_warp_offset + j] =
1832 base_multiplier * a_h_rows[[i, j]] + slope * obs_design[[i, j]];
1833 }
1834 }
1835
1836 if let Some(a_w_rows) = a_w_rows.as_ref() {
1837 for j in 0..link_dev_dim {
1838 row[link_dev_offset + j] = a_w_rows[[i, j]];
1839 }
1840 }
1841
1842 if let (Some(link_c), Some(link_basis)) =
1843 (link_c_obs.as_ref(), link_basis_obs.as_ref())
1844 {
1845 let c = link_c[i];
1846 for j in 0..marginal_dim {
1847 row[j] *= c;
1848 }
1849 for j in 0..link_dev_dim {
1850 row[link_dev_offset + j] =
1851 c * row[link_dev_offset + j] + link_basis[[i, j]];
1852 }
1853 }
1854 }
1855 Ok(())
1856 });
1857 grad_result.map_err(EstimationError::InvalidInput)?;
1858 }
1859 if scale != 1.0 {
1860 grad.mapv_inplace(|v| scale * v);
1861 }
1862 self.transform_internal_eta_to_base_scale(final_eta_internal, Some(grad))
1863 }
1864
1865 pub fn final_eta_from_theta(
1875 &self,
1876 input: &PredictInput,
1877 theta: &Array1<f64>,
1878 ) -> Result<Array1<f64>, EstimationError> {
1879 let (eta, _) = self.final_eta_and_gradient_from_theta(input, theta, false)?;
1880 Ok(eta)
1881 }
1882
1883 pub fn theta_len(&self) -> usize {
1888 self.beta_marginal.len()
1889 + self.beta_logslope.len()
1890 + self.beta_score_warp.as_ref().map_or(0, Array1::len)
1891 + self.beta_link_dev.as_ref().map_or(0, Array1::len)
1892 }
1893
1894 pub fn predict_eta_and_q_chain(
1911 &self,
1912 input: &PredictInput,
1913 ) -> Result<(Array1<f64>, Array1<f64>), EstimationError> {
1914 let z_raw = input.auxiliary_scalar.as_ref().ok_or_else(|| {
1915 EstimationError::InvalidInput(format!(
1916 "bernoulli marginal-slope prediction requires auxiliary z column '{}'",
1917 self.z_column
1918 ))
1919 })?;
1920 let z_normalized = self
1921 .latent_z_normalization
1922 .apply(z_raw, "bernoulli marginal-slope prediction")
1923 .map_err(EstimationError::from)?;
1924 let z = self.apply_latent_z_calibration(&z_normalized);
1930 let z = self.apply_latent_z_conditional_calibration(&z, input)?;
1934 let design_logslope = input.design_noise.as_ref().ok_or_else(|| {
1935 EstimationError::InvalidInput(
1936 "bernoulli marginal-slope prediction requires logslope design".to_string(),
1937 )
1938 })?;
1939 let n = z.len();
1940 if input.offset.len() != n {
1941 return Err(EstimationError::InvalidInput(format!(
1942 "bernoulli marginal-slope prediction primary offset length mismatch: rows={n}, offset={}",
1943 input.offset.len()
1944 )));
1945 }
1946 let logslope_offset = input
1947 .offset_noise
1948 .as_ref()
1949 .map_or_else(|| Array1::zeros(n), Clone::clone);
1950 if logslope_offset.len() != n {
1951 return Err(EstimationError::InvalidInput(format!(
1952 "bernoulli marginal-slope prediction logslope offset length mismatch: rows={n}, offset_noise={}",
1953 logslope_offset.len()
1954 )));
1955 }
1956 let marginal_eta = input
1957 .design
1958 .dot(&self.beta_marginal)
1959 .mapv(|v| v + self.baseline_marginal)
1960 + &input.offset;
1961 let logslope_eta = design_logslope
1962 .dot(&self.beta_logslope)
1963 .mapv(|v| v + self.baseline_logslope)
1964 + &logslope_offset;
1965 let scale = self.probit_frailty_scale();
1966 let flex_active =
1967 self.score_warp_runtime.is_some() || self.link_deviation_runtime.is_some();
1968
1969 if !flex_active {
1972 match &self.latent_measure {
1973 LatentMeasureKind::StandardNormal => {
1974 let sb = logslope_eta.mapv(|x| scale * x);
1977 let deta_dq = sb.mapv(|s| (1.0 + s * s).sqrt());
1978 let eta = &deta_dq * marginal_eta + &sb * z;
1979 return Ok((eta, deta_dq));
1980 }
1981 _ => {
1982 let mut eta = Array1::<f64>::zeros(n);
1983 let mut deta_dq = Array1::<f64>::zeros(n);
1984 for i in 0..n {
1985 let grid = self
1986 .empirical_grid_for_prediction_row(input, i)?
1987 .ok_or_else(|| {
1988 EstimationError::InvalidInput(
1989 "empirical latent prediction did not produce a row grid"
1990 .to_string(),
1991 )
1992 })?;
1993 let (intercept, a_marginal, _) = self
1994 .empirical_rigid_intercept_and_gradient(
1995 marginal_eta[i],
1996 logslope_eta[i],
1997 &grid.nodes,
1998 &grid.weights,
1999 )?;
2000 eta[i] = intercept + scale * logslope_eta[i] * z[i];
2001 deta_dq[i] = a_marginal;
2002 }
2003 return Ok((eta, deta_dq));
2004 }
2005 }
2006 }
2007
2008 let marginal_map = marginal_eta
2014 .iter()
2015 .map(|&eta_marg| {
2016 bernoulli_marginal_link_map(&self.base_link, eta_marg)
2017 .map_err(EstimationError::InvalidInput)
2018 })
2019 .collect::<Result<Vec<_>, _>>()?;
2020 let anchor_corrections =
2023 self.build_anchor_correction_matrices(input, design_logslope, &z)?;
2024 use rayon::iter::{IntoParallelIterator, ParallelIterator};
2028 let pairs: Result<Vec<(f64, f64)>, EstimationError> = (0..n)
2029 .into_par_iter()
2030 .map_init(
2031 || Array1::<f64>::zeros(1),
2032 |warm_start_buf, i| {
2033 let q = marginal_eta[i];
2034 let slope = logslope_eta[i];
2035 let empirical_grid = self.empirical_grid_for_prediction_row(input, i)?;
2036 let score_corr_row = anchor_corrections.score_warp_row(i);
2037 let link_corr_row = anchor_corrections.link_dev_row(i);
2038 let intercept = self.solve_intercept_scalar(
2039 q,
2040 slope,
2041 self.beta_link_dev.as_ref(),
2042 self.beta_score_warp.as_ref(),
2043 empirical_grid.as_ref(),
2044 warm_start_buf,
2045 score_corr_row,
2046 link_corr_row,
2047 )?;
2048 let (_, m_a_raw, _) = self.evaluate_prediction_calibration(
2049 intercept,
2050 q,
2051 slope,
2052 self.beta_score_warp.as_ref(),
2053 self.beta_link_dev.as_ref(),
2054 empirical_grid.as_ref(),
2055 score_corr_row,
2056 link_corr_row,
2057 )?;
2058 let m_a = m_a_raw.max(1e-12);
2059 Ok((intercept, marginal_map[i].mu1 / m_a))
2060 },
2061 )
2062 .collect();
2063 let pairs = pairs?;
2064 let mut intercepts = Array1::<f64>::zeros(n);
2065 let mut a_q = Array1::<f64>::zeros(n);
2066 for (i, (intercept, a)) in pairs.into_iter().enumerate() {
2067 intercepts[i] = intercept;
2068 a_q[i] = a;
2069 }
2070
2071 let score_dev_obs = if let (Some(runtime), Some(beta)) = (
2072 self.score_warp_runtime.as_ref(),
2073 self.beta_score_warp.as_ref(),
2074 ) {
2075 let design = if runtime.anchor_correction.is_some() {
2076 let anchor_rows = anchor_corrections
2077 .score_warp_anchor_rows_view()
2078 .ok_or_else(|| {
2079 EstimationError::InvalidInput(
2080 "bernoulli marginal-slope score-warp anchor residual present but \
2081 anchor_corrections bundle is missing the parametric anchor rows"
2082 .to_string(),
2083 )
2084 })?;
2085 runtime
2086 .design_with_anchor_rows(&z, anchor_rows)
2087 .map_err(EstimationError::from)?
2088 } else {
2089 runtime.design(&z).map_err(EstimationError::from)?
2090 };
2091 design.dot(beta)
2092 } else {
2093 Array1::zeros(n)
2094 };
2095 let eta_base = &intercepts + &(&logslope_eta * &z);
2096 let (link_dev_obs, link_c_obs) = if let (Some(runtime), Some(beta)) = (
2097 self.link_deviation_runtime.as_ref(),
2098 self.beta_link_dev.as_ref(),
2099 ) {
2100 let basis = if runtime.anchor_correction.is_some() {
2101 let anchor_rows =
2102 anchor_corrections
2103 .link_dev_anchor_rows_view()
2104 .ok_or_else(|| {
2105 EstimationError::InvalidInput(
2106 "bernoulli marginal-slope link-deviation anchor residual present but \
2107 anchor_corrections bundle is missing the parametric anchor rows"
2108 .to_string(),
2109 )
2110 })?;
2111 runtime
2112 .design_with_anchor_rows(&eta_base, anchor_rows)
2113 .map_err(EstimationError::from)?
2114 } else {
2115 runtime.design(&eta_base).map_err(EstimationError::from)?
2116 };
2117 let dev = basis.dot(beta);
2118 let d1 = runtime
2119 .first_derivative_design(&eta_base)
2120 .map_err(EstimationError::from)?;
2121 let mut c_obs = d1.dot(beta);
2122 c_obs.mapv_inplace(|v| v + 1.0);
2123 (dev, c_obs)
2124 } else {
2125 (Array1::zeros(n), Array1::ones(n))
2126 };
2127 let final_eta_internal =
2128 (&eta_base + &(&logslope_eta * &score_dev_obs) + &link_dev_obs).mapv(|v| scale * v);
2129 let deta_dq = (&link_c_obs * &a_q).mapv(|v| scale * v);
2130 Ok((final_eta_internal, deta_dq))
2131 }
2132}